Initial commit
This commit is contained in:
commit
cc75318666
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@girs/
|
||||||
|
node_modules/
|
||||||
11
app.ts
Normal file
11
app.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { App } from "astal/gtk3"
|
||||||
|
import style from "./style.css"
|
||||||
|
import Bar from "./widget/Bar"
|
||||||
|
|
||||||
|
App.start({
|
||||||
|
css: style,
|
||||||
|
main() {
|
||||||
|
Bar(0)
|
||||||
|
// Bar(1) // initialize other monitors
|
||||||
|
},
|
||||||
|
})
|
||||||
16
env.d.ts
vendored
Normal file
16
env.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const SRC: string
|
||||||
|
|
||||||
|
declare module "inline:*" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.scss" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.css" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
9
globals.ts
Normal file
9
globals.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Gtk from "gi://Gtk";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const Align: typeof Gtk.Align;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
Align: Gtk.Align,
|
||||||
|
});
|
||||||
16
style.css
Normal file
16
style.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
window.Bar {
|
||||||
|
background: transparent;
|
||||||
|
color: @theme_fg_color;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Bar>centerbox {
|
||||||
|
background: @theme_bg_color;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Bar button {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
// "checkJs": true,
|
||||||
|
// "allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
|
||||||
|
"paths": {
|
||||||
|
"astal": [
|
||||||
|
"/usr/share/astal/gjs"
|
||||||
|
],
|
||||||
|
"astal/*": [
|
||||||
|
"/usr/share/astal/gjs/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
33
widget/Bar.tsx
Normal file
33
widget/Bar.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { App, Astal, Gtk } from "astal/gtk3";
|
||||||
|
import { Variable } from "astal";
|
||||||
|
import { SystemName } from "./system/Name";
|
||||||
|
|
||||||
|
const time = Variable("").poll(1000, "date");
|
||||||
|
|
||||||
|
export default function Bar(monitor: number) {
|
||||||
|
return (
|
||||||
|
<window
|
||||||
|
className="Bar"
|
||||||
|
monitor={monitor}
|
||||||
|
exclusivity={Astal.Exclusivity.EXCLUSIVE}
|
||||||
|
anchor={
|
||||||
|
Astal.WindowAnchor.TOP |
|
||||||
|
Astal.WindowAnchor.LEFT |
|
||||||
|
Astal.WindowAnchor.RIGHT
|
||||||
|
}
|
||||||
|
application={App}
|
||||||
|
>
|
||||||
|
<centerbox>
|
||||||
|
<button onClicked="echo hello" halign={Gtk.Align.CENTER}>
|
||||||
|
Welcome to AGS!
|
||||||
|
</button>
|
||||||
|
<box>
|
||||||
|
<SystemName />
|
||||||
|
</box>
|
||||||
|
<button onClick={() => print("hello")} halign={Gtk.Align.CENTER}>
|
||||||
|
<label label={time()} />
|
||||||
|
</button>
|
||||||
|
</centerbox>
|
||||||
|
</window>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
widget/MyButton.tsx
Normal file
15
widget/MyButton.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Variable, bind } from "astal";
|
||||||
|
|
||||||
|
export function MyButton(): JSX.Element {
|
||||||
|
const count = Variable(0);
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count.set(count.get() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClicked={increment}>
|
||||||
|
<label label={bind(count).as((num) => `Count: ${num}`)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
widget/globals.ts
Normal file
0
widget/globals.ts
Normal file
51
widget/services/System.ts
Normal file
51
widget/services/System.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Variable, bind } from "astal";
|
||||||
|
import { readFileAsync, monitorFile } from "astal/file";
|
||||||
|
import Gio from "gi://Gio";
|
||||||
|
|
||||||
|
export class System {
|
||||||
|
private static _instance: System;
|
||||||
|
|
||||||
|
private async read_name() {
|
||||||
|
const content = await readFileAsync("/etc/machine-info");
|
||||||
|
|
||||||
|
const lines = content
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.map((line) => /^PRETTY_HOSTNAME="?([^"]*)"?$/.exec(line))
|
||||||
|
.filter((line) => line)
|
||||||
|
.map((line) => line[1]);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
this.#name.set(lines[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
monitorFile("/etc/machine-info", async (file, event) => {
|
||||||
|
console.log("file changed", file, event);
|
||||||
|
switch (event) {
|
||||||
|
case Gio.FileMonitorEvent.CHANGES_DONE_HINT:
|
||||||
|
await this.read_name();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.read_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get_default(): System {
|
||||||
|
if (!System._instance) {
|
||||||
|
System._instance = new System();
|
||||||
|
}
|
||||||
|
return System._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private #name: Variable<string> = Variable("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "pretty" hostname of this computer, as a bound value.
|
||||||
|
*
|
||||||
|
* This value is updated when the `/etc/machine-info` file changes.
|
||||||
|
*/
|
||||||
|
public get name() {
|
||||||
|
return bind(this.#name);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
widget/services/sway/definitions.ts
Normal file
175
widget/services/sway/definitions.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* A workspace definition.
|
||||||
|
*/
|
||||||
|
export interface Workspace {
|
||||||
|
/**
|
||||||
|
* The unique ID of the workspace. This is used as the name of the workspace in Sway,
|
||||||
|
* and also for references in groups.
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* The name of the workspace. This is used for display purposes only.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The program to run in the workspace, not including any arguments.
|
||||||
|
*/
|
||||||
|
exec: string;
|
||||||
|
/**
|
||||||
|
* The name of the program, used to create the transient systemd unit for the program.
|
||||||
|
*/
|
||||||
|
program_name: string;
|
||||||
|
/**
|
||||||
|
* The arguments to pass to the program.
|
||||||
|
*/
|
||||||
|
args?: string[];
|
||||||
|
/**
|
||||||
|
* The environment variables to set for the program.
|
||||||
|
*/
|
||||||
|
environ?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Whether to run the program in a systemd unit. Defaults to true.
|
||||||
|
* If false, the program will be run directly by sway, using the `exec` sway command.
|
||||||
|
* If true, a transient systemd unit will be created for the program via `systemd-run`.
|
||||||
|
*/
|
||||||
|
systemd?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to capture the output of the program and display it in the workspace. Defaults to false.
|
||||||
|
* If true, the output of the program will not be logged. When systemd is enabled, the program will be run in a scope unit rather than a service unit.
|
||||||
|
* If false, the output of the program will be logged. When systemd is disabled, this is done via `systemd-cat`, and the program_name will be used as the tag.
|
||||||
|
*/
|
||||||
|
void_output?: boolean;
|
||||||
|
/**
|
||||||
|
* A memory profile to apply to the program. This can only be applied when systemd is enabled, and will be ignored otherwise.
|
||||||
|
*/
|
||||||
|
memory_profile?: {
|
||||||
|
/**
|
||||||
|
* The "high" memory limit for the program. See `systemd.resource-control(5)` for more information.
|
||||||
|
*/
|
||||||
|
high: string;
|
||||||
|
/**
|
||||||
|
* The "max" memory limit for the program. See `systemd.resource-control(5)` for more information. If exceeded, the program will be killed.
|
||||||
|
*/
|
||||||
|
max: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An output definition, used to match the connected outputs to defined contexts.
|
||||||
|
*
|
||||||
|
* Outputs can be identified in one of two ways:
|
||||||
|
* 1) by make, model, and serial of the physical monitor associated with the output
|
||||||
|
* 2) by compositor-assigned name
|
||||||
|
*
|
||||||
|
* Make, model, and serial guarantees a perfect match, and is thus preferred for static
|
||||||
|
* configurations (e.g. desktop) where these values rarely if ever change. Output names
|
||||||
|
* can be used when perfect matching is undesireable (such as for a presentation context)
|
||||||
|
* or infeasible (a monitor that does not provide one of the above values), or for a
|
||||||
|
* monitor that will always be assigned the same name regardless of system configuration
|
||||||
|
* (such as the eDP-1 output on laptops).
|
||||||
|
*
|
||||||
|
* The special name '*' can be used when any output that is not statically defined is acceptable.
|
||||||
|
* Only one output can be selected via wildcard.
|
||||||
|
*
|
||||||
|
* If an output can be identified both perfectly and by name, the perfect match will take precedence.
|
||||||
|
*/
|
||||||
|
export interface Output {
|
||||||
|
/**
|
||||||
|
* The make of the output. At least one of this or `names` must be present.
|
||||||
|
*/
|
||||||
|
make?: string;
|
||||||
|
/**
|
||||||
|
* The model of the output. At least one of this or `names` must be present.
|
||||||
|
*/
|
||||||
|
model?: string;
|
||||||
|
/**
|
||||||
|
* The serial number of the output. At least one of this or `names` must be present.
|
||||||
|
*/
|
||||||
|
serial?: string;
|
||||||
|
/**
|
||||||
|
* The names that the output can be identified by. Either this or `make`, `model`, and `serial` must be present.
|
||||||
|
*/
|
||||||
|
names?: string[];
|
||||||
|
/**
|
||||||
|
* The workspace group to assign to the output.
|
||||||
|
* If mirroring, a group should only be assigned to one output.
|
||||||
|
*/
|
||||||
|
group?: string;
|
||||||
|
/**
|
||||||
|
* The position of the output in the framebuffer.
|
||||||
|
* If mirroring, this should be the same for all outputs in the group.
|
||||||
|
*/
|
||||||
|
position: [number, number];
|
||||||
|
/**
|
||||||
|
* The output's configuration. This is a string that is passed directly to sway's `output` command.
|
||||||
|
* Position should not be included, as it is set by the `position` field.
|
||||||
|
*/
|
||||||
|
mode: string;
|
||||||
|
/**
|
||||||
|
* The bar windows to open on the output in this context, in the order in which they should be opened.
|
||||||
|
*/
|
||||||
|
bars: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace group definition.
|
||||||
|
*/
|
||||||
|
export interface Group {
|
||||||
|
/**
|
||||||
|
* The workspaces in the group, in the order in which they should be referenced by index.
|
||||||
|
*/
|
||||||
|
workspaces: number[];
|
||||||
|
/**
|
||||||
|
* Whether the workspace order should be reversed when displayed. Defaults to false.
|
||||||
|
*
|
||||||
|
* This does not affect the order of the workspaces in the group, only the order in which they are displayed.
|
||||||
|
* If true, the first workspace will be displayed on the right, and the last workspace will be displayed on the left.
|
||||||
|
*/
|
||||||
|
reverse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context definition.
|
||||||
|
*
|
||||||
|
* "Contexts" are a way to define a set of outputs and workspaces that should be active in a given situation.
|
||||||
|
* The name is a holdover from the original conception of this project, where it was intended to be used to
|
||||||
|
* define different "contexts" in which the system could be used (e.g. "work", "home", "presentation").
|
||||||
|
* While this system can be used for that purpose, its primary use is to define a set of outputs and workspaces
|
||||||
|
* that should be active in a given situation, regardless of the user's intent, so that when the monitor
|
||||||
|
* configuration changes, the system can automatically adjust workspaces and status bars to match.
|
||||||
|
*/
|
||||||
|
export interface Context {
|
||||||
|
/**
|
||||||
|
* The outputs that this context requires.
|
||||||
|
*/
|
||||||
|
outputs: Output[];
|
||||||
|
/**
|
||||||
|
* The name of the "primary" group for this context. This group will be focused when the context is activated if
|
||||||
|
* focus was not originally on a group in this context.
|
||||||
|
*/
|
||||||
|
primary: string;
|
||||||
|
/**
|
||||||
|
* The workspace groups that make up this context.
|
||||||
|
*/
|
||||||
|
groups: Record<string, Group>;
|
||||||
|
/**
|
||||||
|
* The priority of this context, used for tiebreaking when multiple contexts are compatible.
|
||||||
|
* Higher priority contexts will be selected over lower priority contexts. Defaults to 0.
|
||||||
|
*
|
||||||
|
* Behind the scenes, this is added to an internal "score" calculated for each context when matching.
|
||||||
|
* The context with the highest score is selected.
|
||||||
|
* A perfect output match is worth 3 points, a name match is worth 2 points, and a wildcard match is worth 1 point.
|
||||||
|
* Any failed match immediately gives the context a score of 0, and contexts with a score <= 0 are not considered.
|
||||||
|
* As such, this value can be used to give a context a slight advantage in the case of a tie, boost a more general
|
||||||
|
* context over a more specific one, or remove it from consideration entirely.
|
||||||
|
*
|
||||||
|
* This value is not considered when requesting a context by name.
|
||||||
|
*/
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
contexts: Record<string, Context>;
|
||||||
|
default_context: string;
|
||||||
|
}
|
||||||
312
widget/services/sway/ipc.ts
Normal file
312
widget/services/sway/ipc.ts
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import { App } from "astal";
|
||||||
|
import {
|
||||||
|
WorkspaceResponse,
|
||||||
|
OutputResponse,
|
||||||
|
SwayMessage,
|
||||||
|
SwayEvent,
|
||||||
|
ModeEvent,
|
||||||
|
WorkspaceEvent,
|
||||||
|
OutputEvent,
|
||||||
|
} from "./types";
|
||||||
|
import Gdk from "gi://Gdk";
|
||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GLib from "gi://GLib";
|
||||||
|
import * as utils from "../../utils";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "Sway",
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Sway connection singleton that implements the sway IPC protocol at a low level.
|
||||||
|
*/
|
||||||
|
export class Sway extends GObject.Object {
|
||||||
|
private #commandSocket: Gio.SocketConnection | null = null;
|
||||||
|
private #subscriptionSocket: Gio.SocketConnection | null = null;
|
||||||
|
|
||||||
|
private #mode: string = "";
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted when the connection to sway is established
|
||||||
|
*/
|
||||||
|
declare connected: () => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
/**
|
||||||
|
* Emitted when the connection to sway is lost
|
||||||
|
*/
|
||||||
|
declare disconnected: () => void;
|
||||||
|
|
||||||
|
@signal(Array)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive a subscription event
|
||||||
|
*/
|
||||||
|
declare subscribed: (events: SwayEvent[]) => void;
|
||||||
|
|
||||||
|
@signal(Object)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive a workspace event
|
||||||
|
*/
|
||||||
|
declare workspace: (event: WorkspaceEvent) => void;
|
||||||
|
|
||||||
|
@signal(Object)
|
||||||
|
/**
|
||||||
|
* Emitted when we receive an output event
|
||||||
|
*/
|
||||||
|
declare output: (event: OutputEvent) => void;
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
/**
|
||||||
|
* The current binding mode of sway
|
||||||
|
*/
|
||||||
|
get mode(): string {
|
||||||
|
return this.#mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command on sway
|
||||||
|
*/
|
||||||
|
public async command(command: string): Promise<CommandResponse[]> {
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(this.#commandSocket, SwayMessage.RUN_COMMAND, command)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current sway workspaces
|
||||||
|
*/
|
||||||
|
public async getWorkspaces(): Promise<WorkspaceResponse[]> {
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_WORKSPACES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current sway outputs
|
||||||
|
*/
|
||||||
|
public async getOutputs(): Promise<OutputResponse[]> {
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_OUTPUTS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current sway tree
|
||||||
|
*/
|
||||||
|
public async getTree(): Promise<TreeNode> {
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_TREE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
super();
|
||||||
|
this.#connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static #instance: Sway;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default instance of Sway
|
||||||
|
*/
|
||||||
|
static get_default() {
|
||||||
|
if (!Sway.#instance) {
|
||||||
|
Sway.#instance = new Sway();
|
||||||
|
}
|
||||||
|
return Sway.#instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #createConnection(
|
||||||
|
address: string
|
||||||
|
): Promise<Gio.SocketConnection> {
|
||||||
|
const client = new Gio.SocketClient();
|
||||||
|
return new Promise<Gio.SocketConnection>((resolve, reject) => {
|
||||||
|
client.connect_to_uri_async(
|
||||||
|
`unix:${address}`,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
(client, result) => {
|
||||||
|
try {
|
||||||
|
const connection = client.connect_to_uri_finish(result);
|
||||||
|
resolve(connection);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #getMode(): Promise<string> {
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_BINDING_STATE)
|
||||||
|
);
|
||||||
|
return result.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #connect() {
|
||||||
|
const address = GLib.getenv("SWAYSOCK");
|
||||||
|
if (!address) {
|
||||||
|
throw new Error("SWAYSOCK not set");
|
||||||
|
}
|
||||||
|
const socket = new Gio.SocketClient();
|
||||||
|
// Create two connections, one for commands and one for subscriptions. This is so we
|
||||||
|
// can easily distinguish between responses to commands and subscription events.
|
||||||
|
const command = await this.#createConnection(address);
|
||||||
|
const subscription = await this.#createConnection(address);
|
||||||
|
|
||||||
|
this.#commandSocket = command;
|
||||||
|
this.#subscriptionSocket = subscription;
|
||||||
|
this.connected();
|
||||||
|
|
||||||
|
// Get the current mode
|
||||||
|
this.#mode = await this.#getMode();
|
||||||
|
this.notify("mode");
|
||||||
|
|
||||||
|
this.#startSubscriberListen();
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly MAGIC = "i3-ipc";
|
||||||
|
static readonly HEADER_SIZE = Sway.MAGIC.length + 8;
|
||||||
|
static readonly CHUNK_SIZE = 4096;
|
||||||
|
|
||||||
|
private #startSubscriberListen() {
|
||||||
|
if (!this.#subscriptionSocket) {
|
||||||
|
throw new Error("Not connected");
|
||||||
|
}
|
||||||
|
this.#subscribe(
|
||||||
|
SwayEvent.OUTPUT,
|
||||||
|
SwayEvent.WORKSPACE,
|
||||||
|
SwayEvent.MODE,
|
||||||
|
SwayEvent.SHUTDOWN
|
||||||
|
);
|
||||||
|
const inputStream = this.#subscriptionSocket.input_stream;
|
||||||
|
const reader = new Gio.DataInputStream({ base_stream: inputStream });
|
||||||
|
const read = async () => {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const [event, payload] = await this.#readRaw(
|
||||||
|
this.#subscriptionSocket
|
||||||
|
);
|
||||||
|
switch (event) {
|
||||||
|
case SwayEvent.OUTPUT:
|
||||||
|
const event = JSON.parse(payload) as OutputResponse;
|
||||||
|
this.output(event);
|
||||||
|
break;
|
||||||
|
case SwayEvent.WORKSPACE:
|
||||||
|
const event = JSON.parse(payload) as WorkspaceResponse;
|
||||||
|
this.workspace(event);
|
||||||
|
break;
|
||||||
|
case SwayEvent.MODE:
|
||||||
|
const event = JSON.parse(payload) as ModeResponse;
|
||||||
|
this.mode(event);
|
||||||
|
break;
|
||||||
|
case SwayEvent.SHUTDOWN:
|
||||||
|
this.disconnected();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("Unhandled event", event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read event", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #readHeader(
|
||||||
|
stream: Gio.InputStream
|
||||||
|
): Promise<{ length: number; type: SwayMessage | SwayEvent }> {
|
||||||
|
// We read one byte at a time until we have constructed a full magic string, then we read the rest of the header
|
||||||
|
// This allows us to handle partial reads and corrupted data gracefully
|
||||||
|
let idx = 0;
|
||||||
|
const magic = new Uint8Array(Sway.MAGIC.length);
|
||||||
|
while (idx < Sway.MAGIC.length) {
|
||||||
|
const buffer = await utils.readFromStreamRaw(stream, 1);
|
||||||
|
const byte = buffer[0];
|
||||||
|
if (byte !== Sway.MAGIC.charCodeAt(idx)) {
|
||||||
|
// Reset if we don't match the magic string
|
||||||
|
idx = 0;
|
||||||
|
} else {
|
||||||
|
// Otherwise, keep going
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const header = new DataView(await readFromStream(stream, 8).buffer);
|
||||||
|
const length = header.getUint32(0, true);
|
||||||
|
const type = header.getUint32(4, true);
|
||||||
|
return { length, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #readRaw(
|
||||||
|
socket: Gio.SocketConnection
|
||||||
|
): Promise<[SwayMessage | SwayEvent, string]> {
|
||||||
|
const inputStream = socket.input_stream;
|
||||||
|
|
||||||
|
const { length, type } = await this.#readHeader(inputStream);
|
||||||
|
const payloadBuf = utils.readFromStream(inputStream, length);
|
||||||
|
const payload = new TextDecoder().decode(payloadBuf);
|
||||||
|
|
||||||
|
return [type as SwayMessage | SwayEvent, payload];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #sendRaw(
|
||||||
|
socket: Gio.SocketConnection | null,
|
||||||
|
type: SwayMessage,
|
||||||
|
payload: string = ""
|
||||||
|
): Promise<string> {
|
||||||
|
if (!socket || !socket.is_connected()) {
|
||||||
|
throw new Error("Not connected");
|
||||||
|
}
|
||||||
|
if (socket === this.#commandSocket && type === SwayMessage.SUBSCRIBE) {
|
||||||
|
throw new Error("Cannot subscribe on command socket");
|
||||||
|
}
|
||||||
|
// Construct the message
|
||||||
|
const buffer = new ArrayBuffer(Sway.HEADER_SIZE + payload.length);
|
||||||
|
const magicView = new Uint8Array(buffer, 0, Sway.MAGIC.length);
|
||||||
|
const lengthView = new DataView(buffer, Sway.MAGIC.length, 4);
|
||||||
|
const typeView = new DataView(buffer, Sway.MAGIC.length + 4, 4);
|
||||||
|
const payloadView = new Uint8Array(buffer, Sway.HEADER_SIZE);
|
||||||
|
|
||||||
|
magicView.set(Sway.MAGIC.split("").map((c) => c.charCodeAt(0)));
|
||||||
|
lengthView.setUint32(0, payload.length, true);
|
||||||
|
typeView.setUint32(0, type, true);
|
||||||
|
payloadView.set(payload.split("").map((c) => c.charCodeAt(0)));
|
||||||
|
|
||||||
|
const outputStream = this.#commandSocket.output_stream;
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
await utils.writeToStream(outputStream, buffer);
|
||||||
|
|
||||||
|
// Read the response
|
||||||
|
const [resultType, result] = await this.#readRaw(socket);
|
||||||
|
if (resultType !== type) {
|
||||||
|
throw new Error(`Unexpected response type: ${resultType}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async #subscribe(...events: SwayEvent[]) {
|
||||||
|
if (!this.#subscriptionSocket) {
|
||||||
|
throw new Error("Not connected");
|
||||||
|
}
|
||||||
|
const eventNames = events.map((e) => SwayEvent[e].toLowerCase());
|
||||||
|
const payload = JSON.stringify(eventNames);
|
||||||
|
const result = JSON.parse(
|
||||||
|
await this.#sendRaw(
|
||||||
|
this.#subscriptionSocket,
|
||||||
|
SwayMessage.SUBSCRIBE,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
this.subscribed(events);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Failed to subscribe: ${result}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
widget/services/sway/service.ts
Normal file
25
widget/services/sway/service.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { App } from "astal";
|
||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import { Sway } from "./ipc";
|
||||||
|
import * as types from "./types";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayService",
|
||||||
|
})
|
||||||
|
export class SwayService extends GObject.Object {
|
||||||
|
private static #instance: SwayService;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Initialize the IPC and register event listeners
|
||||||
|
Sway.get_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get_default(): SwayService {
|
||||||
|
if (!SwayService.#instance) {
|
||||||
|
SwayService.#instance = new SwayService();
|
||||||
|
}
|
||||||
|
return SwayService.#instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private #onWorkspaceEvent(event: types.WorkspaceEvent) {}
|
||||||
|
}
|
||||||
402
widget/services/sway/types.ts
Normal file
402
widget/services/sway/types.ts
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import Gio from "gi://Gio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sway IPC message types
|
||||||
|
*
|
||||||
|
* @see man:sway-ipc(7)
|
||||||
|
*/
|
||||||
|
export enum SwayMessage {
|
||||||
|
/**
|
||||||
|
* Runs the payload as sway commands
|
||||||
|
*/
|
||||||
|
RUN_COMMAND = 0,
|
||||||
|
/**
|
||||||
|
* Get the list of current workspaces
|
||||||
|
*/
|
||||||
|
GET_WORKSPACES = 1,
|
||||||
|
SUBSCRIBE = 2,
|
||||||
|
GET_OUTPUTS = 3,
|
||||||
|
GET_TREE = 4,
|
||||||
|
GET_MARKS = 5,
|
||||||
|
GET_BAR_CONFIG = 6,
|
||||||
|
GET_VERSION = 7,
|
||||||
|
GET_BINDING_MODES = 8,
|
||||||
|
GET_CONFIG = 9,
|
||||||
|
SEND_TICK = 10,
|
||||||
|
SYNC = 11,
|
||||||
|
GET_BINDING_STATE = 12,
|
||||||
|
GET_INPUTS = 100,
|
||||||
|
GET_SEATS = 101,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SwayEvent {
|
||||||
|
WORKSPACE = 0x80000000,
|
||||||
|
OUTPUT = 0x80000001,
|
||||||
|
MODE = 0x80000002,
|
||||||
|
WINDOW = 0x80000003,
|
||||||
|
BARCONFIG_UPDATE = 0x80000004,
|
||||||
|
BINDING = 0x80000005,
|
||||||
|
SHUTDOWN = 0x80000006,
|
||||||
|
TICK = 0x80000007,
|
||||||
|
BAR_STATE_UPDATE = 0x80000014,
|
||||||
|
INPUT = 0x80000015,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SubpixelHinting {
|
||||||
|
NONE = "none",
|
||||||
|
RGB = "rgb",
|
||||||
|
BGR = "bgr",
|
||||||
|
VRGB = "vrgb",
|
||||||
|
VBGR = "vbgr",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutputTransform =
|
||||||
|
| "normal"
|
||||||
|
| "90"
|
||||||
|
| "180"
|
||||||
|
| "270"
|
||||||
|
| "flipped"
|
||||||
|
| "flipped-90"
|
||||||
|
| "flipped-180"
|
||||||
|
| "flipped-270";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rectangle with a top-left corner at (x, y) and a width and height. Defines
|
||||||
|
* a region of the screen.
|
||||||
|
*/
|
||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response to a command.
|
||||||
|
*/
|
||||||
|
export interface CommandResponse {
|
||||||
|
/**
|
||||||
|
* Whether the command was successful
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
/**
|
||||||
|
* The error message if the command was not successful.
|
||||||
|
* Undefined if the command was successful.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
/**
|
||||||
|
* Whether the command was not successful due to a parse error.
|
||||||
|
* Undefined if the command was successful.
|
||||||
|
*/
|
||||||
|
parse_error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace as returned by GET_WORKSPACES
|
||||||
|
*/
|
||||||
|
export interface WorkspaceResponse {
|
||||||
|
/**
|
||||||
|
* The name of the workspace
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The number of the workspace
|
||||||
|
*/
|
||||||
|
num: number;
|
||||||
|
/**
|
||||||
|
* Whether the workspace is focused
|
||||||
|
*/
|
||||||
|
focused: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the workspace is visible
|
||||||
|
*/
|
||||||
|
visible: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the workspace is urgent
|
||||||
|
*/
|
||||||
|
urgent: boolean;
|
||||||
|
/**
|
||||||
|
* The output the workspace is on
|
||||||
|
*/
|
||||||
|
output: string;
|
||||||
|
/**
|
||||||
|
* The rect of the workspace
|
||||||
|
*/
|
||||||
|
rect: Rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sway output's modes as returned by GET_OUTPUTS
|
||||||
|
*/
|
||||||
|
export interface OutputMode {
|
||||||
|
/**
|
||||||
|
* The width of the mode in pixels
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* The height of the mode in pixels
|
||||||
|
*/
|
||||||
|
height: number;
|
||||||
|
/**
|
||||||
|
* The refresh rate of the mode in millihertz
|
||||||
|
*/
|
||||||
|
refresh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sway output as returned by GET_OUTPUTS
|
||||||
|
*/
|
||||||
|
export interface OutputResponse {
|
||||||
|
/**
|
||||||
|
* The name of the output.
|
||||||
|
*
|
||||||
|
* This is based on the hardware location of the output, e.g. "HDMI-A-1".
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The make of the output, e.g. "Dell".
|
||||||
|
*/
|
||||||
|
make: string;
|
||||||
|
/**
|
||||||
|
* The model of the output, e.g. "U2412M".
|
||||||
|
*/
|
||||||
|
model: string;
|
||||||
|
/**
|
||||||
|
* The serial number of the output.
|
||||||
|
*/
|
||||||
|
serial: string;
|
||||||
|
/**
|
||||||
|
* Whether this output is active/enabled.
|
||||||
|
*/
|
||||||
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Whether this output is on/off.
|
||||||
|
*/
|
||||||
|
power: boolean;
|
||||||
|
/**
|
||||||
|
* The scale of the output. Will be -1 if the output is disabled.
|
||||||
|
*/
|
||||||
|
scale: number;
|
||||||
|
/**
|
||||||
|
* The subpixel hinting of the output.
|
||||||
|
*/
|
||||||
|
subpixel_hinting: SubpixelHinting;
|
||||||
|
/**
|
||||||
|
* The current transform of the output.
|
||||||
|
*/
|
||||||
|
transform: OutputTransform;
|
||||||
|
/**
|
||||||
|
* The name of the workspace currently visible on the output.
|
||||||
|
*/
|
||||||
|
current_workspace: string;
|
||||||
|
/**
|
||||||
|
* The bounds of the output.
|
||||||
|
*/
|
||||||
|
rect: Rect;
|
||||||
|
/**
|
||||||
|
* The modes supported by the output.
|
||||||
|
*/
|
||||||
|
modes: OutputMode[];
|
||||||
|
/**
|
||||||
|
* The current mode of the output.
|
||||||
|
*/
|
||||||
|
current_mode: OutputMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeType {
|
||||||
|
ROOT = "root",
|
||||||
|
OUTPUT = "output",
|
||||||
|
WORKSPACE = "workspace",
|
||||||
|
CONTAINER = "con",
|
||||||
|
FLOATING_CONTAINER = "floating_con",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeBorder {
|
||||||
|
NONE = "none",
|
||||||
|
PIXEL = "pixel",
|
||||||
|
NORMAL = "normal",
|
||||||
|
CSD = "csd",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeLayout {
|
||||||
|
SPLITH = "splith",
|
||||||
|
SPLITV = "splitv",
|
||||||
|
STACKED = "stacked",
|
||||||
|
TABBED = "tabbed",
|
||||||
|
OUTPUT = "output",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeOrientation {
|
||||||
|
HORIZONTAL = "horizontal",
|
||||||
|
VERTICAL = "vertical",
|
||||||
|
NONE = "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeFullscreenMode {
|
||||||
|
NONE = 0,
|
||||||
|
WORKSPACE = 1,
|
||||||
|
GLOBAL = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XWaylandWindowProperties {
|
||||||
|
class: string;
|
||||||
|
instance: string;
|
||||||
|
title: string;
|
||||||
|
window_role: string;
|
||||||
|
window_type: string;
|
||||||
|
transient_for: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sway node as returned by GET_TREE
|
||||||
|
*/
|
||||||
|
export interface TreeNode {
|
||||||
|
id: number;
|
||||||
|
name: number;
|
||||||
|
type: NodeType;
|
||||||
|
border: NodeBorder;
|
||||||
|
current_border_width: number;
|
||||||
|
layout: NodeLayout;
|
||||||
|
orientation: NodeOrientation;
|
||||||
|
percent: number | null;
|
||||||
|
rect: Rect;
|
||||||
|
window_rect: Rect;
|
||||||
|
deco_rect: Rect;
|
||||||
|
geometry: Rect;
|
||||||
|
urgent?: boolean;
|
||||||
|
sticky: boolean;
|
||||||
|
marks: string[];
|
||||||
|
focused: boolean;
|
||||||
|
focus: number[];
|
||||||
|
nodes: TreeNode[];
|
||||||
|
representation?: string;
|
||||||
|
fullscreen_mode?: NodeFullscreenMode;
|
||||||
|
app_id?: string | null;
|
||||||
|
pid?: number;
|
||||||
|
visible?: boolean;
|
||||||
|
shell?: "xdg_shell" | "xwayland";
|
||||||
|
inhibit_idle?: boolean;
|
||||||
|
window?: number;
|
||||||
|
window_properties?: XWaylandWindowProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InputType {
|
||||||
|
KEYBOARD = "keyboard",
|
||||||
|
POINTER = "pointer",
|
||||||
|
TOUCH = "touch",
|
||||||
|
TABLET_TOOL = "tablet_tool",
|
||||||
|
TABLET_PAD = "tablet_pad",
|
||||||
|
SWITCH = "switch",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LibInputAccelProfile {
|
||||||
|
NONE = "none",
|
||||||
|
FLAT = "flat",
|
||||||
|
ADAPTIVE = "adaptive",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LibInputBool {
|
||||||
|
TRUE = "enabled",
|
||||||
|
FALSE = "disabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration of a libinput device as returned by GET_INPUTS
|
||||||
|
*/
|
||||||
|
export interface LibInputDevice {
|
||||||
|
send_events: "enabled" | "disabled" | "disabled_on_external_mouse";
|
||||||
|
tap: LibInputBool;
|
||||||
|
tap_button_map: "lmr" | "lrm";
|
||||||
|
tap_drag: LibInputBool;
|
||||||
|
tap_drag_lock: LibInputBool;
|
||||||
|
accel_speed: number;
|
||||||
|
accel_profile: LibInputAccelProfile;
|
||||||
|
natural_scroll: LibInputBool;
|
||||||
|
left_handed: LibInputBool;
|
||||||
|
click_method: "none" | "button_areas" | "clickfinger";
|
||||||
|
middle_emulation: LibInputBool;
|
||||||
|
scroll_method: "none" | "two_finger" | "edge" | "on_button_down";
|
||||||
|
scroll_button: number;
|
||||||
|
scroll_button_lock: LibInputBool;
|
||||||
|
dwt: LibInputBool;
|
||||||
|
dwtp: LibInputBool;
|
||||||
|
calibration_matrix: [number, number, number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sway input device as returned by GET_INPUTS
|
||||||
|
*/
|
||||||
|
export interface InputResponse {
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
vendor: number;
|
||||||
|
product: number;
|
||||||
|
type: InputType;
|
||||||
|
xkb_active_layout_name?: string;
|
||||||
|
xkb_layout_names?: string[];
|
||||||
|
xkb_active_layout_index?: number;
|
||||||
|
scroll_factor?: number;
|
||||||
|
libinput?: LibInputDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WorkspaceEventChange {
|
||||||
|
INIT = "init",
|
||||||
|
EMPTY = "empty",
|
||||||
|
FOCUS = "focus",
|
||||||
|
MOVE = "move",
|
||||||
|
RENAME = "rename",
|
||||||
|
URGENT = "urgent",
|
||||||
|
RELOAD = "reload",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceEvent {
|
||||||
|
change: WorkspaceEventChange;
|
||||||
|
current: WorkspaceResponse;
|
||||||
|
old: WorkspaceResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutputEvent {
|
||||||
|
/**
|
||||||
|
* The change that triggered the event. Currently, this is always "unspecified".
|
||||||
|
* @todo: Update with new values when sway adds them
|
||||||
|
*/
|
||||||
|
change: "unspecified";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModeEvent {
|
||||||
|
/**
|
||||||
|
* The new binding mode.
|
||||||
|
*/
|
||||||
|
change: string;
|
||||||
|
pango_markup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WindowEventChange {
|
||||||
|
NEW = "new",
|
||||||
|
CLOSE = "close",
|
||||||
|
FOCUS = "focus",
|
||||||
|
TITLE = "title",
|
||||||
|
FULLSCREEN_MODE = "fullscreen_mode",
|
||||||
|
MOVE = "move",
|
||||||
|
FLOATING = "floating",
|
||||||
|
URGENT = "urgent",
|
||||||
|
MARK = "mark",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowEvent {
|
||||||
|
change: WindowEventChange;
|
||||||
|
container: TreeNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InputEventChange {
|
||||||
|
ADDED = "added",
|
||||||
|
REMOVED = "removed",
|
||||||
|
XKB_KEYMAP = "xkb_keymap",
|
||||||
|
XKB_LAYOUT = "xkb_layout",
|
||||||
|
LIBINPUT_CONFIG = "libinput_config",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputEvent {
|
||||||
|
change: InputEventChange;
|
||||||
|
input: InputResponse;
|
||||||
|
}
|
||||||
343
widget/services/sway/workspaces.ts
Normal file
343
widget/services/sway/workspaces.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import GObject, { register, property, signal } from "astal/gobject";
|
||||||
|
import { App } from "astal";
|
||||||
|
import { readFileAsync, monitorFile } from "astal/file";
|
||||||
|
import * as types from "./types";
|
||||||
|
import * as defs from "./definitions";
|
||||||
|
import Gdk from "gi://Gdk";
|
||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GLib from "gi://GLib";
|
||||||
|
import { Sway } from "./ipc";
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspace",
|
||||||
|
})
|
||||||
|
export class Workspace extends GObject.Object {
|
||||||
|
private #active: boolean = false;
|
||||||
|
private #focused: boolean = false;
|
||||||
|
private #visible: boolean = false;
|
||||||
|
private #urgent: boolean = false;
|
||||||
|
|
||||||
|
private #groups: Group[] = [];
|
||||||
|
private #currentGroup: Group | null = null;
|
||||||
|
private #currentOutput: string | null = null;
|
||||||
|
|
||||||
|
constructor(public readonly definition: defs.Workspace) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get active() {
|
||||||
|
return this.#active;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get focused() {
|
||||||
|
return this.#focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get visible() {
|
||||||
|
return this.#visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get urgent() {
|
||||||
|
return this.#urgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ $gtype: "SwayWorkspaceGroup" })
|
||||||
|
/**
|
||||||
|
* The group that this workspace is currently assigned to.
|
||||||
|
*
|
||||||
|
* While a workspace can be in multiple groups, including multiple groups
|
||||||
|
* in the same context, it can only be assigned to one group at a time. If
|
||||||
|
* activated in a different group in the same context, it will be reassigned
|
||||||
|
* to that group.
|
||||||
|
*
|
||||||
|
* An inactive workspace will not have a current group, even if it only belongs
|
||||||
|
* to a single group in the active context.
|
||||||
|
*/
|
||||||
|
get currentGroup() {
|
||||||
|
return this.#currentGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(String)
|
||||||
|
get currentOutput() {
|
||||||
|
return this.#currentGroup?.outputName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event: types.WorkspaceEvent) {
|
||||||
|
if (
|
||||||
|
event.change === types.WorkspaceEventChange.EMPTY &&
|
||||||
|
event.current?.name == this.definition.index.toString()
|
||||||
|
) {
|
||||||
|
// If the workspace was emptied, deactivate it.
|
||||||
|
this.#active = false;
|
||||||
|
this.#focused = false;
|
||||||
|
this.#visible = false;
|
||||||
|
this.#urgent = false;
|
||||||
|
this.#currentGroup = null;
|
||||||
|
this.#currentOutput = null;
|
||||||
|
this.notify("active");
|
||||||
|
this.notify("focused");
|
||||||
|
this.notify("visible");
|
||||||
|
this.notify("urgent");
|
||||||
|
this.notify("current-group");
|
||||||
|
this.notify("current-output");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update =
|
||||||
|
event.current?.name === this.definition.index.toString()
|
||||||
|
? event.current
|
||||||
|
: event.old;
|
||||||
|
if (update !== undefined) {
|
||||||
|
this.parseUpdate(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseUpdate(update: types.WorkspaceResponse) {
|
||||||
|
if (!this.#active) {
|
||||||
|
this.#active = true;
|
||||||
|
this.notify("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#focused !== update.focused) {
|
||||||
|
this.#focused = update.focused;
|
||||||
|
this.notify("focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#visible !== update.visible) {
|
||||||
|
this.#visible = update.visible;
|
||||||
|
this.notify("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#urgent !== update.urgent) {
|
||||||
|
this.#urgent = update.urgent;
|
||||||
|
this.notify("urgent");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#currentOutput !== update.output) {
|
||||||
|
this.#currentGroup =
|
||||||
|
this.#groups.find((group) => group.outputName === update.output) ??
|
||||||
|
// Default to null, rather than undefined.
|
||||||
|
null;
|
||||||
|
this.notify("current-group");
|
||||||
|
this.notify("current-output");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async relocate(to: Group) {
|
||||||
|
if (this.#currentGroup === to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#active) {
|
||||||
|
// If the workspace is active, we need to move it to the new group.
|
||||||
|
await Sway.command(
|
||||||
|
`workspace ${this.#index}; move workspace to output ${to.outputName}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If the workspace is inactive, we can simply assign it to the new group.
|
||||||
|
await Sway.command(`workspace ${this.#index} output ${to.outputName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceGroup",
|
||||||
|
})
|
||||||
|
export class Group extends GObject.Object {
|
||||||
|
private #workspaces: Workspace[] = [];
|
||||||
|
private #context: Context;
|
||||||
|
private #outputName: string | null = null;
|
||||||
|
|
||||||
|
private #monitor: Gdk.Monitor | null = null;
|
||||||
|
|
||||||
|
@property({ $gtype: "SwayWorkspaceContext" })
|
||||||
|
get context() {
|
||||||
|
return this.#context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Workspace)
|
||||||
|
get focusedWorkspace() {
|
||||||
|
return this.#workspaces.find((workspace) => workspace.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Workspace)
|
||||||
|
get focused() {
|
||||||
|
return this.focusedWorkspace?.focused ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Gdk.Monitor)
|
||||||
|
get monitor() {
|
||||||
|
return this.#monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare focused: () => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare unfocused: () => void;
|
||||||
|
|
||||||
|
@signal(Gdk.Monitor)
|
||||||
|
declare activated: (monitor: Gdk.Monitor) => void;
|
||||||
|
|
||||||
|
@signal()
|
||||||
|
declare deactivated: () => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly definition: defs.Group,
|
||||||
|
public readonly outputDefinition: defs.Output,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.#context = context;
|
||||||
|
this.#workspaces = definition.workspaces.map((index) =>
|
||||||
|
Tree.instance.workspaces.get(index.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#workspaces.forEach((workspace) => {
|
||||||
|
workspace.connect(
|
||||||
|
"notify::visible",
|
||||||
|
// Bind the function, just to be safe.
|
||||||
|
(() => {
|
||||||
|
if (workspace.visible) {
|
||||||
|
this.notify("focused-workspace");
|
||||||
|
}
|
||||||
|
}).bind(this)
|
||||||
|
);
|
||||||
|
workspace.connect(
|
||||||
|
"notify::focused",
|
||||||
|
(() => {
|
||||||
|
if (workspace === this.focusedWorkspace) {
|
||||||
|
this.notify("focused");
|
||||||
|
}
|
||||||
|
}).bind(this)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the score of this group based on the given Sway output.
|
||||||
|
*/
|
||||||
|
score(output: types.OutputResponse): [number, number] {
|
||||||
|
if (
|
||||||
|
this.outputDefinition.make === output.make &&
|
||||||
|
this.outputDefinition.model === output.model &&
|
||||||
|
this.outputDefinition.serial === output.serial
|
||||||
|
) {
|
||||||
|
// Perfect match scores 3.
|
||||||
|
return [3, 0];
|
||||||
|
}
|
||||||
|
const nameMatch = this.outputDefinition.names.findIndex(
|
||||||
|
(name) => name === output.name
|
||||||
|
);
|
||||||
|
if (nameMatch !== -1) {
|
||||||
|
// Name match scores 2.
|
||||||
|
return [2, nameMatch];
|
||||||
|
}
|
||||||
|
if (this.outputDefinition.names.includes("*")) {
|
||||||
|
// Wildcard match scores 1.
|
||||||
|
return [1, 0];
|
||||||
|
}
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputPreferences(outputs: types.OutputResponse[]) {
|
||||||
|
// Highest preference goes to a perfect match
|
||||||
|
const scores = outputs
|
||||||
|
.map((output) => [output, ...this.score(output)] as const)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a[1] === b[1]) {
|
||||||
|
return a[2] - b[2];
|
||||||
|
}
|
||||||
|
return b[1] - a[1];
|
||||||
|
})
|
||||||
|
.filter(([_, score]) => score > 0)
|
||||||
|
.map(([output]) => output);
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(output: types.OutputResponse) {
|
||||||
|
// Ensure that this output is compatible with the group.
|
||||||
|
const [score, _] = this.score(output);
|
||||||
|
if (score === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Output ${output.name} is not compatible with this group.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure monitor
|
||||||
|
await Sway.get_default().command(
|
||||||
|
`output ${output.name} pos ${this.outputDefinition.x} ${this.outputDefinition.y} ${this.outputDefinition.mode}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable monitor.
|
||||||
|
await Sway.get_default().command(`output ${output.name} enable`);
|
||||||
|
|
||||||
|
// Store monitor name.
|
||||||
|
this.#outputName = output.name;
|
||||||
|
|
||||||
|
// Retrieve GDK monitor.
|
||||||
|
const monitor = Gdk.Display.get_default().get_monitor_at_point(
|
||||||
|
this.outputDefinition.x,
|
||||||
|
this.outputDefinition.y
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify listeners.
|
||||||
|
this.#monitor = monitor;
|
||||||
|
this.activated(monitor);
|
||||||
|
this.notify("monitor");
|
||||||
|
}
|
||||||
|
|
||||||
|
async focus() {
|
||||||
|
if (this.#outputName === null) {
|
||||||
|
throw new Error("Group is not activated.");
|
||||||
|
}
|
||||||
|
await Sway.get_default().command(`focus output ${this.#outputName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceContext",
|
||||||
|
})
|
||||||
|
class Context extends GObject.Object {
|
||||||
|
private #groups: Map<string, Group> = new Map();
|
||||||
|
private #focusedGroup: Group | null = null;
|
||||||
|
|
||||||
|
constructor(public readonly definition: defs.Context) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(Group)
|
||||||
|
get focusedGroup() {
|
||||||
|
return this.#focusedGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@register({
|
||||||
|
GTypeName: "SwayWorkspaceTree",
|
||||||
|
})
|
||||||
|
class Tree extends GObject.Object {
|
||||||
|
private #workspaces: Map<string, Workspace> = new Map();
|
||||||
|
private #contexts: Map<string, Context> = new Map();
|
||||||
|
|
||||||
|
private static #instance: Tree | null = null;
|
||||||
|
|
||||||
|
public get workspaces() {
|
||||||
|
return this.#workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
private async #parseFile(path: string) {
|
||||||
|
const contents = await readFileAsync(path);
|
||||||
|
const data = JSON.parse(contents) as defs.Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get instance() {
|
||||||
|
if (!Tree.#instance) {
|
||||||
|
Tree.#instance = new Tree();
|
||||||
|
}
|
||||||
|
return Tree.#instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
widget/system/Name.tsx
Normal file
7
widget/system/Name.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { System } from "../services/System";
|
||||||
|
|
||||||
|
export function SystemName() {
|
||||||
|
const system = System.get_default();
|
||||||
|
system.name.subscribe(console.log);
|
||||||
|
return <label label={system.name} />;
|
||||||
|
}
|
||||||
68
widget/utils.ts
Normal file
68
widget/utils.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import Gio from "gi://Gio";
|
||||||
|
import GLib from "gi://GLib";
|
||||||
|
|
||||||
|
export async function readFromStreamRaw(
|
||||||
|
stream: Gio.InputStream,
|
||||||
|
bytes: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
const buffer = new Uint8Array(bytes);
|
||||||
|
stream.read_bytes_async(
|
||||||
|
bytes,
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
null,
|
||||||
|
(stream, result) => {
|
||||||
|
try {
|
||||||
|
const data = stream.read_bytes_finish(result);
|
||||||
|
data.get_data(buffer);
|
||||||
|
resolve(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFromStream(
|
||||||
|
stream: Gio.InputStream,
|
||||||
|
bytes: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
const chunkCount = Math.ceil(bytes / 4096);
|
||||||
|
const buffer = await Array.from({ length: chunkCount }, (_, i) => i).reduce(
|
||||||
|
(acc, i) => {
|
||||||
|
const buffer = await acc;
|
||||||
|
const chunkSize = Math.min(4096, bytes - i * 4096);
|
||||||
|
const chunk = await readFromStreamRaw(stream, chunkSize);
|
||||||
|
buffer.set(chunk, i * 4096);
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
Promise.resolve(new Uint8Array(bytes))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeToStream(
|
||||||
|
stream: Gio.OutputStream,
|
||||||
|
data: ArrayBuffer | Uint8Array
|
||||||
|
): Promise<void> {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
data = new Uint8Array(data);
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
stream.write_all_async(
|
||||||
|
data,
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
null,
|
||||||
|
(stream, result) => {
|
||||||
|
try {
|
||||||
|
stream.write_all_finish(result);
|
||||||
|
resolve();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user