Migrate pinenote-shell to AGS v3/Astal framework
Replace the custom GJS/GTK4 @agi/gtk3 setup with AGS v3 on GTK3 using Astal services (Hyprland, Brightness). Updates README installation instructions, package scripts, and rewrites main.js to use AGS JSX widgets and reactive state bindings. Adds a minimal package-lock.json.
This commit is contained in:
+82
-28
@@ -16,18 +16,22 @@ This Astal-based GTK application provides:
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **OS**: Arch Linux with Hyprland
|
- **OS**: Arch Linux with Hyprland
|
||||||
- **Desktop Environment**: Astal/GJS environment
|
- **Desktop Environment**: AGS (Aylur's GTK Shell) v3 / Astal
|
||||||
- **Dependencies**: GJS, GTK 4, hyprgrass
|
- **Dependencies**: GJS, GTK 3, Astal (`libastal-*-git`), `ags`, hyprgrass
|
||||||
- **Display**: Pine64 PineNote (or any compatible monitor)
|
- **Display**: Pine64 PineNote (or any compatible monitor)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
Astal and AGS are not distributed via npm. Install the system packages from the AUR:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aur install ags-node
|
# Install AGS and Astal core libraries (GTK3 + GJS bindings)
|
||||||
# or for Arch Linux:
|
yay -S ags libastal-meta libastal-gjs-git libastal-hyprland-git libastal-brightness-git
|
||||||
sudo pacman -S gjs
|
|
||||||
|
# Base GTK/Layer-Shell support
|
||||||
|
sudo pacman -S gjs gtk3 gtk-layer-shell
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clone and Setup
|
### Clone and Setup
|
||||||
@@ -36,28 +40,50 @@ sudo pacman -S gjs
|
|||||||
cd ~/projects/pineNoteShell
|
cd ~/projects/pineNoteShell
|
||||||
# Project already set up in pinenote-shell/
|
# Project already set up in pinenote-shell/
|
||||||
|
|
||||||
# Install dependencies
|
# No npm dependencies are required
|
||||||
npm install --prefix ./pinenote-shell
|
cd pinenote-shell
|
||||||
cd pinenote-shell && npm install
|
|
||||||
|
|
||||||
# Make widget executable
|
# Make widget executable
|
||||||
chmod +x ./src/main.js
|
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
|
### Hyprland Configuration
|
||||||
|
|
||||||
Add the following to your `~/.config/hypr/hyprgrass.conf`:
|
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
|
```ini
|
||||||
[gesture:toggle_widget]
|
[gesture:toggle_widget]
|
||||||
hotkeys = [Mod1, T]
|
hotkeys = [Mod1, T]
|
||||||
shorthand = "pinch open"
|
shorthand = "pinch open"
|
||||||
actions = [
|
actions = [
|
||||||
{
|
{
|
||||||
command = "exec ags -t pinenote-shell",
|
command = "exec ags toggle pinenote-fullscreen -i pinenote-shell",
|
||||||
timeout = 300,
|
timeout = 300,
|
||||||
target = "window:pinenote-fullscreen"
|
target = "window:pinenote-fullscreen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -66,9 +92,9 @@ hotkeys = [Mod1, B]
|
|||||||
shorthand = "swipe up"
|
shorthand = "swipe up"
|
||||||
actions = [
|
actions = [
|
||||||
{
|
{
|
||||||
command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(100)'",
|
command = "exec ags request 'brightness 100' -i pinenote-shell",
|
||||||
timeout = 300,
|
timeout = 300,
|
||||||
target = "window:pinenote-fullscreen"
|
target = "window:pinenote-fullscreen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -77,13 +103,35 @@ hotkeys = [Mod1, V]
|
|||||||
shorthand = "swipe down"
|
shorthand = "swipe down"
|
||||||
actions = [
|
actions = [
|
||||||
{
|
{
|
||||||
command = "exec ags --js-evaluate 'globalThis.widget.brightnessController.setBrightness(0)'",
|
command = "exec ags request 'brightness 0' -i pinenote-shell",
|
||||||
timeout = 300,
|
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
|
### Additional Gestures
|
||||||
|
|
||||||
Add more gestures for other controls:
|
Add more gestures for other controls:
|
||||||
@@ -94,9 +142,9 @@ hotkeys = [Mod1, R]
|
|||||||
shorthand = "swipe left"
|
shorthand = "swipe left"
|
||||||
actions = [
|
actions = [
|
||||||
{
|
{
|
||||||
command = "exec ags --js-evaluate 'globalThis.widget.screenRotator.rotateTo(\"landscape\")'",
|
command = "exec ags request 'rotate landscape' -i pinenote-shell",
|
||||||
timeout = 300,
|
timeout = 300,
|
||||||
target = "window:pinenote-fullscreen"
|
target = "window:pinenote-fullscreen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -105,9 +153,9 @@ hotkeys = [Mod1, T]
|
|||||||
shorthand = "double tap left"
|
shorthand = "double tap left"
|
||||||
actions = [
|
actions = [
|
||||||
{
|
{
|
||||||
command = "exec ags --js-evaluate 'globalThis.widget.launcherManager.launch(\"terminal\")'",
|
command = "exec ags request 'launch terminal' -i pinenote-shell",
|
||||||
timeout = 300,
|
timeout = 300,
|
||||||
target = "window:pinenote-fullscreen"
|
target = "window:pinenote-fullscreen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -144,14 +192,19 @@ const launchers = {
|
|||||||
|
|
||||||
### Hyprland Integration
|
### Hyprland Integration
|
||||||
|
|
||||||
The widget uses hyprctl commands to communicate with:
|
The widget uses `hyprctl` and the `AstalHyprland` library to communicate with Hyprland:
|
||||||
- **Screen rotation**: Monitors current rotation via `hyprland.active.monitor.rotation`
|
- **Screen rotation**: Monitors the focused monitor's `transform` property
|
||||||
- **Display refresh**: Uses `hyprctl keyword reload` for display updates
|
- **Display refresh**: Uses `pdlc-cli --refresh` for PineNote e-ink refresh
|
||||||
- **Brightness**: Controls through `/sys/class/backlight/` interface
|
- **Brightness**: Uses `AstalBrightness` (`/sys/class/backlight/`)
|
||||||
|
|
||||||
## Styling
|
## 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:
|
Common customization options:
|
||||||
- `background-color`: Change widget background
|
- `background-color`: Change widget background
|
||||||
@@ -162,15 +215,16 @@ Common customization options:
|
|||||||
|
|
||||||
### Widget Won't Appear
|
### Widget Won't Appear
|
||||||
|
|
||||||
1. **Check Astal installation**: Ensure gjs and ags are installed correctly
|
1. **Check Astal/AGS installation**: Ensure `ags`, `gjs`, and the Astal libraries are installed correctly
|
||||||
2. **Verify hyprgrass config**: Make sure your gesture bindings are correct
|
2. **Verify CSS path**: Make sure `~/.config/ags/style.css` exists; otherwise the app will crash on start
|
||||||
3. **Debug output**: Run with `gjs -d src/main.js` to see console errors
|
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
|
### Controls Not Working
|
||||||
|
|
||||||
1. **Permission issues**: Some operations may require appropriate permissions
|
1. **Permission issues**: Some operations may require appropriate permissions
|
||||||
2. **Missing applications**: Ensure your preferred terminal/browser exist
|
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
|
### Performance Issues
|
||||||
|
|
||||||
|
|||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "pinenote-shell",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "pinenote-shell",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,8 @@
|
|||||||
"main": "./src/main.js",
|
"main": "./src/main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "gjs src/main.js",
|
"start": "ags run src/main.js",
|
||||||
"dev": "gjs --interpret src/main.js"
|
"dev": "ags run src/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {}
|
||||||
"@agi/gtk3": "^0.7.4"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+267
-383
@@ -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 { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor
|
||||||
const { App, Astal, Hyprland, Widget, Utils, GObject, exec, execAsync } = await import('@agi/gtk3');
|
|
||||||
|
|
||||||
class PinenoteShell extends App {
|
const userHome = GLib.get_home_dir()
|
||||||
constructor() {
|
const cssPath = `${userHome}/.config/ags/style.css`
|
||||||
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({
|
// --- helpers --------------------------------------------------------------
|
||||||
explicitBlur: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.brightnessController = new BrightnessController();
|
const notify = (msg) => execAsync(["notify-send", msg]).catch(console.error)
|
||||||
this.screenRotator = new ScreenRotator(this.hyprland);
|
|
||||||
this.powerManager = new PowerManager();
|
|
||||||
this.launcherManager = new LauncherManager();
|
|
||||||
this.refreshController = new RefreshController();
|
|
||||||
this.waveformController = new WaveformController();
|
|
||||||
|
|
||||||
this.buildInterface();
|
const toTitleCase = (str) =>
|
||||||
}
|
str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase())
|
||||||
|
|
||||||
buildInterface() {
|
// --- managers --------------------------------------------------------------
|
||||||
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 {
|
class BrightnessController {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentBrightness = 100;
|
this.astal = Brightness.get_default()
|
||||||
this.updateFromSystem();
|
this.screen = this.astal?.screen
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFromSystem() {
|
get current() {
|
||||||
try {
|
if (!this.screen) return 0
|
||||||
// Try to read from /sys/class/backlight
|
return Math.round(this.screen.brightness * 100)
|
||||||
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) {
|
set(level) {
|
||||||
try {
|
if (!this.screen) return
|
||||||
const backlightFiles = await Utils.execAsync('find /sys/class/backlight -name "brightness" | head -1');
|
this.screen.brightness = level / 100
|
||||||
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 {
|
class ScreenRotator {
|
||||||
constructor(hyprland) {
|
constructor(hyprland) {
|
||||||
this.hyprland = hyprland;
|
this.hyprland = hyprland
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotateTo(rotation) {
|
async rotateTo(rotation) {
|
||||||
try {
|
try {
|
||||||
if (this.hyprland.active.monitor) {
|
const monitor = this.hyprland.focused_monitor
|
||||||
const monitor = this.hyprland.active.monitor;
|
if (!monitor) return
|
||||||
let hyprlandRotation;
|
|
||||||
|
|
||||||
switch (rotation) {
|
const transform =
|
||||||
case "landscape":
|
rotation === "landscape"
|
||||||
hyprlandRotation = 0;
|
? Hyprland.Monitor.Transform.NORMAL
|
||||||
break;
|
: Hyprland.Monitor.Transform.ROTATE_270_DEG
|
||||||
case "portrait":
|
|
||||||
hyprlandRotation = 3; // 270 degrees in Hyprland's coordinate system
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use hyprctl to set monitor rotation
|
// Hyprland expects keyword monitor <name>,transform,<value>
|
||||||
await Utils.execAsync(`hyprctl keyword -- monitor "${monitor.id}" rotate ${hyprlandRotation}`);
|
await execAsync([
|
||||||
|
"hyprctl",
|
||||||
|
"keyword",
|
||||||
|
"monitor",
|
||||||
|
`${monitor.name},transform,${transform}`,
|
||||||
|
])
|
||||||
|
|
||||||
exec(`notify-send "Screen rotated to ${rotation}"`);
|
notify(`Screen rotated to ${rotation}`)
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
exec(`notify-send "Failed to rotate screen: ${err.message}"`);
|
notify(`Failed to rotate screen: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PowerManager {
|
class PowerManager {
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
async suspend() {
|
async suspend() {
|
||||||
try {
|
try {
|
||||||
await Utils.execAsync('systemctl suspend');
|
await execAsync(["systemctl", "suspend"])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
exec(`notify-send "Failed to suspend: ${err.message}"`);
|
notify(`Failed to suspend: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
try {
|
try {
|
||||||
await Utils.execAsync('systemctl poweroff');
|
await execAsync(["systemctl", "poweroff"])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
exec(`notify-send "Failed to power off: ${err.message}"`);
|
notify(`Failed to power off: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reboot() {
|
async reboot() {
|
||||||
try {
|
try {
|
||||||
await Utils.execAsync('systemctl reboot');
|
await execAsync(["systemctl", "reboot"])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
exec(`notify-send "Failed to reboot: ${err.message}"`);
|
notify(`Failed to reboot: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LauncherManager {
|
class LauncherManager {
|
||||||
constructor() {}
|
launch(appType) {
|
||||||
|
|
||||||
async launch(appType) {
|
|
||||||
const launchers = {
|
const launchers = {
|
||||||
terminal: "alacritty", // Or your preferred terminal emulator
|
terminal: "alacritty",
|
||||||
browser: "brave-browser", // Or chromium/firefox
|
browser: "brave-browser",
|
||||||
"e-reader": "calibre", // Or your e-reader app
|
"e-reader": "calibre",
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
exec(`nohup ${launchers[appType]} &`);
|
|
||||||
exec(`notify-send "${Utils.toTitleCase(appType)} launched"`);
|
|
||||||
} catch (err) {
|
|
||||||
exec(`notify-send "Failed to launch ${appType}: ${err.message}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cmd = launchers[appType]
|
||||||
|
if (!cmd) return
|
||||||
|
|
||||||
|
execAsync([cmd]).catch((err) => notify(`Failed to launch ${appType}: ${err.message}`))
|
||||||
|
notify(`${toTitleCase(appType)} launched`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RefreshController {
|
class RefreshController {
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
async triggerRefresh() {
|
async triggerRefresh() {
|
||||||
try {
|
try {
|
||||||
// Trigger a full e-ink display refresh via pdlc-cli
|
await execAsync(["pdlc-cli", "--refresh"])
|
||||||
await Utils.execAsync('pdlc-cli --refresh 2>/dev/null');
|
notify("E-ink display refreshed")
|
||||||
exec(`notify-send "E-ink display refreshed"`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`Failed to refresh display: ${err.message}`);
|
console.log(`Failed to refresh display: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class WaveformController {
|
class WaveformController {
|
||||||
constructor() {
|
constructor() {
|
||||||
// PineNote uses the PDLC-EPD display controller
|
this.waveforms = ["gc16", "gc42", "a2", "full"]
|
||||||
// Waveforms control the e-ink refresh behavior
|
this.current = createState(this.waveforms[0])
|
||||||
this.currentWaveform = "gc16"; // Default: balanced quality/speed
|
|
||||||
this.waveforms = ["gc16", "gc42", "a2", "full"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setWaveform(index) {
|
setWaveform(index) {
|
||||||
const idx = Math.round(index);
|
const idx = Math.round(index)
|
||||||
if (idx >= 0 && idx < this.waveforms.length) {
|
if (idx < 0 || idx >= this.waveforms.length) return
|
||||||
this.currentWaveform = this.waveforms[idx];
|
|
||||||
exec(`notify-send "E-ink waveform: ${this.currentWaveform}"`);
|
|
||||||
|
|
||||||
// Apply waveform via pdlc-cli (PineNote display controller)
|
const waveform = this.waveforms[idx]
|
||||||
try {
|
this.current[1](waveform)
|
||||||
Utils.execAsync(`pdlc-cli --waveform ${this.currentWaveform} 2>/dev/null || true`);
|
notify(`E-ink waveform: ${waveform}`)
|
||||||
} catch (err) {
|
|
||||||
console.log(`Could not apply waveform: ${this.currentWaveform}`);
|
execAsync(["pdlc-cli", "--waveform", waveform]).catch((err) =>
|
||||||
}
|
console.log(`Could not apply waveform: ${err.message}`),
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
App.addIcons(`${Utils.userHome}/.config/ags/icons`);
|
// --- widget ----------------------------------------------------------------
|
||||||
new PinenoteShell();
|
|
||||||
|
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) => (
|
||||||
|
<button
|
||||||
|
class={`brightness-button brightness-${level}`}
|
||||||
|
onClicked={() => brightness.set(level)}
|
||||||
|
>
|
||||||
|
<label label={`${level}%`} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <label class="status-label" label={status} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<window
|
||||||
|
name="pinenote-fullscreen"
|
||||||
|
namespace="fullscreen"
|
||||||
|
visible={visible}
|
||||||
|
anchor={TOP | BOTTOM | LEFT | RIGHT}
|
||||||
|
exclusivity={Astal.Exclusivity.IGNORE}
|
||||||
|
keymode={Astal.Keymode.EXCLUSIVE}
|
||||||
|
onButtonPressEvent={(self, event) => {
|
||||||
|
const [, x, y] = event.get_coords()
|
||||||
|
const { x: cx, y: cy, width, height } = self.get_child().get_allocation()
|
||||||
|
const outside = x < cx || x > cx + width || y < cy || y > cy + height
|
||||||
|
if (outside) closeWidget()
|
||||||
|
}}
|
||||||
|
onKeyPressEvent={(self, event) => {
|
||||||
|
if (event.get_keyval()[1] === Gdk.KEY_Escape) closeWidget()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box class="pinenote-shell-widget" vertical>
|
||||||
|
<centerbox class="header">
|
||||||
|
<label $type="start" class="title-big" label="PineNote Control Panel" />
|
||||||
|
<box $type="end">
|
||||||
|
<button class="close-button" onClicked={closeWidget}>
|
||||||
|
<icon icon="window-close" />
|
||||||
|
</button>
|
||||||
|
</box>
|
||||||
|
</centerbox>
|
||||||
|
|
||||||
|
<box vertical homogeneous={false}>
|
||||||
|
{/* Screen Rotation */}
|
||||||
|
<box class="section" vertical>
|
||||||
|
<label class="title" label="Screen Rotation" />
|
||||||
|
<box homogeneous={false}>
|
||||||
|
<button onClicked={() => rotator.rotateTo("landscape")}>
|
||||||
|
<icon icon="view-normal" />
|
||||||
|
</button>
|
||||||
|
<button onClicked={() => rotator.rotateTo("portrait")}>
|
||||||
|
<icon icon="camera-image" />
|
||||||
|
</button>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Brightness */}
|
||||||
|
<box class="section" vertical>
|
||||||
|
<label class="title" label="Display Brightness" />
|
||||||
|
<box homogeneous={false}>{BrightnessControls()}</box>
|
||||||
|
<levelbar
|
||||||
|
vertical
|
||||||
|
class="brightness-levelbar"
|
||||||
|
value={brightnessLevel((v) => v / 100)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Quick Launch */}
|
||||||
|
<box class="section" vertical>
|
||||||
|
<label class="title" label="Launch Applications" />
|
||||||
|
<box homogeneous={false}>
|
||||||
|
<button onClicked={() => launcher.launch("terminal")}>
|
||||||
|
<icon icon="terminal" />
|
||||||
|
</button>
|
||||||
|
<button onClicked={() => launcher.launch("browser")}>
|
||||||
|
<icon icon="web-browser" />
|
||||||
|
</button>
|
||||||
|
<button onClicked={() => launcher.launch("e-reader")}>
|
||||||
|
<icon icon="document-open" />
|
||||||
|
</button>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Display & Waveform */}
|
||||||
|
<box class="section" vertical>
|
||||||
|
<label class="title" label="Display & Waveform" />
|
||||||
|
<box homogeneous={false}>
|
||||||
|
<button onClicked={() => refresh.triggerRefresh()}>
|
||||||
|
<icon icon="view-refresh" />
|
||||||
|
</button>
|
||||||
|
<box vertical>
|
||||||
|
<label class="waveform-label" label={currentWaveform} />
|
||||||
|
<spinbutton
|
||||||
|
adjustment={new Gtk.Adjustment({ lower: 0, upper: 3, step_increment: 1 })}
|
||||||
|
digits={0}
|
||||||
|
onValueChanged={({ value }) => waveform.setWaveform(value)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* System */}
|
||||||
|
<box class="section" vertical>
|
||||||
|
<label class="title" label="System Actions" />
|
||||||
|
<box homogeneous={false}>
|
||||||
|
<button onClicked={() => power.suspend()}>
|
||||||
|
<icon icon="system-suspend" />
|
||||||
|
</button>
|
||||||
|
<button onClicked={showPowerMenu}>
|
||||||
|
<icon icon="system-shutdown" />
|
||||||
|
</button>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<centerbox class="footer">
|
||||||
|
<StatusLabel $type="start" />
|
||||||
|
</centerbox>
|
||||||
|
</box>
|
||||||
|
</window>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- entry point -----------------------------------------------------------
|
||||||
|
|
||||||
|
app.start({
|
||||||
|
instanceName: "pinenote-shell",
|
||||||
|
css: readFile(cssPath),
|
||||||
|
main() {
|
||||||
|
PinenoteShell()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user