From 850a8d0b1c998127bf7a347962769a740d5e5dd7 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 13 Jul 2026 12:16:01 +0200 Subject: [PATCH] Add Pinenote Shell widget with control panel --- pinenote-shell/README.md | 193 +++++++++++++++ pinenote-shell/package.json | 14 ++ pinenote-shell/src/main.js | 456 ++++++++++++++++++++++++++++++++++++ pinenote-shell/style.css | 93 ++++++++ 4 files changed, 756 insertions(+) create mode 100644 pinenote-shell/README.md create mode 100644 pinenote-shell/package.json create mode 100644 pinenote-shell/src/main.js create mode 100644 pinenote-shell/style.css diff --git a/pinenote-shell/README.md b/pinenote-shell/README.md new file mode 100644 index 0000000..a01bb4c --- /dev/null +++ b/pinenote-shell/README.md @@ -0,0 +1,193 @@ +# PineNote Shell Widget + +A fullscreen GTK widget designed for Pine64 PineNote running Arch Linux with Hyprland, controllable via hyprgrass gestures. + +## Overview + +This Astal-based GTK application provides: + +- Screen rotation (portrait/landscape) +- Display brightness control (100%, 75%, 25%, 0% steps) +- Power operations (suspend, shutdown, reboot) +- Quick launch shortcuts for terminal, browser, and e-reader +- Display refresh functionality +- Audio waveform switching + +## Requirements + +- **OS**: Arch Linux with Hyprland +- **Desktop Environment**: Astal/GJS environment +- **Dependencies**: GJS, GTK 4, hyprgrass +- **Display**: Pine64 PineNote (or any compatible monitor) + +## Installation + +### Prerequisites + +```bash +aur install ags-node +# or for Arch Linux: +sudo pacman -S gjs +``` + +### Clone and Setup + +```bash +cd ~/projects/pineNoteShell +# Project already set up in pinenote-shell/ + +# Install dependencies +npm install --prefix ./pinenote-shell +cd pinenote-shell && npm install + +# Make widget executable +chmod +x ./src/main.js +gjs --set-mode=auto-start +``` + +### Hyprland Configuration + +Add the following to your `~/.config/hypr/hyprgrass.conf`: + +```ini +[gesture:toggle_widget] +hotkeys = [Mod1, T] +shorthand = "pinch open" +actions = [ + { + command = "exec ags -t pinenote-shell", + timeout = 300, +target = "window:pinenote-fullscreen" + } +] + +[gesture:brightness_up] +hotkeys = [Mod1, B] +shorthand = "swipe up" +actions = [ + { + command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(100)'", + timeout = 300, +target = "window:pinenote-fullscreen" + } +] + +[gesture:brightness_down] +hotkeys = [Mod1, V] +shorthand = "swipe down" +actions = [ + { + command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(0)'", + timeout = 300, +target = "window:pinenote-fullscreen" + } +] +``` + +### Additional Gestures + +Add more gestures for other controls: + +```ini +[gesture:rotate_landscape] +hotkeys = [Mod1, R] +shorthand = "swipe left" +actions = [ + { + command = "exec ags --js-evaluate 'globalThis.widget.screenRotator.rotateTo(\"landscape\")'", + timeout = 300, +target = "window:pinenote-fullscreen" + } +] + +[gesture:launch_terminal] +hotkeys = [Mod1, T] +shorthand = "double tap left" +actions = [ + { + command = "exec ags --js-evaluate 'globalThis.widget.launcherManager.launch(\"terminal\")'", + timeout = 300, +target = "window:pinenote-fullscreen" + } +] +``` + +## Usage + +### Basic Controls + +1. **Open/Close**: Activate with hyprgrass gesture (see configuration) +2. **Screen Rotation**: Tap landscape/portrait icons to rotate display +3. **Brightness**: Click brightness level buttons or use gestures in config +4. **System Actions**: Use suspend/shutdown buttons for power operations +5. **Launch Apps**: Click terminal, browser, or e-reader shortcuts + +### Advanced Features + +- **Waveform Switching**: Use the waveform control to switch between audio output modes +- **Display Refresh**: Force display refresh with the refresh button +- **Status Display**: Monitor rotation and brightness levels in footer + +## Configuration + +### Custom Applications + +Edit `src/main.js` and modify the `LauncherManager.launch()` method: + +```javascript +const launchers = { + terminal: "alacritty", // Your preferred terminal + browser: "brave-browser", // Or chromium/firefox/edge + "e-reader": "calibre", // Your e-reader application +}; +``` + +### 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 + +## 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. + +Common customization options: +- `background-color`: Change widget background +- `border-radius`, `margin`: Adjust widget borders and spacing +- Icon sets: Use different icon themes for better visual consistency + +## Troubleshooting + +### 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 + +### 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 + +### Performance Issues + +- The widget is designed to be lightweight and efficient +- For best performance on PineNote, consider reducing animation frequency +- Monitor system resources with `htop` or similar tools + +## Future Enhancements + +Potential features for future development: +- [ ] Multi-monitor support +- [ ] Advanced brightness curves (not just discrete steps) +- [ ] Custom app launcher configuration +- [ ] Audio waveform visualization +- [ ] System monitoring widgets +- [ ] Integration with PineNote-specific settings + +## License + +This project is provided as-is for your PineNote setup. Use responsibly and test changes before deploying to production hardware. \ No newline at end of file diff --git a/pinenote-shell/package.json b/pinenote-shell/package.json new file mode 100644 index 0000000..188ace4 --- /dev/null +++ b/pinenote-shell/package.json @@ -0,0 +1,14 @@ +{ + "name": "pinenote-shell", + "version": "1.0.0", + "description": "Fullscreen GTK widget for Pine64 PineNote with Hyprland support", + "main": "./src/main.js", + "type": "module", + "scripts": { + "start": "gjs src/main.js", + "dev": "gjs --interpret src/main.js" + }, + "dependencies": { + "@agi/gtk3": "^0.7.4" + } +} \ No newline at end of file diff --git a/pinenote-shell/src/main.js b/pinenote-shell/src/main.js new file mode 100644 index 0000000..235d80a --- /dev/null +++ b/pinenote-shell/src/main.js @@ -0,0 +1,456 @@ +//!/usr/bin/env -S gjs -m + +const { Gdk, Gtk } = imports.gi; +const { App, Astal, Hyprland, Widget, Utils, GObject, exec, execAsync } = await import('@agi/gtk3'); + +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 + }); + + this.hyprland = new Hyprland({ + explicitBlur: true, + }); + + 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(); + + this.buildInterface(); + } + + 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(); + } +} + +class BrightnessController { + constructor() { + this.currentBrightness = 100; + this.updateFromSystem(); + } + + 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; + } + } + + 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}"`); + } + } +} + +class ScreenRotator { + constructor(hyprland) { + this.hyprland = hyprland; + } + + async rotateTo(rotation) { + try { + if (this.hyprland.active.monitor) { + const monitor = this.hyprland.active.monitor; + let hyprlandRotation; + + switch (rotation) { + case "landscape": + hyprlandRotation = 0; + break; + case "portrait": + hyprlandRotation = 3; // 270 degrees in Hyprland's coordinate system + break; + default: + return; + } + + // Use hyprctl to set monitor rotation + await Utils.execAsync(`hyprctl keyword -- monitor "${monitor.id}" rotate ${hyprlandRotation}`); + + exec(`notify-send "Screen rotated to ${rotation}"`); + } + } catch (err) { + exec(`notify-send "Failed to rotate screen: ${err.message}"`); + } + } +} + +class PowerManager { + constructor() {} + + async suspend() { + try { + await Utils.execAsync('systemctl suspend'); + } catch (err) { + exec(`notify-send "Failed to suspend: ${err.message}"`); + } + } + + async shutdown() { + try { + await Utils.execAsync('systemctl poweroff'); + } catch (err) { + exec(`notify-send "Failed to power off: ${err.message}"`); + } + } + + async reboot() { + try { + await Utils.execAsync('systemctl reboot'); + } catch (err) { + exec(`notify-send "Failed to reboot: ${err.message}"`); + } + } +} + +class LauncherManager { + constructor() {} + + async 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}"`); + } + } +} + +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"`); + } catch (err) { + 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"]; + } + + 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}`); + } + } + } +} + +App.addIcons(`${Utils.userHome}/.config/ags/icons`); +new PinenoteShell(); \ No newline at end of file diff --git a/pinenote-shell/style.css b/pinenote-shell/style.css new file mode 100644 index 0000000..dbbe93f --- /dev/null +++ b/pinenote-shell/style.css @@ -0,0 +1,93 @@ +* { + margin: 0; + padding: 0; +} + +window.pinenote-shell-widget { + background-color: rgba(20, 20, 30, 0.95); + color: #ffffff; + font-family: "Ubuntu", "Noto Sans", sans-serif; + border-radius: 8px; + margin: 16px; +} + +window.pinenote-shell-widget .title-big { + font-size: 24px; + font-weight: bold; + color: #61afef; + margin: 16px; +} + +window.pinenote-shell-widget .header { + background-color: rgba(0, 0, 0, 0.3); + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +window.pinenote-shell-widget .section { + background-color: rgba(255, 255, 255, 0.05); + padding: 16px; + margin: 8px 16px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +window.pinenote-shell-widget .title { + font-size: 18px; + font-weight: bold; + color: #98c379; + margin-bottom: 12px; +} + +window.pinenote-shell-widget .brightness-levelbar { + min-height: 200px; + width: 20px; + margin: 0 auto; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +window.pinenote-shell-widget .brightness-levelbar.fill-block { + background-color: #e5c07b; +} + +window.pinenote-shell-widget .footer { + padding: 16px; + background-color: rgba(0, 0, 0, 0.3); + font-size: 12px; + color: #7f848f; +} + +window.pinenote-shell-widget .status-label { + padding-left: 16px; +} + +window.pinenote-shell-widget button { + background-color: rgba(97, 175, 239, 0.2); + border: 1px solid #61afef; + border-radius: 6px; + padding: 12px; + margin: 8px; + color: #ffffff; + transition: all 0.3s ease; +} + +window.pinenote-shell-widget button:hover { + background-color: rgba(97, 175, 239, 0.4); + transform: scale(1.05); +} + +window.pinenote-shell-widget .brightness-button { + padding: 8px; + min-width: 60px; +} + +window.pinenote-shell-widget .waveform-label { + font-size: 14px; + color: #c678dd; + margin-bottom: 8px; +} + +window.pinenote-shell-widget Icon { + color: #61afef; +} \ No newline at end of file