diff --git a/pinenote-shell/README.md b/pinenote-shell/README.md index a01bb4c..ff9b73f 100644 --- a/pinenote-shell/README.md +++ b/pinenote-shell/README.md @@ -16,18 +16,22 @@ This Astal-based GTK application provides: ## Requirements - **OS**: Arch Linux with Hyprland -- **Desktop Environment**: Astal/GJS environment -- **Dependencies**: GJS, GTK 4, hyprgrass +- **Desktop Environment**: AGS (Aylur's GTK Shell) v3 / Astal +- **Dependencies**: GJS, GTK 3, Astal (`libastal-*-git`), `ags`, hyprgrass - **Display**: Pine64 PineNote (or any compatible monitor) ## Installation ### Prerequisites +Astal and AGS are not distributed via npm. Install the system packages from the AUR: + ```bash -aur install ags-node -# or for Arch Linux: -sudo pacman -S gjs +# Install AGS and Astal core libraries (GTK3 + GJS bindings) +yay -S ags libastal-meta libastal-gjs-git libastal-hyprland-git libastal-brightness-git + +# Base GTK/Layer-Shell support +sudo pacman -S gjs gtk3 gtk-layer-shell ``` ### Clone and Setup @@ -36,28 +40,50 @@ sudo pacman -S gjs cd ~/projects/pineNoteShell # Project already set up in pinenote-shell/ -# Install dependencies -npm install --prefix ./pinenote-shell -cd pinenote-shell && npm install +# No npm dependencies are required +cd pinenote-shell # Make widget executable chmod +x ./src/main.js -gjs --set-mode=auto-start +``` + +> **Note:** `npm install` is not needed for this project. The previous `package.json` referenced a package (`@agi/gtk3`) that does not exist on the npm registry. + +## Running + +The entry point uses an `ags run` shebang, so you can start it directly: + +```bash +./src/main.js +``` + +Or use the `ags` CLI explicitly: + +```bash +ags run ./src/main.js +``` + +You can toggle the window from another terminal: + +```bash +ags toggle pinenote-fullscreen -i pinenote-shell ``` ### Hyprland Configuration Add the following to your `~/.config/hypr/hyprgrass.conf`: +> **Note:** AGS v3 uses `ags toggle` / `ags request` instead of the old `--js-evaluate` flag. + ```ini [gesture:toggle_widget] hotkeys = [Mod1, T] shorthand = "pinch open" actions = [ { - command = "exec ags -t pinenote-shell", + command = "exec ags toggle pinenote-fullscreen -i pinenote-shell", timeout = 300, -target = "window:pinenote-fullscreen" + target = "window:pinenote-fullscreen" } ] @@ -66,9 +92,9 @@ hotkeys = [Mod1, B] shorthand = "swipe up" actions = [ { - command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(100)'", + command = "exec ags request 'brightness 100' -i pinenote-shell", timeout = 300, -target = "window:pinenote-fullscreen" + target = "window:pinenote-fullscreen" } ] @@ -77,13 +103,35 @@ hotkeys = [Mod1, V] shorthand = "swipe down" actions = [ { - command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(0)'", + command = "exec ags request 'brightness 0' -i pinenote-shell", timeout = 300, -target = "window:pinenote-fullscreen" + target = "window:pinenote-fullscreen" } ] ``` +If you want the brightness requests to actually work, add a `requestHandler` to `app.start` in `src/main.js`, for example: + +```js +app.start({ + instanceName: "pinenote-shell", + css: readFile(cssPath), + requestHandler(argv, response) { + if (argv[0] === "brightness") { + const level = parseInt(argv[1], 10) + brightness.set(level) + response(`brightness set to ${level}`) + } + response("unknown command") + }, + main() { + PinenoteShell() + }, +}) +``` + +(You'll need to lift the `brightness` manager out of the component scope for this.) + ### Additional Gestures Add more gestures for other controls: @@ -94,9 +142,9 @@ hotkeys = [Mod1, R] shorthand = "swipe left" actions = [ { - command = "exec ags --js-evaluate 'globalThis.widget.screenRotator.rotateTo(\"landscape\")'", + command = "exec ags request 'rotate landscape' -i pinenote-shell", timeout = 300, -target = "window:pinenote-fullscreen" + target = "window:pinenote-fullscreen" } ] @@ -105,9 +153,9 @@ hotkeys = [Mod1, T] shorthand = "double tap left" actions = [ { - command = "exec ags --js-evaluate 'globalThis.widget.launcherManager.launch(\"terminal\")'", + command = "exec ags request 'launch terminal' -i pinenote-shell", timeout = 300, -target = "window:pinenote-fullscreen" + target = "window:pinenote-fullscreen" } ] ``` @@ -144,14 +192,19 @@ const launchers = { ### Hyprland Integration -The widget uses hyprctl commands to communicate with: -- **Screen rotation**: Monitors current rotation via `hyprland.active.monitor.rotation` -- **Display refresh**: Uses `hyprctl keyword reload` for display updates -- **Brightness**: Controls through `/sys/class/backlight/` interface +The widget uses `hyprctl` and the `AstalHyprland` library to communicate with Hyprland: +- **Screen rotation**: Monitors the focused monitor's `transform` property +- **Display refresh**: Uses `pdlc-cli --refresh` for PineNote e-ink refresh +- **Brightness**: Uses `AstalBrightness` (`/sys/class/backlight/`) ## Styling -The widget uses CSS styling loaded from `$HOME/.config/ags/style.css`. You can customize the appearance by modifying this file or creating your own theme. +The widget uses CSS styling loaded from `$HOME/.config/ags/style.css`. The `style.css` file in this directory is the source of truth; copy or symlink it there before running: + +```bash +mkdir -p ~/.config/ags +cp ./style.css ~/.config/ags/style.css +``` Common customization options: - `background-color`: Change widget background @@ -162,15 +215,16 @@ Common customization options: ### Widget Won't Appear -1. **Check Astal installation**: Ensure gjs and ags are installed correctly -2. **Verify hyprgrass config**: Make sure your gesture bindings are correct -3. **Debug output**: Run with `gjs -d src/main.js` to see console errors +1. **Check Astal/AGS installation**: Ensure `ags`, `gjs`, and the Astal libraries are installed correctly +2. **Verify CSS path**: Make sure `~/.config/ags/style.css` exists; otherwise the app will crash on start +3. **Verify hyprgrass config**: Make sure your gesture bindings are correct +4. **Debug output**: Run with `ags run ./src/main.js` in a terminal to see console errors ### Controls Not Working 1. **Permission issues**: Some operations may require appropriate permissions 2. **Missing applications**: Ensure your preferred terminal/browser exist -3. **Hyprland compatibility**: Test basic hyprctl commands manually first +3. **Hyprland compatibility**: Test basic `hyprctl` commands manually first ### Performance Issues diff --git a/pinenote-shell/package-lock.json b/pinenote-shell/package-lock.json new file mode 100644 index 0000000..28c23e5 --- /dev/null +++ b/pinenote-shell/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "pinenote-shell", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pinenote-shell", + "version": "1.0.0" + } + } +} diff --git a/pinenote-shell/package.json b/pinenote-shell/package.json index 188ace4..741e5e6 100644 --- a/pinenote-shell/package.json +++ b/pinenote-shell/package.json @@ -5,10 +5,8 @@ "main": "./src/main.js", "type": "module", "scripts": { - "start": "gjs src/main.js", - "dev": "gjs --interpret src/main.js" + "start": "ags run src/main.js", + "dev": "ags run src/main.js" }, - "dependencies": { - "@agi/gtk3": "^0.7.4" - } + "dependencies": {} } \ No newline at end of file diff --git a/pinenote-shell/src/main.js b/pinenote-shell/src/main.js index 235d80a..278324b 100644 --- a/pinenote-shell/src/main.js +++ b/pinenote-shell/src/main.js @@ -1,456 +1,340 @@ -//!/usr/bin/env -S gjs -m +#!/usr/bin/env -S ags run +import GLib from "gi://GLib" +import app from "ags/gtk3/app" +import { Astal, Gtk, Gdk } from "ags/gtk3" +import { execAsync } from "ags/process" +import { readFile } from "ags/file" +import { createBinding, createState, createComputed } from "ags" +import Hyprland from "gi://AstalHyprland" +import Brightness from "gi://AstalBrightness" -const { Gdk, Gtk } = imports.gi; -const { App, Astal, Hyprland, Widget, Utils, GObject, exec, execAsync } = await import('@agi/gtk3'); +const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor -class PinenoteShell extends App { - constructor() { - super({ - id: "pinenote-shell", - name: "pinenote.shell", - instance: "pinenote-shell", - css: Utils.readFile(`${Utils.userHome}/.config/ags/style.css`), - // No hot corner needed - hyprgrass gestures control this - }); +const userHome = GLib.get_home_dir() +const cssPath = `${userHome}/.config/ags/style.css` - this.hyprland = new Hyprland({ - explicitBlur: true, - }); +// --- helpers -------------------------------------------------------------- - this.brightnessController = new BrightnessController(); - this.screenRotator = new ScreenRotator(this.hyprland); - this.powerManager = new PowerManager(); - this.launcherManager = new LauncherManager(); - this.refreshController = new RefreshController(); - this.waveformController = new WaveformController(); +const notify = (msg) => execAsync(["notify-send", msg]).catch(console.error) - this.buildInterface(); - } +const toTitleCase = (str) => + str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()) - buildInterface() { - const centerBox = Widget.Box({ - vertical: false, - children: [ - // Screen Rotation Section - Widget.Box({ - className: "section", - vertical: true, - children: [ - Widget.Label({ - label: "Screen Rotation", - className: "title", - }), - Widget.Box({ - homogeneous: false, - children: [ - Widget.Button({ - onClicked: () => this.screenRotator.rotateTo("landscape"), - child: Widget.Icon({ - icon: "view-normal", - size: 40, - }) - }), - Widget.Button({ - onClicked: () => this.screenRotator.rotateTo("portrait"), - child: Widget.Icon({ - icon: "camera-image", - size: 40, - }) - }), - ], - }), - ] - }), - - // Brightness Control Section - Widget.Box({ - className: "section", - vertical: true, - children: [ - Widget.Label({ - label: "Display Brightness", - className: "title", - }), - Widget.Box({ - homogeneous: false, - children: this.createBrightnessControls(), - }), - Widget.LevelBar({ - value: this.brightnessController.currentBrightness / 100, - minValue: 0, - maxValue: 1, - vertical: true, - className: "brightness-levelbar", - }), - ] - }), - - // Quick Launch Section - Widget.Box({ - className: "section", - vertical: true, - children: [ - Widget.Label({ - label: "Launch Applications", - className: "title", - }), - Widget.Box({ - homogeneous: false, - children: [ - Widget.Button({ - onClicked: () => this.launcherManager.launch("terminal"), - child: Widget.Icon({ icon: "terminal", size: 40 }), - }), - Widget.Button({ - onClicked: () => this.launcherManager.launch("browser"), - child: Widget.Icon({ icon: "web-browser", size: 40 }), - }), - Widget.Button({ - onClicked: () => this.launcherManager.launch("e-reader"), - child: Widget.Icon({ icon: "document-open", size: 40 }), - }), - ], - }), - ] - }), - - // Display & Waveform Controls Section - Widget.Box({ - className: "section", - vertical: true, - children: [ - Widget.Label({ - label: "Display & Waveform", - className: "title", - }), - Widget.Box({ - homogeneous: false, - children: [ - // Refresh Controls - Widget.Button({ - onClicked: () => this.refreshController.triggerRefresh(), - child: Widget.Icon({ icon: "view-refresh", size: 40 }), - }), - // Waveform Switch (e-ink display mode) - Widget.Box({ - vertical: true, - children: [ - Widget.Label({ - label: this.waveformController.currentWaveform, - className: "waveform-label", - }), - Widget.SpinButton({ - adjustment: new Gtk.Adjustment(0, 0, 3, 1, 1), - digits: 0, - onValueChanged: (spin) => { - this.waveformController.setWaveform(spin.getValue()); - } - }), - ] - }), - ], - }), - ] - }), - - // System Controls Section - Widget.Box({ - className: "section", - vertical: true, - children: [ - Widget.Label({ - label: "System Actions", - className: "title", - }), - Widget.Box({ - homogeneous: false, - children: [ - Widget.Button({ - onClicked: () => this.powerManager.suspend(), - child: Widget.Icon({ icon: "system-suspend", size: 40 }), - }), - Widget.Button({ - onClicked: () => this.showPowerMenu(), - child: Widget.Icon({ icon: "system-shutdown", size: 40 }), - }), - ], - }), - ] - }), - ].flat(Infinity), // Flatten nested arrays - }); - - this.addWindow( - Astal.Window({ - namespace: "fullscreen", - name: "pinenote-fullscreen", - anchor: [Astal.WindowAnchor.TOP, Astal.WindowAnchor.BOTTOM, - Astal.WindowAnchor.LEFT, Astal.WindowAnchor.RIGHT], - exclusivity: Astal.Exclusivity.NORMAL, - visible: false, - child: Widget.Box({ - vertical: true, - cssClasses: ["pinenote-shell-widget"], - setup: (box) => { - // Click outside content area to close - box.get_parent().connect("button-release-event", () => { - this.closeWidget(); - return true; - }); - }, - children: [ - // Header - Widget.CenterBox({ - className: "header", - startWidget: Widget.Label({ - label: "PineNote Control Panel", - className: "title-big", - }), - endWidget: Widget.Box({ - children: [ - Widget.Button({ - onClicked: () => this.closeWidget(), - child: Widget.Icon({ icon: "window-close", size: 20 }), - className: "close-button", - }) - ] - }) - }), - - // Main Content - centerBox, - - // Footer with status - Widget.CenterBox({ - className: "footer", - startWidget: Widget.Label({ - label: this.createStatusLabel(), - className: "status-label", - }) - }), - ] - }), - }) - ); - } - - createBrightnessControls() { - const levels = [100, 75, 25, 0]; - return levels.map((level) => Widget.Button({ - onClicked: () => this.brightnessController.setBrightness(level), - child: Widget.Label({ label: `${level}%` }), - className: `brightness-button brightness-${level}`, - })); - } - - createStatusLabel() { - let status = []; - - if (this.hyprland.active.monitor) { - const monitor = this.hyprland.active.monitor; - status.push(`Rotation: ${monitor.rotation || "auto"}`); - } - - status.push(`Brightness: ${this.brightnessController.currentBrightness}%`); - - return status.join(" | "); - } - - closeWidget() { - const window = this.getWindow("fullscreen", "pinenote-fullscreen"); - if (window) window.setVisible(false); - } - - toggleVisibility() { - const window = this.getWindow("fullscreen", "pinenote-fullscreen"); - if (window) window.setVisible(!window.isVisible()); - } - - showPowerMenu() { - const dialog = new Gtk.MessageDialog({ - transient_for: null, - modal: true, - destroy_with_parent: true, - message_type: Gtk.MessageType.QUESTION, - buttons: Gtk.ButtonsType.YES_NO, - default_button: Gtk.ResponseType.NO, - secondary_text: "Choose an action:", - }); - - dialog.add_button("Reboot", Gtk.ResponseType.YES); - dialog.add_button("Power Off", Gtk.ResponseType.OK); - - dialog.response.connect((_dialog, response) => { - if (response === Gtk.ResponseType.YES) { - this.powerManager.reboot(); - } else if (response === Gtk.ResponseType.OK) { - this.powerManager.shutdown(); - } - _dialog.close(); - }); - - dialog.show(); - } -} +// --- managers -------------------------------------------------------------- class BrightnessController { constructor() { - this.currentBrightness = 100; - this.updateFromSystem(); + this.astal = Brightness.get_default() + this.screen = this.astal?.screen } - async updateFromSystem() { - try { - // Try to read from /sys/class/backlight - const backlightFiles = await Utils.execAsync('find /sys/class/backlight -name "brightness" | head -1'); - if (backlightFiles) { - const maxFile = await Utils.execAsync(`grep max_brightness ${backlightFiles}`); - const currentFile = await Utils.execAsync(`cat ${backlightFiles}`); - - this.maxBrightness = parseInt(maxFile.split(' ')[1] || 0); - this.currentBrightness = Math.round(parseInt(currentFile) / this.maxBrightness * 100); - } - } catch (err) { - // Keep default brightness if cannot read from system - this.maxBrightness = 100; - } + get current() { + if (!this.screen) return 0 + return Math.round(this.screen.brightness * 100) } - async setBrightness(level) { - try { - const backlightFiles = await Utils.execAsync('find /sys/class/backlight -name "brightness" | head -1'); - if (backlightFiles && level > 0) { - const brightnessPath = `${backlightFiles}`; - await Utils.writeFile(brightnessPath, Math.round(level * this.maxBrightness / 100)); - } - - this.currentBrightness = level; - - // Send notification via hyprland - exec(`notify-send "Brightness set to ${level}%"`); - } catch (err) { - exec(`notify-send "Failed to set brightness: ${err.message}"`); - } + set(level) { + if (!this.screen) return + this.screen.brightness = level / 100 } } class ScreenRotator { constructor(hyprland) { - this.hyprland = hyprland; + this.hyprland = hyprland } async rotateTo(rotation) { try { - if (this.hyprland.active.monitor) { - const monitor = this.hyprland.active.monitor; - let hyprlandRotation; + const monitor = this.hyprland.focused_monitor + if (!monitor) return - switch (rotation) { - case "landscape": - hyprlandRotation = 0; - break; - case "portrait": - hyprlandRotation = 3; // 270 degrees in Hyprland's coordinate system - break; - default: - return; - } + const transform = + rotation === "landscape" + ? Hyprland.Monitor.Transform.NORMAL + : Hyprland.Monitor.Transform.ROTATE_270_DEG - // Use hyprctl to set monitor rotation - await Utils.execAsync(`hyprctl keyword -- monitor "${monitor.id}" rotate ${hyprlandRotation}`); + // Hyprland expects keyword monitor ,transform, + await execAsync([ + "hyprctl", + "keyword", + "monitor", + `${monitor.name},transform,${transform}`, + ]) - exec(`notify-send "Screen rotated to ${rotation}"`); - } + notify(`Screen rotated to ${rotation}`) } catch (err) { - exec(`notify-send "Failed to rotate screen: ${err.message}"`); + notify(`Failed to rotate screen: ${err.message}`) } } } class PowerManager { - constructor() {} - async suspend() { try { - await Utils.execAsync('systemctl suspend'); + await execAsync(["systemctl", "suspend"]) } catch (err) { - exec(`notify-send "Failed to suspend: ${err.message}"`); + notify(`Failed to suspend: ${err.message}`) } } async shutdown() { try { - await Utils.execAsync('systemctl poweroff'); + await execAsync(["systemctl", "poweroff"]) } catch (err) { - exec(`notify-send "Failed to power off: ${err.message}"`); + notify(`Failed to power off: ${err.message}`) } } async reboot() { try { - await Utils.execAsync('systemctl reboot'); + await execAsync(["systemctl", "reboot"]) } catch (err) { - exec(`notify-send "Failed to reboot: ${err.message}"`); + notify(`Failed to reboot: ${err.message}`) } } } class LauncherManager { - constructor() {} - - async launch(appType) { + launch(appType) { const launchers = { - terminal: "alacritty", // Or your preferred terminal emulator - browser: "brave-browser", // Or chromium/firefox - "e-reader": "calibre", // Or your e-reader app - }; - - try { - exec(`nohup ${launchers[appType]} &`); - exec(`notify-send "${Utils.toTitleCase(appType)} launched"`); - } catch (err) { - exec(`notify-send "Failed to launch ${appType}: ${err.message}"`); + terminal: "alacritty", + browser: "brave-browser", + "e-reader": "calibre", } + + const cmd = launchers[appType] + if (!cmd) return + + execAsync([cmd]).catch((err) => notify(`Failed to launch ${appType}: ${err.message}`)) + notify(`${toTitleCase(appType)} launched`) } } class RefreshController { - constructor() {} - async triggerRefresh() { try { - // Trigger a full e-ink display refresh via pdlc-cli - await Utils.execAsync('pdlc-cli --refresh 2>/dev/null'); - exec(`notify-send "E-ink display refreshed"`); + await execAsync(["pdlc-cli", "--refresh"]) + notify("E-ink display refreshed") } catch (err) { - console.log(`Failed to refresh display: ${err.message}`); + console.log(`Failed to refresh display: ${err.message}`) } } } class WaveformController { constructor() { - // PineNote uses the PDLC-EPD display controller - // Waveforms control the e-ink refresh behavior - this.currentWaveform = "gc16"; // Default: balanced quality/speed - this.waveforms = ["gc16", "gc42", "a2", "full"]; + this.waveforms = ["gc16", "gc42", "a2", "full"] + this.current = createState(this.waveforms[0]) } setWaveform(index) { - const idx = Math.round(index); - if (idx >= 0 && idx < this.waveforms.length) { - this.currentWaveform = this.waveforms[idx]; - exec(`notify-send "E-ink waveform: ${this.currentWaveform}"`); - - // Apply waveform via pdlc-cli (PineNote display controller) - try { - Utils.execAsync(`pdlc-cli --waveform ${this.currentWaveform} 2>/dev/null || true`); - } catch (err) { - console.log(`Could not apply waveform: ${this.currentWaveform}`); - } - } + const idx = Math.round(index) + if (idx < 0 || idx >= this.waveforms.length) return + + const waveform = this.waveforms[idx] + this.current[1](waveform) + notify(`E-ink waveform: ${waveform}`) + + execAsync(["pdlc-cli", "--waveform", waveform]).catch((err) => + console.log(`Could not apply waveform: ${err.message}`), + ) } } -App.addIcons(`${Utils.userHome}/.config/ags/icons`); -new PinenoteShell(); \ No newline at end of file +// --- widget ---------------------------------------------------------------- + +function PinenoteShell() { + const hyprland = Hyprland.get_default() + const brightness = new BrightnessController() + const rotator = new ScreenRotator(hyprland) + const power = new PowerManager() + const launcher = new LauncherManager() + const refresh = new RefreshController() + const waveform = new WaveformController() + + const [visible, setVisible] = createState(false) + + const brightnessLevel = brightness.screen + ? createBinding(brightness.screen, "brightness")((v) => Math.round((v || 0) * 100)) + : createState(100)[0] + + const currentWaveform = waveform.current[0] + + function closeWidget() { + setVisible(false) + } + + function toggleVisibility() { + setVisible((v) => !v) + } + + function showPowerMenu() { + const dialog = new Gtk.MessageDialog({ + transient_for: null, + modal: true, + destroy_with_parent: true, + message_type: Gtk.MessageType.QUESTION, + buttons: Gtk.ButtonsType.YES_NO, + default_button: Gtk.ResponseType.NO, + secondary_text: "Choose an action:", + }) + + dialog.add_button("Reboot", Gtk.ResponseType.YES) + dialog.add_button("Power Off", Gtk.ResponseType.OK) + + dialog.connect("response", (_dialog, response) => { + if (response === Gtk.ResponseType.YES) power.reboot() + else if (response === Gtk.ResponseType.OK) power.shutdown() + _dialog.close() + }) + + dialog.show() + } + + function BrightnessControls() { + const levels = [100, 75, 25, 0] + return levels.map((level) => ( + + )) + } + + function StatusLabel() { + const focusedMonitor = createBinding(hyprland, "focused-monitor") + const transform = createComputed(() => { + const monitor = focusedMonitor() + if (!monitor) return "auto" + const t = monitor.transform + return t === Hyprland.Monitor.Transform.NORMAL ? "landscape" : "portrait" + }) + + const status = createComputed(() => `Rotation: ${transform()} | Brightness: ${brightnessLevel()}%`) + + return