From ff87bc5a5262dda33c4ad4c61ebaf1a13fb61832 Mon Sep 17 00:00:00 2001 From: Joao Ferrete Date: Tue, 14 Jul 2026 11:09:00 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Lighter=20v2=20=E2=80=94=20per-side=20b?= =?UTF-8?q?orders,=20profiles,=20auto-switching,=20OSS=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Independent thickness per side (top/bottom/left/right, 0 disables a side), with optional linked mode and automatic migration from the old single 'thickness' key - Named profiles stored as JSON in GSettings: create/apply/rename/duplicate/ delete from a new Profiles preferences page and the panel menu - Optional automatic profile switching by focused window (app id and/or window title regex, with priority and default-profile fallback) Architecture: - Split monolithic extension.js into lib/ (borderManager, profileManager, windowWatcher, brightness, colorUtils, indicator, migrations) and preferences/ (generalPage, profilesPage) - Replace deprecated Clutter.Color with St.Widget + CSS (GNOME 47 ready) and Gtk.ColorButton with Gtk.ColorDialogButton - Fix missing maximize-brightness listener; keybinding now ActionMode.NORMAL Tooling: - Makefile (build/lint/pot/pack/install/nested) with a guard preventing 'make install' from deleting the repo through a symlinked installation - ESLint (GNOME style), GitHub Actions CI, gettext i18n with full pt_BR translation, CONTRIBUTING.md, CHANGELOG.md, rewritten README Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 34 + .gitignore | 6 +- CHANGELOG.md | 42 + CONTRIBUTING.md | 107 ++ Makefile | 57 + README.md | 110 +- eslint.config.js | 47 + extension.js | 359 +----- lib/borderManager.js | 108 ++ lib/brightness.js | 43 + lib/colorUtils.js | 80 ++ lib/indicator.js | 145 +++ lib/migrations.js | 18 + lib/profileManager.js | 178 +++ lib/windowWatcher.js | 165 +++ metadata.json | 4 +- package-lock.json | 1111 +++++++++++++++++ package.json | 14 + po/lighter.pot | 243 ++++ po/pt_BR.po | 184 +++ preferences/generalPage.js | 168 +++ preferences/profilesPage.js | 330 +++++ prefs.js | 116 +- ...gnome.shell.extensions.lighter.gschema.xml | 46 +- 24 files changed, 3271 insertions(+), 444 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 eslint.config.js create mode 100644 lib/borderManager.js create mode 100644 lib/brightness.js create mode 100644 lib/colorUtils.js create mode 100644 lib/indicator.js create mode 100644 lib/migrations.js create mode 100644 lib/profileManager.js create mode 100644 lib/windowWatcher.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 po/lighter.pot create mode 100644 po/pt_BR.po create mode 100644 preferences/generalPage.js create mode 100644 preferences/profilesPage.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..17543c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install + - run: npm run lint + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install GNOME tooling + run: | + sudo apt-get update + sudo apt-get install -y libglib2.0-bin gnome-shell-common gnome-shell gettext + - name: Validate GSettings schema + run: glib-compile-schemas --strict --dry-run schemas/ + - name: Pack extension + run: make pack + - uses: actions/upload-artifact@v4 + with: + name: extension-zip + path: dist/*.shell-extension.zip diff --git a/.gitignore b/.gitignore index c816185..59a5e03 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -.claude \ No newline at end of file +.claude +schemas/gschemas.compiled +dist/ +node_modules/ +po/*.mo diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..12ec3b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0] - Unreleased + +### Added +- Independent border thickness per side (top/bottom/left/right); setting a side to 0 + disables it entirely. +- Named profiles: save the current appearance as a profile (e.g. "Google Meet") and + switch between profiles from the panel menu or the preferences window. +- Optional automatic profile switching based on the focused window (match by + application ID or a regular expression on the window title). +- Build tooling: `Makefile` (build/lint/pot/pack/install/nested), ESLint config, + GitHub Actions CI, and gettext translation scaffolding (`po/`). +- `CONTRIBUTING.md` and this changelog. +- Brazilian Portuguese translation. + +### Changed +- Borders are now drawn with `St.Widget` and CSS colors instead of the deprecated + `Clutter.Color` API (removed in GNOME 47). +- The color picker in preferences uses `Gtk.ColorDialogButton` instead of the + deprecated `Gtk.ColorButton`. +- The preferences keyboard shortcut is now only active in normal mode + (`Shell.ActionMode.NORMAL`). +- `make install` refuses to run when the extension is installed as a symlink into + the repository, because `gnome-extensions install --force` would delete the + repository contents through the symlink. + +### Fixed +- Toggling "Maximize Brightness" in preferences now takes effect immediately. +- `schemas/gschemas.compiled` is no longer tracked in the repository. + +## [1] - 2025 + +### Added +- Initial release: adjustable white screen borders for videocall lighting, with + thickness, opacity, color, temperature, multi-monitor support, automatic screen + brightness maximization, and a panel indicator. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d5e6718 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing to Lighter + +Thanks for your interest in contributing! Lighter is a GNOME Shell extension written in +GJS (GNOME JavaScript) using ES modules, targeting GNOME 45+. + +## Development setup + +Build dependencies: `glib2` (for `glib-compile-schemas`) and `gettext` (for +`make pot` and for compiling translations at pack time) — on Debian/Ubuntu: +`sudo apt install gettext`. + +```bash +git clone https://github.com/joaoferrete/Lighter.git +cd Lighter +make install # packs and installs the extension for your user +``` + +Alternatively, for a symlink-based workflow (edits picked up on shell restart): + +```bash +ln -s "$PWD" ~/.local/share/gnome-shell/extensions/lighter@gnome-shell-extensions.ferrete.com +make build # compiles the GSettings schema locally +``` + +> **Warning:** pick ONE of the two workflows. Never run `make install` while the +> extension is installed as a symlink into your working copy — +> `gnome-extensions install --force` deletes the previous installation, and with a +> symlink that means deleting your repository's contents. `make install` detects +> this and refuses to run, but don't bypass it. + +Enable it with: + +```bash +gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com +``` + +## Testing your changes + +The safest way to test is a nested GNOME Shell session (Wayland): + +```bash +make nested +``` + +Inside the nested session, enable the extension and open its preferences +(`gnome-extensions prefs lighter@...`). + +On X11 you can instead reload the running shell with `Alt+F2`, type `r`, press Enter. +On Wayland (non-nested) you must log out and back in. + +Watch the logs while testing: + +```bash +journalctl -f -o cat /usr/bin/gnome-shell +``` + +And watch settings changes live: + +```bash +dconf watch /org/gnome/shell/extensions/lighter/ +``` + +## Code style + +We follow the [GNOME Shell JavaScript style](https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/docs/js-coding-style.md): +4-space indent, single quotes, semicolons, `camelCase`. + +Run the linter before submitting: + +```bash +npm install +npm run lint +``` + +A few project rules: + +- `preferences/` and `prefs.js` run in a GTK process: never import anything from + `resource:///org/gnome/shell/ui/*` there. +- `extension.js` and shell-side modules in `lib/` run inside the compositor: never + import `Gtk`/`Adw` there. +- `lib/colorUtils.js` and `lib/profileManager.js` are shared by both sides: keep them + free of both Shell UI and GTK imports (only `GLib`/`Gio`). +- Everything created in `enable()` must be destroyed/disconnected in `disable()` + (signals, timeouts, actors, keybindings). This is a hard requirement for + extensions.gnome.org review. +- User-visible strings must be wrapped in `_()` for translation. + +## Translations + +1. Regenerate the template: `make pot` +2. Create or update your language file, e.g.: + `msginit -i po/lighter.pot -o po/pt_BR.po -l pt_BR.UTF-8` (new) or + `msgmerge -U po/pt_BR.po po/lighter.pot` (update) +3. Translate the entries and open a pull request. Compiled `.mo` files are generated + automatically at pack time — do not commit them. + +## Submitting changes + +1. Fork and create a topic branch. +2. Make your changes, run `npm run lint`, and test in a nested session. +3. Update `CHANGELOG.md` under the *Unreleased* heading. +4. Open a pull request describing what changed and why. + +## Reporting bugs + +Open an issue with your GNOME Shell version (`gnome-shell --version`), the extension +version, whether you're on Wayland or X11, and any relevant `journalctl` output. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0d35de2 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +UUID = lighter@gnome-shell-extensions.ferrete.com +ZIP = dist/$(UUID).shell-extension.zip +EXT_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(UUID) + +SOURCES = extension.js prefs.js $(wildcard lib/*.js) $(wildcard preferences/*.js) + +.PHONY: all build lint pot pack install uninstall nested clean + +all: build + +# Compile schemas for local development (a symlinked install needs this; +# `gnome-extensions install` compiles them automatically since GNOME 44) +build: + glib-compile-schemas --strict schemas/ + +lint: + npx eslint $(SOURCES) + +pot: + mkdir -p po + xgettext --from-code=UTF-8 --keyword=_ --keyword=N_ \ + --package-name=lighter --output=po/lighter.pot $(SOURCES) + +pack: + mkdir -p dist + gnome-extensions pack --force --out-dir=dist \ + --extra-source=lib \ + --extra-source=preferences \ + --extra-source=LICENSE \ + --podir=po + +# DANGER: `gnome-extensions install --force` deletes the existing installation +# before extracting the zip. If the installation is a symlink into this repo, +# that deletes the repo contents THROUGH the symlink. Refuse to proceed. +install: pack + @if [ -L "$(EXT_DIR)" ]; then \ + echo "ERROR: $(EXT_DIR) is a symlink (likely into this repo)."; \ + echo "Running 'gnome-extensions install --force' would delete the"; \ + echo "symlink target's contents — i.e. this repository."; \ + echo "If you use the symlink workflow, just run 'make build' and"; \ + echo "restart GNOME Shell. To switch to zip installs, first run:"; \ + echo " rm \"$(EXT_DIR)\""; \ + exit 1; \ + fi + gnome-extensions install --force $(ZIP) + @echo "Restart GNOME Shell (or log out/in on Wayland) and enable with:" + @echo " gnome-extensions enable $(UUID)" + +uninstall: + gnome-extensions uninstall $(UUID) + +# Run a nested GNOME Shell session for testing (Wayland) +nested: + dbus-run-session -- gnome-shell --nested --wayland + +clean: + rm -rf dist schemas/gschemas.compiled diff --git a/README.md b/README.md index 877acd4..e33232b 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,99 @@ # Lighter (GNOME Extension) -![Lighter Icon](https://img.icons8.com/color/96/sun.png) +![CI](https://github.com/joaoferrete/Lighter/actions/workflows/ci.yml/badge.svg) +![GNOME 45+](https://img.shields.io/badge/GNOME-45%20%7C%2046%20%7C%2047-blue) +![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-green) -**Lighter** is a GNOME Shell Extension that improves your videocall lighting at night by adding adjustable white borders around your screen edges. Perfect for laptop users who need extra illumination without external gear. +**Lighter** is a GNOME Shell extension that improves your videocall lighting at night by +adding adjustable light borders around your screen edges — a software ring light. Perfect +for laptop users who need extra illumination without external gear. + + + + ## Features -- **Adjustable Thickness**: From thin lines to thick light bars. -- **Customizable Color**: Full RGB color picker support (warm/cool white, or any color). -- **Opacity Control**: Adjust the brightness to your comfort level. -- **Multi-Monitor Support**: Show on the primary monitor or all screens. -- **Seamless Integration**: Native GNOME Shell feel with a toggle in the top bar. -- **Convenient Shortcut**: Quick access to settings with `Ctrl + Alt + L`. + +- **Per-side thickness**: Adjust each border (top, bottom, left, right) independently, + or keep them linked. Set a side to 0 to disable it — e.g. only bottom + sides for a + lower fill light. +- **Profiles**: Save your setups as named profiles (e.g. *Google Meet*) and switch + between them from the panel menu or the preferences window. +- **Automatic profile switching** *(optional)*: Apply a profile automatically when a + matching window is focused — match by application ID or by a regex on the window + title (works for Meet running in a browser tab). +- **Customizable color & temperature**: Full RGB color picker, plus a warm↔cold + temperature slider when the color is white. +- **Opacity control**: Adjust the light intensity to your comfort level. +- **Maximize brightness**: Optionally push the screen backlight to 100% while active, + restoring your previous level when turned off. +- **Multi-monitor support**: Show on the primary monitor or all screens. +- **Seamless integration**: Native GNOME Shell feel with a toggle in the top bar. +- **Convenient shortcut**: `Ctrl + Alt + L` opens the preferences. ## Requirements - GNOME Shell 45, 46, or 47 -- `glib2` (provides `glib-compile-schemas`, usually pre-installed on GNOME systems) -On Debian/Ubuntu, ensure the required packages are installed: +## Installation + +### From source + ```bash -sudo apt install gnome-shell-extensions glib-2.0-dev +git clone https://github.com/joaoferrete/Lighter.git +cd Lighter +make install ``` -On Fedora: +Then restart GNOME Shell (log out/in on Wayland, or `Alt + F2` → `r` → `Enter` on X11) +and enable the extension: + ```bash -sudo dnf install gnome-shell gnome-extensions-app glib2-devel +gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com ``` -## Installation & Development +## Usage -To run this extension locally: +- Click the ☀ indicator in the top bar to toggle the light, pick a profile, or adjust + the color temperature. +- Open the full preferences with `Ctrl + Alt + L` or: -### 1. Clone & Link -Clone this repository and create a symbolic link in the GNOME extensions directory: -```bash -mkdir -p ~/.local/share/gnome-shell/extensions -ln -s "$(pwd)" ~/.local/share/gnome-shell/extensions/lighter@gnome-shell-extensions.ferrete.com -``` + ```bash + gnome-extensions prefs lighter@gnome-shell-extensions.ferrete.com + ``` -### 2. Compile Schemas -The settings schema must be compiled for the extension to work: -```bash -glib-compile-schemas schemas/ -``` +### Profiles -### 3. Restart GNOME Shell -- **X11**: Press `Alt + F2`, type `r`, and hit `Enter`. -- **Wayland**: Log out and log back in, or restart your session. +On the **Profiles** page of the preferences, click **+** to save the current settings +as a named profile. Each profile stores thickness, opacity, color, temperature, +brightness, and monitor options. -### 4. Enable the Extension -Once the shell has restarted, enable the extension: -```bash -gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com -``` +To switch profiles automatically, enable **Auto-switch profiles** and give a profile a +match rule — an application ID (e.g. `firefox.desktop`) and/or a window title regex +(e.g. `Meet`). When a focused window matches, the profile is applied; when nothing +matches, the optional *default profile* is applied. Manual selection always works and +takes effect immediately. + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Quick start: -### 5. Open Preferences -To configure the borders (thickness, color, etc.), open the preferences window from the GNOME Extensions app or run: ```bash -gnome-extensions prefs lighter@gnome-shell-extensions.ferrete.com +make install # pack and install locally +make nested # test in a nested GNOME Shell session (Wayland) +make lint # run ESLint (requires `npm install` once) +make pot # regenerate the translation template ``` -**Shortcut:** You can also press `Ctrl + Alt + L` at any time to open the preferences directly. - ### Troubleshooting -If the extension shows as ERROR state after code changes, you must restart GNOME Shell (step 3) to reload the updated code. Check logs with: + +If the extension shows an ERROR state after code changes, restart GNOME Shell to reload +the updated code. Check logs with: + ```bash journalctl /usr/bin/gnome-shell -b | grep -i lighter ``` + +## License + +[GPL-3.0](LICENSE) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..465c9f1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,47 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +// Style rules follow GNOME Shell conventions: +// https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/lint/ +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.es2021, + // GJS globals + ARGV: 'readonly', + imports: 'readonly', + console: 'readonly', + log: 'readonly', + logError: 'readonly', + print: 'readonly', + printerr: 'readonly', + TextDecoder: 'readonly', + TextEncoder: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + // GNOME Shell global context object + global: 'readonly', + }, + }, + rules: { + 'camelcase': ['error', {properties: 'never', allow: ['^vfunc_', '^on_']}], + 'comma-dangle': ['error', 'always-multiline'], + 'eqeqeq': 'error', + 'indent': ['error', 4, {SwitchCase: 1}], + 'no-unused-vars': ['error', {varsIgnorePattern: '^_', argsIgnorePattern: '^_'}], + 'no-var': 'error', + 'prefer-const': 'error', + 'quotes': ['error', 'single', {avoidEscape: true}], + 'semi': ['error', 'always'], + }, + }, + { + ignores: ['dist/', 'node_modules/'], + }, +]; diff --git a/extension.js b/extension.js index c2d454b..c6e6f57 100644 --- a/extension.js +++ b/extension.js @@ -1,167 +1,59 @@ -import Clutter from 'gi://Clutter'; -import GObject from 'gi://GObject'; -import Gio from 'gi://Gio'; -import St from 'gi://St'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; -import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; -import * as Slider from 'resource:///org/gnome/shell/ui/slider.js'; import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; -import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; - -const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper( - '' + - ' ' + - ' ' + - ' ' + - '' -); - -const LightBorder = GObject.registerClass( - class LightBorder extends Clutter.Actor { - _init(params) { - super._init(params); - this.set_reactive(false); // Click-through - } - } -); - -const LighterSliderItem = GObject.registerClass( - class LighterSliderItem extends PopupMenu.PopupBaseMenuItem { - _init(value) { - super._init({ - activate: false, - can_focus: false, - }); - - this._slider = new Slider.Slider(value); - this.add_child(this._slider); - - // Allow slider to expand - this._slider.x_expand = true; - } - - get value() { - return this._slider.value; - } - - set value(v) { - this._slider.value = v; - } - - connectSlider(signal, callback) { - if (signal === 'value-changed') { - return this._slider.connect('notify::value', () => { - callback(this._slider, this._slider.value); - }); - } - return this._slider.connect(signal, callback); - } - } -); - -const LighterIndicator = GObject.registerClass( - class LighterIndicator extends PanelMenu.Button { - _init(extension) { - super._init(0.0, 'Lighter'); - this._extension = extension; - this._settings = extension.getSettings(); - - const icon = new St.Icon({ - icon_name: 'display-brightness-symbolic', - style_class: 'system-status-icon', - }); - this.add_child(icon); - - // Active Toggle - this._activeItem = new PopupMenu.PopupSwitchMenuItem(_('Active'), this._settings.get_boolean('enabled')); - this._activeItem.connect('toggled', (item, state) => { - this._settings.set_boolean('enabled', state); - }); - this.menu.addMenuItem(this._activeItem); - - // Temperature Slider - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - const tempItem = new PopupMenu.PopupMenuItem(_('Temperature')); - tempItem.reactive = false; - this.menu.addMenuItem(tempItem); - - this._tempSlider = new LighterSliderItem(this._settings.get_int('temperature') / 100); - this._tempSlider.connectSlider('value-changed', (slider, value) => { - this._settings.set_int('temperature', Math.round(value * 100)); - }); - this.menu.addMenuItem(this._tempSlider); - - // Settings Button - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - const settingsItem = new PopupMenu.PopupMenuItem(_('Settings')); - settingsItem.connect('activate', () => { - this._extension.openPreferences(); - }); - this.menu.addMenuItem(settingsItem); - - // Sync UI with settings - this._changedId = this._settings.connect('changed', (settings, key) => { - if (key === 'enabled') - this._activeItem.setToggleState(settings.get_boolean('enabled')); - else if (key === 'temperature') - this._tempSlider.value = settings.get_int('temperature') / 100; - }); - } - - destroy() { - if (this._changedId) { - this._settings.disconnect(this._changedId); - this._changedId = 0; - } - super.destroy(); - } - } -); +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {BorderManager} from './lib/borderManager.js'; +import {BrightnessController} from './lib/brightness.js'; +import {LighterIndicator} from './lib/indicator.js'; +import {ProfileManager} from './lib/profileManager.js'; +import {WindowWatcher} from './lib/windowWatcher.js'; +import {migrateSettings} from './lib/migrations.js'; + +// Keys whose changes require redrawing the borders +const SYNC_KEYS = [ + 'enabled', + 'thickness-top', + 'thickness-bottom', + 'thickness-left', + 'thickness-right', + 'opacity', + 'color', + 'temperature', + 'multi-monitor', + 'maximize-brightness', +]; export default class LighterExtension extends Extension { enable() { try { - console.log('Lighter Extension Enabled v1.4 - Fixed color parsing'); this._settings = this.getSettings(); - this._borders = []; - this._oldBrightness = -1; - - // Indicator - this._indicator = new LighterIndicator(this); + migrateSettings(this._settings); + + this._brightness = new BrightnessController(); + this._borders = new BorderManager(this._settings); + this._profiles = new ProfileManager(this._settings); + + this._indicator = new LighterIndicator(this, this._profiles); Main.panel.addToStatusArea(this.uuid, this._indicator); - // Keybinding Main.wm.addKeybinding( 'open-prefs-shortcut', this._settings, Meta.KeyBindingFlags.NONE, - Shell.ActionMode.ALL, - () => { - this.openPreferences(); - } - ); - - // Brightness Proxy - this._proxy = new BrightnessProxy( - Gio.DBus.session, - 'org.gnome.SettingsDaemon.Power', - '/org/gnome/SettingsDaemon/Power/Screen' + Shell.ActionMode.NORMAL, + () => this.openPreferences(), ); - // Listen for internal changes - this._settingsIds = [ - this._settings.connect('changed::enabled', () => this._sync()), - this._settings.connect('changed::thickness', () => this._sync()), - this._settings.connect('changed::opacity', () => this._sync()), - this._settings.connect('changed::color', () => this._sync()), - this._settings.connect('changed::temperature', () => this._sync()), - this._settings.connect('changed::multi-monitor', () => this._sync()), - ]; + this._settingsId = this._settings.connect('changed', (settings, key) => { + if (SYNC_KEYS.includes(key)) + this._sync(); + }); + this._monitorsChangedId = Main.layoutManager.connect( + 'monitors-changed', () => this._sync()); - // Also sync on monitor changes - this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._sync()); + this._watcher = new WindowWatcher(this._settings, this._profiles); + this._watcher.enable(); this._sync(); } catch (e) { @@ -171,164 +63,49 @@ export default class LighterExtension extends Extension { } disable() { - this._restoreBrightness(); - this._settingsIds.forEach(id => this._settings.disconnect(id)); - Main.layoutManager.disconnect(this._monitorsChangedId); - - if (this._indicator) { - this._indicator.destroy(); - this._indicator = null; - } - - this._clearBorders(); - this._settings = null; - this._proxy = null; - Main.wm.removeKeybinding('open-prefs-shortcut'); - } - - _restoreBrightness() { - if (this._oldBrightness !== -1 && this._proxy) { - this._proxy.Brightness = this._oldBrightness; - this._oldBrightness = -1; + if (this._watcher) { + this._watcher.disable(); + this._watcher = null; } - } - - _clearBorders() { - this._borders.forEach(b => b.destroy()); - this._borders = []; - } - _sync() { - this._clearBorders(); - - const enabled = this._settings.get_boolean('enabled'); - - // Handle brightness - if (enabled && this._settings.get_boolean('maximize-brightness')) { - if (this._oldBrightness === -1 && this._proxy) { - this._oldBrightness = this._proxy.Brightness; - this._proxy.Brightness = 100; - } - } else { - this._restoreBrightness(); + if (this._settingsId) { + this._settings.disconnect(this._settingsId); + this._settingsId = 0; } - if (!enabled) - return; - - const thickness = this._settings.get_int('thickness'); - const opacity = this._settings.get_double('opacity'); - const colorStr = this._settings.get_string('color'); - const multiMonitor = this._settings.get_boolean('multi-monitor'); - const temperature = this._settings.get_int('temperature'); - - let color; - // If color is default white, use temperature for warm/cool tint - const parsed = this._parseColor(colorStr); - if (parsed.red === 255 && parsed.green === 255 && parsed.blue === 255) { - color = this._getColorFromTemperature(temperature); - } else { - color = parsed; + if (this._monitorsChangedId) { + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; } - color.alpha = Math.floor(opacity * 255); - - const monitors = multiMonitor ? Main.layoutManager.monitors : [Main.layoutManager.primaryMonitor]; - for (const monitor of monitors) { - this._createBordersForMonitor(monitor, thickness, color); + if (this._indicator) { + this._indicator.destroy(); + this._indicator = null; } - } - _parseColor(str) { - try { - // Handle hex format: #rrggbb - const hexMatch = str.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); - if (hexMatch) { - return new Clutter.Color({ - red: parseInt(hexMatch[1], 16), - green: parseInt(hexMatch[2], 16), - blue: parseInt(hexMatch[3], 16), - alpha: 255, - }); - } - // Handle rgb/rgba format: rgb(r,g,b) or rgba(r,g,b,a) - const rgbMatch = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (rgbMatch) { - return new Clutter.Color({ - red: parseInt(rgbMatch[1]), - green: parseInt(rgbMatch[2]), - blue: parseInt(rgbMatch[3]), - alpha: 255, - }); - } - } catch (e) { - console.error(`Lighter: Failed to parse color "${str}": ${e}`); + if (this._borders) { + this._borders.destroy(); + this._borders = null; } - return new Clutter.Color({ red: 255, green: 255, blue: 255, alpha: 255 }); - } - _getColorFromTemperature(temp) { - // Linear interpolation: - // 0 (Warm): [255, 147, 41] - // 50 (Neutral): [255, 255, 255] - // 100 (Cold): [201, 226, 255] - - let r, g, b; - if (temp <= 50) { - const factor = temp / 50; - r = 255; - g = 147 + (255 - 147) * factor; - b = 41 + (255 - 41) * factor; - } else { - const factor = (temp - 50) / 50; - r = 255 - (255 - 201) * factor; - g = 255 - (255 - 226) * factor; - b = 255; + if (this._brightness) { + this._brightness.destroy(); + this._brightness = null; } - return new Clutter.Color({ - red: Math.floor(r), - green: Math.floor(g), - blue: Math.floor(b) - }); + Main.wm.removeKeybinding('open-prefs-shortcut'); + this._profiles = null; + this._settings = null; } - _createBordersForMonitor(monitor, thickness, color) { - const {x, y, width, height} = monitor; - - // Create 4 actors for this monitor - const borders = [ - this._addBorder(x, y, width, thickness, color), // Top - this._addBorder(x, y + height - thickness, width, thickness, color), // Bottom - this._addBorder(x, y + thickness, thickness, height - 2 * thickness, color), // Left - this._addBorder(x + width - thickness, y + thickness, thickness, height - 2 * thickness, color), // Right - ]; - - // Animate them in - const targetOpacity = color.alpha; - borders.forEach(b => { - b.opacity = 0; - b.ease({ - opacity: targetOpacity, - duration: 500, - mode: Clutter.AnimationMode.EASE_OUT_QUAD - }); - }); - } + _sync() { + const enabled = this._settings.get_boolean('enabled'); - _addBorder(x, y, width, height, color) { - const border = new LightBorder({ - x, y, width, height, - background_color: color - }); - - // Add as chrome (Always on Top) - Main.layoutManager.addChrome(border, { - affectsInputRegion: false, - trackFullscreen: true - }); + if (enabled && this._settings.get_boolean('maximize-brightness')) + this._brightness.maximize(); + else + this._brightness.restore(); - this._borders.push(border); - return border; + this._borders.sync(); } } diff --git a/lib/borderManager.js b/lib/borderManager.js new file mode 100644 index 0000000..7d86d9f --- /dev/null +++ b/lib/borderManager.js @@ -0,0 +1,108 @@ +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {resolveColor, toBackgroundCss} from './colorUtils.js'; + +const FADE_IN_MS = 500; + +const LightBorder = GObject.registerClass( + class LightBorder extends St.Widget { + _init(params) { + super._init(params); + this.set_reactive(false); // Click-through + } + }, +); + +/** + * Draws the light borders along the monitor edges based on the current settings. + * Profiles are invisible to this class: it only reads the flat appearance keys. + */ +export class BorderManager { + constructor(settings) { + this._settings = settings; + this._borders = []; + } + + sync() { + this.clear(); + + if (!this._settings.get_boolean('enabled')) + return; + + const sides = { + top: this._settings.get_int('thickness-top'), + bottom: this._settings.get_int('thickness-bottom'), + left: this._settings.get_int('thickness-left'), + right: this._settings.get_int('thickness-right'), + }; + const color = resolveColor( + this._settings.get_string('color'), + this._settings.get_int('temperature')); + const cssColor = toBackgroundCss(color); + const targetOpacity = Math.floor(this._settings.get_double('opacity') * 255); + + const monitors = (this._settings.get_boolean('multi-monitor') + ? Main.layoutManager.monitors + : [Main.layoutManager.primaryMonitor]).filter(m => m); + + for (const monitor of monitors) + this._createBordersForMonitor(monitor, sides, cssColor, targetOpacity); + } + + clear() { + this._borders.forEach(b => b.destroy()); + this._borders = []; + } + + destroy() { + this.clear(); + this._borders = null; + this._settings = null; + } + + _createBordersForMonitor(monitor, sides, cssColor, targetOpacity) { + const {x, y, width, height} = monitor; + const {top, bottom, left, right} = sides; + // Left/right bars fill the space between the horizontal bars; a side + // with thickness 0 is simply not drawn. + const sideHeight = Math.max(0, height - top - bottom); + + const borders = []; + if (top > 0) + borders.push(this._addBorder(x, y, width, top, cssColor)); + if (bottom > 0) + borders.push(this._addBorder(x, y + height - bottom, width, bottom, cssColor)); + if (left > 0 && sideHeight > 0) + borders.push(this._addBorder(x, y + top, left, sideHeight, cssColor)); + if (right > 0 && sideHeight > 0) + borders.push(this._addBorder(x + width - right, y + top, right, sideHeight, cssColor)); + + borders.forEach(b => { + b.opacity = 0; + b.ease({ + opacity: targetOpacity, + duration: FADE_IN_MS, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + } + + _addBorder(x, y, width, height, cssColor) { + const border = new LightBorder({ + x, y, width, height, + style: cssColor, + }); + + // Add as chrome (Always on Top) + Main.layoutManager.addChrome(border, { + affectsInputRegion: false, + trackFullscreen: true, + }); + + this._borders.push(border); + return border; + } +} diff --git a/lib/brightness.js b/lib/brightness.js new file mode 100644 index 0000000..213eeb6 --- /dev/null +++ b/lib/brightness.js @@ -0,0 +1,43 @@ +import Gio from 'gi://Gio'; + +const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper( + '' + + ' ' + + ' ' + + ' ' + + '', +); + +/** + * Controls the screen backlight through gnome-settings-daemon, remembering the + * user's brightness so it can be restored later. + */ +export class BrightnessController { + constructor() { + this._saved = -1; + this._proxy = new BrightnessProxy( + Gio.DBus.session, + 'org.gnome.SettingsDaemon.Power', + '/org/gnome/SettingsDaemon/Power/Screen', + ); + } + + maximize() { + if (this._saved === -1 && this._proxy) { + this._saved = this._proxy.Brightness; + this._proxy.Brightness = 100; + } + } + + restore() { + if (this._saved !== -1 && this._proxy) { + this._proxy.Brightness = this._saved; + this._saved = -1; + } + } + + destroy() { + this.restore(); + this._proxy = null; + } +} diff --git a/lib/colorUtils.js b/lib/colorUtils.js new file mode 100644 index 0000000..bc8bf1d --- /dev/null +++ b/lib/colorUtils.js @@ -0,0 +1,80 @@ +// Pure color helpers shared by the shell process and the preferences process. +// Keep this module free of Shell UI and GTK imports. + +/** + * Parse a `#rrggbb` or `rgb()/rgba()` string into a plain color object. + * + * @param {string} str the color string + * @returns {{r: number, g: number, b: number}} the parsed color (white on failure) + */ +export function parseColor(str) { + try { + const hexMatch = str.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (hexMatch) { + return { + r: parseInt(hexMatch[1], 16), + g: parseInt(hexMatch[2], 16), + b: parseInt(hexMatch[3], 16), + }; + } + + const rgbMatch = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (rgbMatch) { + return { + r: parseInt(rgbMatch[1]), + g: parseInt(rgbMatch[2]), + b: parseInt(rgbMatch[3]), + }; + } + } catch (e) { + console.error(`Lighter: Failed to parse color "${str}": ${e}`); + } + return {r: 255, g: 255, b: 255}; +} + +/** + * Map a temperature value to a color. + * Linear interpolation: 0 (warm) [255, 147, 41] → 50 (neutral) [255, 255, 255] + * → 100 (cold) [201, 226, 255]. + * + * @param {number} temp temperature in the 0–100 range + * @returns {{r: number, g: number, b: number}} the interpolated color + */ +export function colorFromTemperature(temp) { + let r, g, b; + if (temp <= 50) { + const factor = temp / 50; + r = 255; + g = 147 + (255 - 147) * factor; + b = 41 + (255 - 41) * factor; + } else { + const factor = (temp - 50) / 50; + r = 255 - (255 - 201) * factor; + g = 255 - (255 - 226) * factor; + b = 255; + } + + return {r: Math.floor(r), g: Math.floor(g), b: Math.floor(b)}; +} + +/** + * Resolve the effective border color: pure white means "use the temperature". + * + * @param {string} colorStr the configured color string + * @param {number} temperature temperature in the 0–100 range + * @returns {{r: number, g: number, b: number}} the effective color + */ +export function resolveColor(colorStr, temperature) { + const parsed = parseColor(colorStr); + if (parsed.r === 255 && parsed.g === 255 && parsed.b === 255) + return colorFromTemperature(temperature); + return parsed; +} + +/** + * @param {{r: number, g: number, b: number}} color a plain color object + * @returns {string} a CSS background-color declaration + */ +export function toBackgroundCss(color) { + return `background-color: rgb(${color.r}, ${color.g}, ${color.b});`; +} diff --git a/lib/indicator.js b/lib/indicator.js new file mode 100644 index 0000000..6174126 --- /dev/null +++ b/lib/indicator.js @@ -0,0 +1,145 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import * as Slider from 'resource:///org/gnome/shell/ui/slider.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const LighterSliderItem = GObject.registerClass( + class LighterSliderItem extends PopupMenu.PopupBaseMenuItem { + _init(value) { + super._init({ + activate: false, + can_focus: false, + }); + + this._slider = new Slider.Slider(value); + this.add_child(this._slider); + + // Allow slider to expand + this._slider.x_expand = true; + } + + get value() { + return this._slider.value; + } + + set value(v) { + this._slider.value = v; + } + + connectSlider(signal, callback) { + if (signal === 'value-changed') { + return this._slider.connect('notify::value', () => { + callback(this._slider, this._slider.value); + }); + } + return this._slider.connect(signal, callback); + } + }, +); + +export const LighterIndicator = GObject.registerClass( + class LighterIndicator extends PanelMenu.Button { + _init(extension, profileManager) { + super._init(0.0, 'Lighter'); + this._extension = extension; + this._settings = extension.getSettings(); + this._profiles = profileManager; + + const icon = new St.Icon({ + icon_name: 'display-brightness-symbolic', + style_class: 'system-status-icon', + }); + this.add_child(icon); + + // Active Toggle + this._activeItem = new PopupMenu.PopupSwitchMenuItem(_('Active'), + this._settings.get_boolean('enabled')); + this._activeItem.connect('toggled', (item, state) => { + this._settings.set_boolean('enabled', state); + }); + this.menu.addMenuItem(this._activeItem); + + // Profiles + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._profileSubMenu = new PopupMenu.PopupSubMenuMenuItem(_('Profile')); + this.menu.addMenuItem(this._profileSubMenu); + + this._autoSwitchItem = new PopupMenu.PopupSwitchMenuItem(_('Auto-switch profile'), + this._settings.get_boolean('auto-switch')); + this._autoSwitchItem.connect('toggled', (item, state) => { + this._settings.set_boolean('auto-switch', state); + }); + this.menu.addMenuItem(this._autoSwitchItem); + + // Temperature Slider + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + const tempItem = new PopupMenu.PopupMenuItem(_('Temperature')); + tempItem.reactive = false; + this.menu.addMenuItem(tempItem); + + this._tempSlider = new LighterSliderItem(this._settings.get_int('temperature') / 100); + this._tempSlider.connectSlider('value-changed', (slider, value) => { + this._settings.set_int('temperature', Math.round(value * 100)); + }); + this.menu.addMenuItem(this._tempSlider); + + // Settings Button + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + const settingsItem = new PopupMenu.PopupMenuItem(_('Settings')); + settingsItem.connect('activate', () => { + this._extension.openPreferences(); + }); + this.menu.addMenuItem(settingsItem); + + this._rebuildProfileMenu(); + + // Sync UI with settings + this._changedId = this._settings.connect('changed', (settings, key) => { + if (key === 'enabled') + this._activeItem.setToggleState(settings.get_boolean('enabled')); + else if (key === 'temperature') + this._tempSlider.value = settings.get_int('temperature') / 100; + else if (key === 'auto-switch') + this._autoSwitchItem.setToggleState(settings.get_boolean('auto-switch')); + else if (key === 'profiles' || key === 'active-profile') + this._rebuildProfileMenu(); + }); + } + + _rebuildProfileMenu() { + this._profileSubMenu.menu.removeAll(); + + const profiles = this._profiles.getProfiles(); + const hasProfiles = profiles.length > 0; + this._profileSubMenu.visible = hasProfiles; + this._autoSwitchItem.visible = hasProfiles; + if (!hasProfiles) + return; + + const activeId = this._settings.get_string('active-profile'); + for (const profile of profiles) { + const item = new PopupMenu.PopupMenuItem(profile.name); + item.setOrnament(profile.id === activeId + ? PopupMenu.Ornament.DOT + : PopupMenu.Ornament.NONE); + item.connect('activate', () => { + this._profiles.applyProfile(profile.id); + }); + this._profileSubMenu.menu.addMenuItem(item); + } + } + + destroy() { + if (this._changedId) { + this._settings.disconnect(this._changedId); + this._changedId = 0; + } + this._settings = null; + this._profiles = null; + this._extension = null; + super.destroy(); + } + }, +); diff --git a/lib/migrations.js b/lib/migrations.js new file mode 100644 index 0000000..5fe21c4 --- /dev/null +++ b/lib/migrations.js @@ -0,0 +1,18 @@ +const CURRENT_VERSION = 2; + +/** + * Upgrade settings written by older versions of the extension. + * Version 2 replaced the single `thickness` key with per-side keys. + * + * @param {Gio.Settings} settings the extension settings + */ +export function migrateSettings(settings) { + if (settings.get_int('settings-version') >= CURRENT_VERSION) + return; + + const thickness = settings.get_int('thickness'); + for (const side of ['top', 'bottom', 'left', 'right']) + settings.set_int(`thickness-${side}`, thickness); + + settings.set_int('settings-version', CURRENT_VERSION); +} diff --git a/lib/profileManager.js b/lib/profileManager.js new file mode 100644 index 0000000..437df5f --- /dev/null +++ b/lib/profileManager.js @@ -0,0 +1,178 @@ +import GLib from 'gi://GLib'; + +// Appearance keys captured in a profile, with their GSettings type. +// Shared by the shell process and the preferences process — keep this module +// free of Shell UI and GTK imports. +export const PROFILE_KEY_TYPES = { + 'thickness-top': 'i', + 'thickness-bottom': 'i', + 'thickness-left': 'i', + 'thickness-right': 'i', + 'thickness-linked': 'b', + 'opacity': 'd', + 'color': 's', + 'temperature': 'i', + 'maximize-brightness': 'b', + 'multi-monitor': 'b', +}; + +export const PROFILE_KEYS = Object.keys(PROFILE_KEY_TYPES); + +const FORMAT_VERSION = 1; + +/** + * Stores named snapshots of the appearance settings in the `profiles` JSON key. + * + * The flat settings keys remain the source of truth for what is on screen; + * applying a profile copies its values onto them in a single batch, so all + * existing bindings and listeners keep working unchanged. + */ +export class ProfileManager { + constructor(settings) { + this._settings = settings; + } + + /** + * @returns {object[]} the saved profiles (empty on missing/invalid JSON) + */ + getProfiles() { + try { + const parsed = JSON.parse(this._settings.get_string('profiles')); + if (Array.isArray(parsed)) + return parsed; + if (parsed && Array.isArray(parsed.profiles)) + return parsed.profiles; + } catch (e) { + console.error(`Lighter: Invalid profiles JSON, ignoring: ${e}`); + } + return []; + } + + getProfile(id) { + return this.getProfiles().find(p => p.id === id) ?? null; + } + + /** + * Snapshot the current flat settings as a new profile. + * + * @param {string} name the profile name + * @returns {object} the created profile + */ + createProfile(name) { + const profile = { + id: GLib.uuid_string_random(), + name, + settings: this._snapshotSettings(), + match: {enabled: false, 'app-ids': [], 'title-regex': '', priority: 0}, + }; + this._save([...this.getProfiles(), profile]); + return profile; + } + + /** + * Shallow-merge a patch ({name, settings, match}) into a profile. + * + * @param {string} id the profile id + * @param {object} patch fields to replace + * @returns {object|null} the updated profile + */ + updateProfile(id, patch) { + const profiles = this.getProfiles(); + const profile = profiles.find(p => p.id === id); + if (!profile) + return null; + + if (patch.name !== undefined) + profile.name = patch.name; + if (patch.settings !== undefined) + profile.settings = patch.settings; + if (patch.match !== undefined) + profile.match = {...profile.match, ...patch.match}; + + this._save(profiles); + return profile; + } + + /** Re-snapshot the current flat settings into an existing profile. + * + * @param {string} id the profile id + * @returns {object|null} the updated profile + */ + updateProfileFromCurrentSettings(id) { + return this.updateProfile(id, {settings: this._snapshotSettings()}); + } + + deleteProfile(id) { + this._save(this.getProfiles().filter(p => p.id !== id)); + + if (this._settings.get_string('active-profile') === id) + this._settings.set_string('active-profile', ''); + if (this._settings.get_string('default-profile') === id) + this._settings.set_string('default-profile', ''); + } + + duplicateProfile(id) { + const source = this.getProfile(id); + if (!source) + return null; + + const copy = JSON.parse(JSON.stringify(source)); + copy.id = GLib.uuid_string_random(); + copy.name = `${source.name} (copy)`; + this._save([...this.getProfiles(), copy]); + return copy; + } + + /** + * Copy a profile's values onto the flat settings keys in a single batch, + * and mark it as the active profile. Fields missing from the profile + * (saved by older versions) keep their current values. + * + * @param {string} id the profile id + * @returns {boolean} whether the profile existed and was applied + */ + applyProfile(id) { + const profile = this.getProfile(id); + if (!profile) + return false; + + this._settings.delay(); + for (const [key, type] of Object.entries(PROFILE_KEY_TYPES)) { + if (profile.settings?.[key] !== undefined) + this._setValue(key, type, profile.settings[key]); + } + this._settings.set_string('active-profile', profile.id); + this._settings.apply(); + return true; + } + + _snapshotSettings() { + const snapshot = {}; + for (const [key, type] of Object.entries(PROFILE_KEY_TYPES)) + snapshot[key] = this._getValue(key, type); + return snapshot; + } + + _save(profiles) { + this._settings.set_string('profiles', + JSON.stringify({version: FORMAT_VERSION, profiles})); + } + + _getValue(key, type) { + switch (type) { + case 'b': return this._settings.get_boolean(key); + case 'i': return this._settings.get_int(key); + case 'd': return this._settings.get_double(key); + default: return this._settings.get_string(key); + } + } + + _setValue(key, type, value) { + switch (type) { + case 'b': this._settings.set_boolean(key, value); break; + case 'i': this._settings.set_int(key, value); break; + case 'd': this._settings.set_double(key, value); break; + default: this._settings.set_string(key, value); + } + } +} diff --git a/lib/windowWatcher.js b/lib/windowWatcher.js new file mode 100644 index 0000000..d628679 --- /dev/null +++ b/lib/windowWatcher.js @@ -0,0 +1,165 @@ +import GLib from 'gi://GLib'; +import Shell from 'gi://Shell'; + +const DEBOUNCE_MS = 300; + +/** + * Watches the focused window and applies the first matching profile. + * + * Listens to focus changes and to title changes of the focused window — + * switching browser tabs changes the title without changing focus, which is + * how a "Google Meet" profile can kick in when its tab is selected. + */ +export class WindowWatcher { + constructor(settings, profileManager) { + this._settings = settings; + this._profiles = profileManager; + this._focusId = 0; + this._titleId = 0; + this._titleWindow = null; + this._debounceId = 0; + this._settingsIds = null; + this._regexCache = new Map(); + } + + enable() { + this._focusId = global.display.connect('notify::focus-window', + () => this._onFocusChanged()); + this._settingsIds = [ + this._settings.connect('changed::profiles', () => { + this._regexCache.clear(); + this._scheduleEvaluate(); + }), + this._settings.connect('changed::auto-switch', + () => this._scheduleEvaluate()), + this._settings.connect('changed::default-profile', + () => this._scheduleEvaluate()), + ]; + this._onFocusChanged(); + } + + disable() { + if (this._debounceId) { + GLib.source_remove(this._debounceId); + this._debounceId = 0; + } + this._disconnectTitle(); + if (this._focusId) { + global.display.disconnect(this._focusId); + this._focusId = 0; + } + if (this._settingsIds) { + this._settingsIds.forEach(id => this._settings.disconnect(id)); + this._settingsIds = null; + } + this._regexCache.clear(); + this._settings = null; + this._profiles = null; + } + + _disconnectTitle() { + if (this._titleWindow && this._titleId) { + try { + this._titleWindow.disconnect(this._titleId); + } catch { + // The window may already be gone + } + } + this._titleWindow = null; + this._titleId = 0; + } + + _onFocusChanged() { + this._disconnectTitle(); + + const win = global.display.focus_window; + if (win) { + this._titleWindow = win; + this._titleId = win.connect('notify::title', + () => this._scheduleEvaluate()); + } + this._scheduleEvaluate(); + } + + _scheduleEvaluate() { + if (this._debounceId) + GLib.source_remove(this._debounceId); + this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DEBOUNCE_MS, () => { + this._debounceId = 0; + this._evaluate(); + return GLib.SOURCE_REMOVE; + }); + } + + _evaluate() { + if (!this._settings.get_boolean('auto-switch')) + return; + + const win = global.display.focus_window; + const target = this._resolveProfile(win) ?? this._defaultProfile(); + // Compare against the active profile (not the last auto-applied one) so + // that refocusing a matching window wins over an earlier manual switch. + if (target && target.id !== this._settings.get_string('active-profile')) + this._profiles.applyProfile(target.id); + } + + _defaultProfile() { + const id = this._settings.get_string('default-profile'); + return id ? this._profiles.getProfile(id) : null; + } + + /** + * Find the highest-priority profile whose match rule fits the window. + * A rule matches if any of its app ids fits OR its title regex matches. + * + * @param {Meta.Window|null} win the focused window + * @returns {object|null} the matching profile + */ + _resolveProfile(win) { + if (!win) + return null; + + const app = Shell.WindowTracker.get_default().get_window_app(win); + const appId = app?.get_id() ?? ''; + const wmClass = win.get_wm_class() ?? ''; + const title = win.get_title() ?? ''; + + const candidates = this._profiles.getProfiles() + .filter(p => p.match?.enabled) + .sort((a, b) => (b.match.priority ?? 0) - (a.match.priority ?? 0)); + + for (const profile of candidates) { + const appHit = profile.match['app-ids'] + ?.some(id => this._matchesApp(id, appId, wmClass)); + const regex = this._regexFor(profile); + const titleHit = regex ? regex.test(title) : false; + if (appHit || titleHit) + return profile; + } + return null; + } + + _matchesApp(pattern, appId, wmClass) { + const normalize = s => s.toLowerCase().replace(/\.desktop$/, ''); + const wanted = normalize(pattern.trim()); + return wanted !== '' && + (wanted === normalize(appId) || wanted === normalize(wmClass)); + } + + _regexFor(profile) { + const pattern = profile.match?.['title-regex'] ?? ''; + if (!pattern) + return null; + + if (!this._regexCache.has(profile.id)) { + let regex = null; + try { + regex = new RegExp(pattern, 'i'); + } catch { + console.error(`Lighter: Invalid title regex for profile "${profile.name}": ${pattern}`); + } + this._regexCache.set(profile.id, regex); + } + return this._regexCache.get(profile.id); + } +} diff --git a/metadata.json b/metadata.json index bf0fa98..a498a5b 100644 --- a/metadata.json +++ b/metadata.json @@ -8,6 +8,8 @@ "47" ], "settings-schema": "org.gnome.shell.extensions.lighter", + "gettext-domain": "lighter", "url": "https://github.com/joaoferrete/Lighter", - "version": 1 + "version": 1, + "version-name": "2.0" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28eec20 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1111 @@ +{ + "name": "lighter", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lighter", + "devDependencies": { + "@eslint/js": "^9.0.0", + "eslint": "^9.0.0", + "globals": "^15.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dce5b4a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "lighter", + "private": true, + "type": "module", + "description": "Dev tooling for the Lighter GNOME Shell extension (the extension itself has no runtime npm dependencies)", + "scripts": { + "lint": "eslint extension.js prefs.js lib preferences" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "eslint": "^9.0.0", + "globals": "^15.0.0" + } +} diff --git a/po/lighter.pot b/po/lighter.pot new file mode 100644 index 0000000..8aa439f --- /dev/null +++ b/po/lighter.pot @@ -0,0 +1,243 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the lighter package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: lighter\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-14 11:00-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: lib/indicator.js:57 preferences/generalPage.js:30 +msgid "Active" +msgstr "" + +#: lib/indicator.js:66 +msgid "Profile" +msgstr "" + +#: lib/indicator.js:69 +msgid "Auto-switch profile" +msgstr "" + +#: lib/indicator.js:78 preferences/generalPage.js:149 +msgid "Temperature" +msgstr "" + +#: lib/indicator.js:90 +msgid "Settings" +msgstr "" + +#: preferences/generalPage.js:18 preferences/generalPage.js:24 +msgid "General" +msgstr "" + +#: preferences/generalPage.js:25 +msgid "Control the main lighting parameters" +msgstr "" + +#: preferences/generalPage.js:31 +msgid "Turn the screen borders on or off" +msgstr "" + +#: preferences/generalPage.js:37 +msgid "Maximize Brightness" +msgstr "" + +#: preferences/generalPage.js:38 +msgid "Automatically set screen brightness to 100% when active" +msgstr "" + +#: preferences/generalPage.js:44 +msgid "Opacity" +msgstr "" + +#: preferences/generalPage.js:45 +msgid "Adjust the brightness of the light" +msgstr "" + +#: preferences/generalPage.js:57 +msgid "Thickness" +msgstr "" + +#: preferences/generalPage.js:58 +msgid "Width of each border in pixels; 0 disables that side" +msgstr "" + +#: preferences/generalPage.js:63 +msgid "Link sides" +msgstr "" + +#: preferences/generalPage.js:64 +msgid "Changing one side updates all sides" +msgstr "" + +#: preferences/generalPage.js:70 +msgid "Top" +msgstr "" + +#: preferences/generalPage.js:71 +msgid "Bottom" +msgstr "" + +#: preferences/generalPage.js:72 +msgid "Left" +msgstr "" + +#: preferences/generalPage.js:73 +msgid "Right" +msgstr "" + +#: preferences/generalPage.js:121 +msgid "Appearance" +msgstr "" + +#: preferences/generalPage.js:126 +msgid "Color" +msgstr "" + +#: preferences/generalPage.js:127 +msgid "Choose the color of the borders (Default: White)" +msgstr "" + +#: preferences/generalPage.js:150 +msgid "Adjust the color from Warm to Cold (Only if color is White)" +msgstr "" + +#: preferences/generalPage.js:161 +msgid "Multi-Monitor" +msgstr "" + +#: preferences/generalPage.js:162 +msgid "Show borders on all connected screens" +msgstr "" + +#: preferences/profilesPage.js:33 preferences/profilesPage.js:48 +msgid "Cancel" +msgstr "" + +#: preferences/profilesPage.js:34 +msgid "Save" +msgstr "" + +#: preferences/profilesPage.js:49 preferences/profilesPage.js:247 +msgid "Delete" +msgstr "" + +#: preferences/profilesPage.js:72 preferences/profilesPage.js:78 +msgid "Profiles" +msgstr "" + +#: preferences/profilesPage.js:79 +msgid "Save the current settings as a named profile and switch between them" +msgstr "" + +#: preferences/profilesPage.js:83 +msgid "Create a profile from the current settings" +msgstr "" + +#: preferences/profilesPage.js:88 +msgid "New Profile" +msgstr "" + +#: preferences/profilesPage.js:95 +msgid "Automatic Switching" +msgstr "" + +#: preferences/profilesPage.js:96 +msgid "Apply profiles automatically based on the focused window" +msgstr "" + +#: preferences/profilesPage.js:101 +msgid "Auto-switch profiles" +msgstr "" + +#: preferences/profilesPage.js:102 +msgid "Watch the focused window and apply the first matching profile" +msgstr "" + +#: preferences/profilesPage.js:108 +msgid "Default profile" +msgstr "" + +#: preferences/profilesPage.js:109 +msgid "Applied when no window rule matches" +msgstr "" + +#: preferences/profilesPage.js:140 +msgid "Active profile" +msgstr "" + +#: preferences/profilesPage.js:147 +msgid "Apply" +msgstr "" + +#: preferences/profilesPage.js:156 +msgid "Name" +msgstr "" + +#: preferences/profilesPage.js:171 +msgid "Auto-activate" +msgstr "" + +#: preferences/profilesPage.js:172 +msgid "Apply this profile when a window below is focused" +msgstr "" + +#: preferences/profilesPage.js:182 +msgid "Application IDs (comma-separated, e.g. firefox.desktop)" +msgstr "" + +#: preferences/profilesPage.js:197 +msgid "Window title regex (e.g. Meet)" +msgstr "" + +#: preferences/profilesPage.js:216 +msgid "Priority" +msgstr "" + +#: preferences/profilesPage.js:217 +msgid "Higher priority wins when several rules match" +msgstr "" + +#: preferences/profilesPage.js:234 +msgid "Update from current" +msgstr "" + +#: preferences/profilesPage.js:235 +msgid "Overwrite this profile with the current settings" +msgstr "" + +#: preferences/profilesPage.js:242 +msgid "Duplicate" +msgstr "" + +#: preferences/profilesPage.js:253 +msgid "Delete Profile?" +msgstr "" + +#: preferences/profilesPage.js:254 +#, javascript-format +msgid "\"%s\" will be removed permanently." +msgstr "" + +#: preferences/profilesPage.js:270 +msgid "None" +msgstr "" + +#: preferences/profilesPage.js:297 +msgid "No profiles yet" +msgstr "" + +#: preferences/profilesPage.js:298 +msgid "Use the + button to save the current settings as a profile" +msgstr "" diff --git a/po/pt_BR.po b/po/pt_BR.po new file mode 100644 index 0000000..a78f8f3 --- /dev/null +++ b/po/pt_BR.po @@ -0,0 +1,184 @@ +# Brazilian Portuguese translation for the Lighter GNOME Shell extension. +# Copyright (C) 2026 Joao Ferrete +# This file is distributed under the same license as the lighter package. +# +msgid "" +msgstr "" +"Project-Id-Version: lighter\n" +"Report-Msgid-Bugs-To: https://github.com/joaoferrete/Lighter/issues\n" +"PO-Revision-Date: 2026-07-13 11:30-0300\n" +"Last-Translator: Joao Ferrete \n" +"Language-Team: Brazilian Portuguese\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Active" +msgstr "Ativo" + +msgid "Profile" +msgstr "Perfil" + +msgid "Auto-switch profile" +msgstr "Trocar perfil automaticamente" + +msgid "Temperature" +msgstr "Temperatura" + +msgid "Settings" +msgstr "Configurações" + +msgid "General" +msgstr "Geral" + +msgid "Control the main lighting parameters" +msgstr "Controle os principais parâmetros de iluminação" + +msgid "Turn the screen borders on or off" +msgstr "Liga ou desliga as bordas da tela" + +msgid "Maximize Brightness" +msgstr "Maximizar brilho" + +msgid "Automatically set screen brightness to 100% when active" +msgstr "Define automaticamente o brilho da tela em 100% quando ativo" + +msgid "Opacity" +msgstr "Opacidade" + +msgid "Adjust the brightness of the light" +msgstr "Ajuste a intensidade da luz" + +msgid "Thickness" +msgstr "Espessura" + +msgid "Width of each border in pixels; 0 disables that side" +msgstr "Largura de cada borda em pixels; 0 desativa aquele lado" + +msgid "Link sides" +msgstr "Vincular lados" + +msgid "Changing one side updates all sides" +msgstr "Alterar um lado atualiza todos os lados" + +msgid "Top" +msgstr "Superior" + +msgid "Bottom" +msgstr "Inferior" + +msgid "Left" +msgstr "Esquerda" + +msgid "Right" +msgstr "Direita" + +msgid "Appearance" +msgstr "Aparência" + +msgid "Color" +msgstr "Cor" + +msgid "Choose the color of the borders (Default: White)" +msgstr "Escolha a cor das bordas (padrão: branco)" + +msgid "Adjust the color from Warm to Cold (Only if color is White)" +msgstr "Ajuste a cor de quente para fria (somente se a cor for branca)" + +msgid "Multi-Monitor" +msgstr "Múltiplos monitores" + +msgid "Show borders on all connected screens" +msgstr "Mostrar bordas em todas as telas conectadas" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "Save" +msgstr "Salvar" + +msgid "Delete" +msgstr "Excluir" + +msgid "Profiles" +msgstr "Perfis" + +msgid "Save the current settings as a named profile and switch between them" +msgstr "Salve as configurações atuais como um perfil nomeado e alterne entre eles" + +msgid "Create a profile from the current settings" +msgstr "Criar um perfil a partir das configurações atuais" + +msgid "New Profile" +msgstr "Novo perfil" + +msgid "Automatic Switching" +msgstr "Troca automática" + +msgid "Apply profiles automatically based on the focused window" +msgstr "Aplica perfis automaticamente com base na janela em foco" + +msgid "Auto-switch profiles" +msgstr "Trocar perfis automaticamente" + +msgid "Watch the focused window and apply the first matching profile" +msgstr "Observa a janela em foco e aplica o primeiro perfil correspondente" + +msgid "Default profile" +msgstr "Perfil padrão" + +msgid "Applied when no window rule matches" +msgstr "Aplicado quando nenhuma regra de janela corresponde" + +msgid "Active profile" +msgstr "Perfil ativo" + +msgid "Apply" +msgstr "Aplicar" + +msgid "Name" +msgstr "Nome" + +msgid "Auto-activate" +msgstr "Ativação automática" + +msgid "Apply this profile when a window below is focused" +msgstr "Aplica este perfil quando uma das janelas abaixo estiver em foco" + +msgid "Application IDs (comma-separated, e.g. firefox.desktop)" +msgstr "IDs de aplicativos (separados por vírgula, ex.: firefox.desktop)" + +msgid "Window title regex (e.g. Meet)" +msgstr "Regex do título da janela (ex.: Meet)" + +msgid "Priority" +msgstr "Prioridade" + +msgid "Higher priority wins when several rules match" +msgstr "A prioridade mais alta vence quando várias regras correspondem" + +msgid "Update from current" +msgstr "Atualizar com as configurações atuais" + +msgid "Overwrite this profile with the current settings" +msgstr "Sobrescreve este perfil com as configurações atuais" + +msgid "Duplicate" +msgstr "Duplicar" + +msgid "Delete Profile?" +msgstr "Excluir perfil?" + +msgid "\"%s\" will be removed permanently." +msgstr "\"%s\" será removido permanentemente." + +msgid "None" +msgstr "Nenhum" + +msgid "No profiles yet" +msgstr "Nenhum perfil ainda" + +msgid "Use the + button to save the current settings as a profile" +msgstr "Use o botão + para salvar as configurações atuais como um perfil" diff --git a/preferences/generalPage.js b/preferences/generalPage.js new file mode 100644 index 0000000..f0f2bb2 --- /dev/null +++ b/preferences/generalPage.js @@ -0,0 +1,168 @@ +import Adw from 'gi://Adw'; +import Gdk from 'gi://Gdk'; +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk'; +import {gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +const SIDES = ['top', 'bottom', 'left', 'right']; + +/** + * Build the "General" preferences page (activation, thickness, appearance). + * + * @param {Gio.Settings} settings the extension settings + * @param {Adw.PreferencesWindow} window used to clean up signal handlers on close + * @returns {Adw.PreferencesPage} the page + */ +export function buildGeneralPage(settings, window) { + const page = new Adw.PreferencesPage({ + title: _('General'), + icon_name: 'preferences-system-symbolic', + }); + + // Group: General + const generalGroup = new Adw.PreferencesGroup({ + title: _('General'), + description: _('Control the main lighting parameters'), + }); + page.add(generalGroup); + + const enabledRow = new Adw.SwitchRow({ + title: _('Active'), + subtitle: _('Turn the screen borders on or off'), + }); + generalGroup.add(enabledRow); + settings.bind('enabled', enabledRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + const brightnessRow = new Adw.SwitchRow({ + title: _('Maximize Brightness'), + subtitle: _('Automatically set screen brightness to 100% when active'), + }); + generalGroup.add(brightnessRow); + settings.bind('maximize-brightness', brightnessRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + const opacityRow = new Adw.ActionRow({ + title: _('Opacity'), + subtitle: _('Adjust the brightness of the light'), + }); + const opacityScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.1, 1.0, 0.05); + opacityScale.set_draw_value(true); + opacityScale.set_hexpand(true); + opacityScale.set_valign(Gtk.Align.CENTER); + opacityRow.add_suffix(opacityScale); + generalGroup.add(opacityRow); + settings.bind('opacity', opacityScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT); + + // Group: Thickness + const thicknessGroup = new Adw.PreferencesGroup({ + title: _('Thickness'), + description: _('Width of each border in pixels; 0 disables that side'), + }); + page.add(thicknessGroup); + + const linkedRow = new Adw.SwitchRow({ + title: _('Link sides'), + subtitle: _('Changing one side updates all sides'), + }); + thicknessGroup.add(linkedRow); + settings.bind('thickness-linked', linkedRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + const sideTitles = { + top: _('Top'), + bottom: _('Bottom'), + left: _('Left'), + right: _('Right'), + }; + for (const side of SIDES) { + const row = new Adw.SpinRow({ + title: sideTitles[side], + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 500, + step_increment: 5, + page_increment: 25, + }), + }); + thicknessGroup.add(row); + settings.bind(`thickness-${side}`, row, 'value', Gio.SettingsBindFlags.DEFAULT); + } + + // Propagate a side's value to the others while sides are linked. The guard + // avoids re-entrancy from our own writes. + let propagating = false; + const sideChangedIds = SIDES.map(side => + settings.connect(`changed::thickness-${side}`, () => { + if (propagating || !settings.get_boolean('thickness-linked')) + return; + propagating = true; + const value = settings.get_int(`thickness-${side}`); + for (const other of SIDES) { + if (other !== side) + settings.set_int(`thickness-${other}`, value); + } + propagating = false; + }), + ); + const linkedChangedId = settings.connect('changed::thickness-linked', () => { + if (!settings.get_boolean('thickness-linked')) + return; + propagating = true; + const value = settings.get_int('thickness-top'); + for (const side of SIDES) + settings.set_int(`thickness-${side}`, value); + propagating = false; + }); + window.connect('close-request', () => { + sideChangedIds.forEach(id => settings.disconnect(id)); + settings.disconnect(linkedChangedId); + }); + + // Group: Appearance + const appearanceGroup = new Adw.PreferencesGroup({ + title: _('Appearance'), + }); + page.add(appearanceGroup); + + const colorRow = new Adw.ActionRow({ + title: _('Color'), + subtitle: _('Choose the color of the borders (Default: White)'), + }); + const colorButton = new Gtk.ColorDialogButton({ + dialog: new Gtk.ColorDialog({with_alpha: false}), + valign: Gtk.Align.CENTER, + }); + colorRow.add_suffix(colorButton); + appearanceGroup.add(colorRow); + + // Sync color manually because color buttons don't support GSettings binding + // with hex strings + const rgba = new Gdk.RGBA(); + rgba.parse(settings.get_string('color')); + colorButton.set_rgba(rgba); + colorButton.connect('notify::rgba', () => { + const c = colorButton.get_rgba(); + const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0'); + const hex = `#${toHex(c.red)}${toHex(c.green)}${toHex(c.blue)}`; + settings.set_string('color', hex); + }); + + const tempRow = new Adw.ActionRow({ + title: _('Temperature'), + subtitle: _('Adjust the color from Warm to Cold (Only if color is White)'), + }); + const tempScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1); + tempScale.set_draw_value(true); + tempScale.set_hexpand(true); + tempScale.set_valign(Gtk.Align.CENTER); + tempRow.add_suffix(tempScale); + appearanceGroup.add(tempRow); + settings.bind('temperature', tempScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT); + + const monitorRow = new Adw.SwitchRow({ + title: _('Multi-Monitor'), + subtitle: _('Show borders on all connected screens'), + }); + appearanceGroup.add(monitorRow); + settings.bind('multi-monitor', monitorRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + return page; +} diff --git a/preferences/profilesPage.js b/preferences/profilesPage.js new file mode 100644 index 0000000..fda7323 --- /dev/null +++ b/preferences/profilesPage.js @@ -0,0 +1,330 @@ +import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk'; +import {gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +import {ProfileManager} from '../lib/profileManager.js'; + +// Adw.AlertDialog needs libadwaita 1.5 (GNOME 46); fall back to the older +// Adw.MessageDialog on GNOME 45. +function createDialog(window, heading, body) { + if (Adw.AlertDialog) { + const dialog = new Adw.AlertDialog({heading, body}); + dialog.show = () => dialog.present(window); + return dialog; + } + const dialog = new Adw.MessageDialog({ + heading, body, + modal: true, + transient_for: window, + }); + dialog.show = () => dialog.present(); + return dialog; +} + +function promptName(window, heading, initialText, onAccept) { + const dialog = createDialog(window, heading, ''); + const entry = new Gtk.Entry({ + text: initialText, + activates_default: true, + margin_top: 6, + }); + dialog.set_extra_child(entry); + dialog.add_response('cancel', _('Cancel')); + dialog.add_response('accept', _('Save')); + dialog.set_response_appearance('accept', Adw.ResponseAppearance.SUGGESTED); + dialog.set_default_response('accept'); + dialog.set_close_response('cancel'); + dialog.connect('response', (d, response) => { + const name = entry.get_text().trim(); + if (response === 'accept' && name !== '') + onAccept(name); + }); + dialog.show(); +} + +function confirm(window, heading, body, onConfirm) { + const dialog = createDialog(window, heading, body); + dialog.add_response('cancel', _('Cancel')); + dialog.add_response('delete', _('Delete')); + dialog.set_response_appearance('delete', Adw.ResponseAppearance.DESTRUCTIVE); + dialog.set_default_response('cancel'); + dialog.set_close_response('cancel'); + dialog.connect('response', (d, response) => { + if (response === 'delete') + onConfirm(); + }); + dialog.show(); +} + +/** + * Build the "Profiles" preferences page: saved profiles list and automatic + * switching options. + * + * @param {Gio.Settings} settings the extension settings + * @param {Adw.PreferencesWindow} window used for dialogs and signal cleanup + * @returns {Adw.PreferencesPage} the page + */ +export function buildProfilesPage(settings, window) { + const manager = new ProfileManager(settings); + + const page = new Adw.PreferencesPage({ + title: _('Profiles'), + icon_name: 'view-list-symbolic', + }); + + // Group: profile list + const listGroup = new Adw.PreferencesGroup({ + title: _('Profiles'), + description: _('Save the current settings as a named profile and switch between them'), + }); + const addButton = new Gtk.Button({ + icon_name: 'list-add-symbolic', + tooltip_text: _('Create a profile from the current settings'), + valign: Gtk.Align.CENTER, + css_classes: ['flat'], + }); + addButton.connect('clicked', () => { + promptName(window, _('New Profile'), '', name => manager.createProfile(name)); + }); + listGroup.set_header_suffix(addButton); + page.add(listGroup); + + // Group: automatic switching + const autoGroup = new Adw.PreferencesGroup({ + title: _('Automatic Switching'), + description: _('Apply profiles automatically based on the focused window'), + }); + page.add(autoGroup); + + const autoRow = new Adw.SwitchRow({ + title: _('Auto-switch profiles'), + subtitle: _('Watch the focused window and apply the first matching profile'), + }); + autoGroup.add(autoRow); + settings.bind('auto-switch', autoRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + const defaultRow = new Adw.ComboRow({ + title: _('Default profile'), + subtitle: _('Applied when no window rule matches'), + }); + autoGroup.add(defaultRow); + + // When > 0, 'changed::profiles' events were caused by edits on this page + // that already updated the UI in place, so a full rebuild is unnecessary + // (and would steal focus from the row being edited). + let squelch = 0; + const withSquelch = fn => { + squelch++; + try { + fn(); + } finally { + squelch--; + } + }; + + let rows = []; + const activeIcons = new Map(); // profile id -> Gtk.Image + + function updateActiveMarkers() { + const activeId = settings.get_string('active-profile'); + for (const [id, icon] of activeIcons) + icon.visible = id === activeId; + } + + function makeProfileRow(profile) { + const row = new Adw.ExpanderRow({title: profile.name}); + + const activeIcon = new Gtk.Image({ + icon_name: 'object-select-symbolic', + tooltip_text: _('Active profile'), + visible: false, + }); + activeIcons.set(profile.id, activeIcon); + row.add_suffix(activeIcon); + + const applyButton = new Gtk.Button({ + label: _('Apply'), + valign: Gtk.Align.CENTER, + css_classes: ['flat'], + }); + applyButton.connect('clicked', () => manager.applyProfile(profile.id)); + row.add_suffix(applyButton); + + // Rename + const nameRow = new Adw.EntryRow({ + title: _('Name'), + text: profile.name, + show_apply_button: true, + }); + nameRow.connect('apply', () => { + const name = nameRow.get_text().trim(); + if (name === '') + return; + withSquelch(() => manager.updateProfile(profile.id, {name})); + row.title = name; + }); + row.add_row(nameRow); + + // Match rules + const matchRow = new Adw.SwitchRow({ + title: _('Auto-activate'), + subtitle: _('Apply this profile when a window below is focused'), + active: profile.match?.enabled ?? false, + }); + matchRow.connect('notify::active', () => { + withSquelch(() => manager.updateProfile(profile.id, + {match: {enabled: matchRow.get_active()}})); + }); + row.add_row(matchRow); + + const appIdsRow = new Adw.EntryRow({ + title: _('Application IDs (comma-separated, e.g. firefox.desktop)'), + text: (profile.match?.['app-ids'] ?? []).join(', '), + show_apply_button: true, + }); + appIdsRow.connect('apply', () => { + const ids = appIdsRow.get_text() + .split(',') + .map(s => s.trim()) + .filter(s => s !== ''); + withSquelch(() => manager.updateProfile(profile.id, + {match: {'app-ids': ids}})); + }); + row.add_row(appIdsRow); + + const regexRow = new Adw.EntryRow({ + title: _('Window title regex (e.g. Meet)'), + text: profile.match?.['title-regex'] ?? '', + show_apply_button: true, + }); + regexRow.connect('changed', () => { + try { + new RegExp(regexRow.get_text()); + regexRow.remove_css_class('error'); + } catch { + regexRow.add_css_class('error'); + } + }); + regexRow.connect('apply', () => { + withSquelch(() => manager.updateProfile(profile.id, + {match: {'title-regex': regexRow.get_text().trim()}})); + }); + row.add_row(regexRow); + + const priorityRow = new Adw.SpinRow({ + title: _('Priority'), + subtitle: _('Higher priority wins when several rules match'), + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 100, + step_increment: 1, + }), + value: profile.match?.priority ?? 0, + }); + priorityRow.connect('notify::value', () => { + withSquelch(() => manager.updateProfile(profile.id, + {match: {priority: priorityRow.get_value()}})); + }); + row.add_row(priorityRow); + + // Actions + const actionsRow = new Adw.ActionRow(); + const updateButton = new Gtk.Button({ + label: _('Update from current'), + tooltip_text: _('Overwrite this profile with the current settings'), + valign: Gtk.Align.CENTER, + }); + updateButton.connect('clicked', () => { + withSquelch(() => manager.updateProfileFromCurrentSettings(profile.id)); + }); + const duplicateButton = new Gtk.Button({ + label: _('Duplicate'), + valign: Gtk.Align.CENTER, + }); + duplicateButton.connect('clicked', () => manager.duplicateProfile(profile.id)); + const deleteButton = new Gtk.Button({ + label: _('Delete'), + valign: Gtk.Align.CENTER, + css_classes: ['destructive-action'], + }); + deleteButton.connect('clicked', () => { + confirm(window, + _('Delete Profile?'), + _('"%s" will be removed permanently.').replace('%s', profile.name), + () => manager.deleteProfile(profile.id)); + }); + actionsRow.add_suffix(updateButton); + actionsRow.add_suffix(duplicateButton); + actionsRow.add_suffix(deleteButton); + row.add_row(actionsRow); + + return row; + } + + // Default-profile combo: index 0 is "None" + let comboSyncing = false; + function rebuildCombo(profiles) { + comboSyncing = true; + const model = new Gtk.StringList(); + model.append(_('None')); + for (const p of profiles) + model.append(p.name); + defaultRow.set_model(model); + + const defaultId = settings.get_string('default-profile'); + const index = profiles.findIndex(p => p.id === defaultId); + defaultRow.set_selected(index === -1 ? 0 : index + 1); + comboSyncing = false; + } + defaultRow.connect('notify::selected', () => { + if (comboSyncing) + return; + const profiles = manager.getProfiles(); + const selected = defaultRow.get_selected(); + const id = selected > 0 && profiles[selected - 1] ? profiles[selected - 1].id : ''; + settings.set_string('default-profile', id); + }); + + function rebuild() { + rows.forEach(r => listGroup.remove(r)); + rows = []; + activeIcons.clear(); + + const profiles = manager.getProfiles(); + if (profiles.length === 0) { + const placeholder = new Adw.ActionRow({ + title: _('No profiles yet'), + subtitle: _('Use the + button to save the current settings as a profile'), + }); + listGroup.add(placeholder); + rows.push(placeholder); + } else { + for (const profile of profiles) { + const row = makeProfileRow(profile); + listGroup.add(row); + rows.push(row); + } + } + + updateActiveMarkers(); + rebuildCombo(profiles); + } + + const profilesChangedId = settings.connect('changed::profiles', () => { + if (!squelch) + rebuild(); + }); + const activeChangedId = settings.connect('changed::active-profile', + updateActiveMarkers); + const defaultChangedId = settings.connect('changed::default-profile', + () => rebuildCombo(manager.getProfiles())); + window.connect('close-request', () => { + settings.disconnect(profilesChangedId); + settings.disconnect(activeChangedId); + settings.disconnect(defaultChangedId); + }); + + rebuild(); + return page; +} diff --git a/prefs.js b/prefs.js index b7163c6..9d3b947 100644 --- a/prefs.js +++ b/prefs.js @@ -1,116 +1,12 @@ -import GObject from 'gi://GObject'; -import Gtk from 'gi://Gtk'; -import Adw from 'gi://Adw'; -import Gio from 'gi://Gio'; -import Gdk from 'gi://Gdk'; -import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; +import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +import {buildGeneralPage} from './preferences/generalPage.js'; +import {buildProfilesPage} from './preferences/profilesPage.js'; export default class LighterPreferences extends ExtensionPreferences { fillPreferencesWindow(window) { const settings = this.getSettings(); - - // Page - const page = new Adw.PreferencesPage({ - title: _('Settings'), - icon_name: 'lightbulb-symbolic', - }); - window.add(page); - - // Group: General - const generalGroup = new Adw.PreferencesGroup({ - title: _('General'), - description: _('Control the main lighting parameters'), - }); - page.add(generalGroup); - - // Enabled Toggle - const enabledRow = new Adw.SwitchRow({ - title: _('Active'), - subtitle: _('Turn the screen borders on or off'), - }); - generalGroup.add(enabledRow); - settings.bind('enabled', enabledRow, 'active', Gio.SettingsBindFlags.DEFAULT); - - // Maximize Brightness - const brightnessRow = new Adw.SwitchRow({ - title: _('Maximize Brightness'), - subtitle: _('Automatically set screen brightness to 100% when active'), - }); - generalGroup.add(brightnessRow); - settings.bind('maximize-brightness', brightnessRow, 'active', Gio.SettingsBindFlags.DEFAULT); - - // Thickness Slider - const thicknessRow = new Adw.ActionRow({ - title: _('Thickness'), - subtitle: _('Adjust the width of the white borders'), - }); - const thicknessScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 5, 500, 5); - thicknessScale.set_draw_value(true); - thicknessScale.set_hexpand(true); - thicknessScale.set_valign(Gtk.Align.CENTER); - thicknessRow.add_suffix(thicknessScale); - generalGroup.add(thicknessRow); - settings.bind('thickness', thicknessScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT); - - // Opacity Slider - const opacityRow = new Adw.ActionRow({ - title: _('Opacity'), - subtitle: _('Adjust the brightness of the light'), - }); - const opacityScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.1, 1.0, 0.05); - opacityScale.set_draw_value(true); - opacityScale.set_hexpand(true); - opacityScale.set_valign(Gtk.Align.CENTER); - opacityRow.add_suffix(opacityScale); - generalGroup.add(opacityRow); - settings.bind('opacity', opacityScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT); - - // Group: Appearance - const appearanceGroup = new Adw.PreferencesGroup({ - title: _('Appearance'), - }); - page.add(appearanceGroup); - - // Color Picker - const colorRow = new Adw.ActionRow({ - title: _('Color'), - subtitle: _('Choose the color of the borders (Default: White)'), - }); - const colorButton = new Gtk.ColorButton(); - colorButton.set_valign(Gtk.Align.CENTER); - colorRow.add_suffix(colorButton); - appearanceGroup.add(colorRow); - - // Sync color manually because Gtk.ColorButton doesn't support GSettings binding well with hex strings - const rgba = new Gdk.RGBA(); - rgba.parse(settings.get_string('color')); - colorButton.set_rgba(rgba); - colorButton.connect('color-set', () => { - const c = colorButton.get_rgba(); - const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, '0'); - const hex = `#${toHex(c.red)}${toHex(c.green)}${toHex(c.blue)}`; - settings.set_string('color', hex); - }); - - // Temperature Slider - const tempRow = new Adw.ActionRow({ - title: _('Temperature'), - subtitle: _('Adjust the color from Warm to Cold (Only if color is White)'), - }); - const tempScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1); - tempScale.set_draw_value(true); - tempScale.set_hexpand(true); - tempScale.set_valign(Gtk.Align.CENTER); - tempRow.add_suffix(tempScale); - appearanceGroup.add(tempRow); - settings.bind('temperature', tempScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT); - - // Multi-Monitor Toggle - const monitorRow = new Adw.SwitchRow({ - title: _('Multi-Monitor'), - subtitle: _('Show borders on all connected screens'), - }); - appearanceGroup.add(monitorRow); - settings.bind('multi-monitor', monitorRow, 'active', Gio.SettingsBindFlags.DEFAULT); + window.add(buildGeneralPage(settings, window)); + window.add(buildProfilesPage(settings, window)); } } diff --git a/schemas/org.gnome.shell.extensions.lighter.gschema.xml b/schemas/org.gnome.shell.extensions.lighter.gschema.xml index 5c3a934..579aee5 100644 --- a/schemas/org.gnome.shell.extensions.lighter.gschema.xml +++ b/schemas/org.gnome.shell.extensions.lighter.gschema.xml @@ -7,9 +7,33 @@ 50 - The thickness of the white borders in pixels. + Deprecated: single thickness for all sides. Migrated to the per-side thickness keys. + + 50 + Thickness of the top border in pixels (0 disables it). + + + + 50 + Thickness of the bottom border in pixels (0 disables it). + + + + 50 + Thickness of the left border in pixels (0 disables it). + + + + 50 + Thickness of the right border in pixels (0 disables it). + + + + true + Whether changing one side's thickness updates all sides. + 0.9 The opacity of the light bars. @@ -34,5 +58,25 @@ ['<Control><Alt>l'] Keyboard shortcut to open preferences. + + '[]' + Saved profiles as a JSON document. + + + '' + ID of the last applied profile (empty for custom settings). + + + '' + Profile applied when auto-switching and no window rule matches. + + + false + Whether to switch profiles automatically based on the focused window. + + + 1 + Internal settings format version, used for migrations. +