Add Pinenote Shell widget with control panel

This commit is contained in:
2026-07-13 12:16:01 +02:00
commit 850a8d0b1c
4 changed files with 756 additions and 0 deletions
+193
View File
@@ -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.
+14
View File
@@ -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"
}
}
+456
View File
@@ -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();
+93
View File
@@ -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;
}