From cc753186667051eb740783bbf847b81990f5bc61 Mon Sep 17 00:00:00 2001 From: Ezri Brimhall Date: Thu, 31 Oct 2024 15:39:18 -0600 Subject: [PATCH] Initial commit --- .gitignore | 2 + app.ts | 11 + env.d.ts | 16 ++ globals.ts | 9 + style.css | 16 ++ tsconfig.json | 21 ++ widget/Bar.tsx | 33 +++ widget/MyButton.tsx | 15 ++ widget/globals.ts | 0 widget/services/System.ts | 51 ++++ widget/services/sway/definitions.ts | 175 ++++++++++++ widget/services/sway/ipc.ts | 312 +++++++++++++++++++++ widget/services/sway/service.ts | 25 ++ widget/services/sway/types.ts | 402 ++++++++++++++++++++++++++++ widget/services/sway/workspaces.ts | 343 ++++++++++++++++++++++++ widget/system/Name.tsx | 7 + widget/utils.ts | 68 +++++ 17 files changed, 1506 insertions(+) create mode 100644 .gitignore create mode 100644 app.ts create mode 100644 env.d.ts create mode 100644 globals.ts create mode 100644 style.css create mode 100644 tsconfig.json create mode 100644 widget/Bar.tsx create mode 100644 widget/MyButton.tsx create mode 100644 widget/globals.ts create mode 100644 widget/services/System.ts create mode 100644 widget/services/sway/definitions.ts create mode 100644 widget/services/sway/ipc.ts create mode 100644 widget/services/sway/service.ts create mode 100644 widget/services/sway/types.ts create mode 100644 widget/services/sway/workspaces.ts create mode 100644 widget/system/Name.tsx create mode 100644 widget/utils.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6850183 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +@girs/ +node_modules/ \ No newline at end of file diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..0d0f4b9 --- /dev/null +++ b/app.ts @@ -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 + }, +}) diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..bdd011e --- /dev/null +++ b/env.d.ts @@ -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 +} diff --git a/globals.ts b/globals.ts new file mode 100644 index 0000000..8fa92d2 --- /dev/null +++ b/globals.ts @@ -0,0 +1,9 @@ +import Gtk from "gi://Gtk"; + +declare global { + const Align: typeof Gtk.Align; +} + +Object.assign(globalThis, { + Align: Gtk.Align, +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..cf3bbe5 --- /dev/null +++ b/style.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d6a915 --- /dev/null +++ b/tsconfig.json @@ -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/*" + ] + }, + } +} diff --git a/widget/Bar.tsx b/widget/Bar.tsx new file mode 100644 index 0000000..1d5b50f --- /dev/null +++ b/widget/Bar.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/widget/MyButton.tsx b/widget/MyButton.tsx new file mode 100644 index 0000000..7594662 --- /dev/null +++ b/widget/MyButton.tsx @@ -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 ( + + ); +} diff --git a/widget/globals.ts b/widget/globals.ts new file mode 100644 index 0000000..e69de29 diff --git a/widget/services/System.ts b/widget/services/System.ts new file mode 100644 index 0000000..b03a3ce --- /dev/null +++ b/widget/services/System.ts @@ -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 = 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); + } +} diff --git a/widget/services/sway/definitions.ts b/widget/services/sway/definitions.ts new file mode 100644 index 0000000..afc3c12 --- /dev/null +++ b/widget/services/sway/definitions.ts @@ -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; + /** + * 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; + /** + * 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; + default_context: string; +} diff --git a/widget/services/sway/ipc.ts b/widget/services/sway/ipc.ts new file mode 100644 index 0000000..237bd93 --- /dev/null +++ b/widget/services/sway/ipc.ts @@ -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 { + const result = JSON.parse( + await this.#sendRaw(this.#commandSocket, SwayMessage.RUN_COMMAND, command) + ); + } + + /** + * Get the current sway workspaces + */ + public async getWorkspaces(): Promise { + const result = JSON.parse( + await this.#sendRaw(this.#commandSocket, SwayMessage.GET_WORKSPACES) + ); + } + + /** + * Get the current sway outputs + */ + public async getOutputs(): Promise { + const result = JSON.parse( + await this.#sendRaw(this.#commandSocket, SwayMessage.GET_OUTPUTS) + ); + } + + /** + * Get the current sway tree + */ + public async getTree(): Promise { + 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 { + const client = new Gio.SocketClient(); + return new Promise((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 { + 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 { + 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}`); + } + } +} diff --git a/widget/services/sway/service.ts b/widget/services/sway/service.ts new file mode 100644 index 0000000..4f8e7d1 --- /dev/null +++ b/widget/services/sway/service.ts @@ -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) {} +} diff --git a/widget/services/sway/types.ts b/widget/services/sway/types.ts new file mode 100644 index 0000000..8e6cf5c --- /dev/null +++ b/widget/services/sway/types.ts @@ -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; +} diff --git a/widget/services/sway/workspaces.ts b/widget/services/sway/workspaces.ts new file mode 100644 index 0000000..08397e0 --- /dev/null +++ b/widget/services/sway/workspaces.ts @@ -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 = 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 = new Map(); + private #contexts: Map = 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; + } +} diff --git a/widget/system/Name.tsx b/widget/system/Name.tsx new file mode 100644 index 0000000..44df243 --- /dev/null +++ b/widget/system/Name.tsx @@ -0,0 +1,7 @@ +import { System } from "../services/System"; + +export function SystemName() { + const system = System.get_default(); + system.name.subscribe(console.log); + return