Initial commit

This commit is contained in:
Ezri Brimhall 2024-10-31 15:39:18 -06:00
commit cc75318666
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
17 changed files with 1506 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
@girs/
node_modules/

11
app.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

51
widget/services/System.ts Normal file
View 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);
}
}

View 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
View 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}`);
}
}
}

View 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) {}
}

View 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;
}

View 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
View 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
View 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);
}
}
);
});
}