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:
2026-07-13 13:31:57 +02:00
parent 850a8d0b1c
commit 4938bb472b
4 changed files with 365 additions and 417 deletions
+268 -384
View File
@@ -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 <name>,transform,<value>
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();
// --- 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) => (
<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 &amp; 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()
},
})