mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-17 23:24:46 +00:00
Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1294ba741a | |||
| 6ea44ac60c | |||
| 56399996f5 | |||
| 2b1f67b1ea | |||
| 71f942ff36 | |||
| 7968270aa4 | |||
| b406970b93 | |||
| d75ba7fff6 | |||
| 98307b8a2e | |||
| 6c4fa04221 | |||
| b6fe86f418 | |||
| 3d41f39ad9 | |||
| c9f3b6b1ad | |||
| bb792cee95 | |||
| 376c6442f9 |
24 changed files with 772 additions and 456 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
|
|
@ -54,7 +54,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install glib-compile-schemas
|
||||
run: sudo apt-get update -q && sudo apt-get install -y libglib2.0-bin
|
||||
|
|
|
|||
40
.github/workflows/eslint.yml
vendored
Normal file
40
.github/workflows/eslint.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: ESLint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main' ]
|
||||
pull_request:
|
||||
branches: [ 'main' ]
|
||||
schedule:
|
||||
- cron: '33 14 * * 5'
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: Run eslint scanning
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
# Required for private repositories by github/codeql-action/upload-sarif
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
npm install eslint@^10.0.0 @eslint/js@^10.0.0
|
||||
npm install @microsoft/eslint-formatter-sarif@2.1.7
|
||||
|
||||
- name: Lint
|
||||
run: >
|
||||
npx eslint .
|
||||
--format @microsoft/eslint-formatter-sarif
|
||||
--output-file eslint-results.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Report
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: eslint-results.sarif
|
||||
wait-for-processing: true
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -4,12 +4,18 @@ strata-daemon/target/
|
|||
# Bundled binary (no longer shipped - daemon must be installed separately)
|
||||
strata@edu4rdshl.dev/bin/
|
||||
|
||||
# Screenshots / scratch images
|
||||
# Screenshots / scratch images (project assets under assets/ are kept)
|
||||
*.png
|
||||
!assets/*.png
|
||||
|
||||
# External reference code (not part of this project)
|
||||
external_sources/
|
||||
|
||||
# Node / lint tooling
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.shell-extension.zip
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -295,38 +295,24 @@ image.
|
|||
|
||||
## Theming
|
||||
|
||||
St's CSS engine has no custom properties (`var()`) and no reliable
|
||||
`!important`, so the light theme is not a runtime-generated stylesheet.
|
||||
Instead `stylesheet.css` is the dark theme (default, auto-loaded by GNOME),
|
||||
and `light.css` carries light overrides with every rule scoped under a
|
||||
`.strata-theme-light` ancestor class -- e.g. dark `.strata-panel { ... }` is
|
||||
overridden by `.strata-panel.strata-theme-light { ... }`. That scoping makes
|
||||
each light rule strictly more specific than its dark counterpart, so it wins
|
||||
deterministically regardless of stylesheet load order.
|
||||
Light and dark are handled by GNOME Shell's built-in per-variant stylesheet
|
||||
loading, not by any code in the extension. When it enables an extension the
|
||||
Shell loads `stylesheet-<variant>.css`, where the variant comes from
|
||||
`Main.getStyleVariant()` (the shell's own light/dark), and falls back to
|
||||
`stylesheet.css`; it reloads that sheet when the color scheme changes.
|
||||
|
||||
The switch between themes is a single class. `extension.js` loads `light.css`
|
||||
into the St theme context once at `enable()` (and unloads it on `disable()`),
|
||||
but loading it changes nothing on screen: its rules are present in the engine
|
||||
yet match no actors, because nothing carries `.strata-theme-light` yet. The
|
||||
panel resolves the effective theme from the `theme` setting (`auto` consults
|
||||
`org.gnome.desktop.interface color-scheme`) and adds or removes that one class
|
||||
on its root box. With the class present the more-specific light rules win and
|
||||
the panel is light; with it absent only the base dark rules apply. So
|
||||
switching is one class toggle on an existing subtree -- instant, off the
|
||||
ingest/render hot paths, and needing no reload.
|
||||
Strata ships two sheets and no `stylesheet.css`:
|
||||
|
||||
`light.css` is loaded exactly once and the theme context's `changed` signal is
|
||||
deliberately not used: `load_stylesheet` itself emits `changed`, so reloading
|
||||
on it feeds back into itself and hits "too much recursion" (it fired on screen
|
||||
unlock, which restyles widgets). The one caveat of loading once is a full
|
||||
GNOME Shell *theme* switch (the User Themes extension swapping the whole Shell
|
||||
theme), which replaces the theme object and drops every dynamically loaded
|
||||
sheet, including `light.css`. After that, light mode falls back to the dark
|
||||
base rules until the extension is re-enabled; dark mode is unaffected because
|
||||
GNOME re-loads `stylesheet.css` itself. This is rare and recoverable, and far
|
||||
preferable to re-subscribing to `changed`. The ordinary light/dark switch
|
||||
(including the system Settings light/dark that `auto` follows) is just the
|
||||
class toggle and is unaffected.
|
||||
- `stylesheet-dark.css` is the full dark theme (the base).
|
||||
- `stylesheet-light.css` does `@import url("stylesheet-dark.css")` and then
|
||||
overrides the colors for a light panel. Its rules follow the import, so they
|
||||
win by load order.
|
||||
|
||||
This is the structure the built-in `window-list` extension uses. Because the
|
||||
variant tracks the *shell* style, the panel matches the shell chrome: a normal
|
||||
session prefers dark, so the panel is dark, and it turns light only when the
|
||||
shell itself is light (a `prefer-light` color scheme, the Classic session, or
|
||||
high contrast). There is no theme setting and no manual stylesheet loading.
|
||||
|
||||
## Wayland clipboard monitor
|
||||
|
||||
|
|
|
|||
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
|
||||
---
|
||||
|
||||
## [0.11.0] - 2026-07-02
|
||||
|
||||
The daemon is unchanged from 0.10.0; all changes are in the GNOME Shell extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Light and dark styling follows the GNOME Shell style variant via
|
||||
`stylesheet-dark.css` and `stylesheet-light.css`, loaded automatically by the
|
||||
Shell, instead of loading `light.css` into the theme context by hand. The
|
||||
manual theme setting and its preferences row are gone; the panel matches the
|
||||
shell.
|
||||
- Signal connections use `connectObject`/`disconnectObject`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `disable()` stops the daemon synchronously with `SIGTERM` instead of deferring
|
||||
the kill to a timeout.
|
||||
- The daemon-restart timeout is cleared before a new one is scheduled.
|
||||
- Removed the `try`/`catch` around `send_signal`, which does not throw.
|
||||
|
||||
### Performance
|
||||
|
||||
- The thumbnail cache is cleared asynchronously in batches instead of a
|
||||
synchronous enumerate and delete on the compositor thread.
|
||||
- The thumbnail path cache is pruned on delete and clear-all.
|
||||
- Each row computes its type (image, URL, color) once.
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] - 2026-06-25
|
||||
|
||||
The daemon is unchanged from 0.9.0; all changes are in the GNOME Shell extension.
|
||||
|
||||
### Fixed
|
||||
|
||||
- URL items now show their hostname as a subtitle. The old code used the WHATWG
|
||||
`URL` API, which GJS does not provide, so it always threw and the subtitle
|
||||
never appeared. It now uses `GLib.Uri`.
|
||||
- The packaged extension was missing `dbus.js`. The extension imports it, but
|
||||
`make pack` did not bundle it, so installing from the packaged zip would
|
||||
fail to load. It is now included.
|
||||
|
||||
### Changed
|
||||
|
||||
- `ClipboardItem` uses a `constructor()` with an explicit `GTypeName` instead of
|
||||
`_init()`.
|
||||
- The error-logging helper lives in a single `util.js` module shared by the
|
||||
extension, panel, and item widget.
|
||||
|
||||
### Internal
|
||||
|
||||
- Adopted the GJS ESLint style guide: an `eslint.config.js` flat config, an
|
||||
`npm run lint` script, and a GitHub Actions workflow that lints on push and
|
||||
pull requests. The extension passes with no warnings.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] - 2026-06-25
|
||||
|
||||
The daemon is unchanged from 0.8.0; all changes are in the GNOME Shell extension.
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -37,7 +37,8 @@ install: schemas
|
|||
pack: schemas
|
||||
gnome-extensions pack $(EXTENSION_UUID) \
|
||||
--extra-source=ui \
|
||||
--extra-source=light.css \
|
||||
--extra-source=dbus.js \
|
||||
--extra-source=util.js \
|
||||
--force
|
||||
@echo "Packed: $(EXTENSION_UUID).shell-extension.zip"
|
||||
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -10,6 +10,15 @@ All heavy work (hashing, decoding, storage, search, thumbnails) lives in a Rust
|
|||
daemon. The GNOME Shell extension only renders UI and forwards events over
|
||||
D-Bus, so the compositor is never blocked, even with thousands of items.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://extensions.gnome.org/extension/10291/strata/">
|
||||
<img src="assets/ego.svg" width="200" alt="Download from extensions.gnome.org">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **Requires a companion daemon.** Installing from GNOME Extensions is only half
|
||||
> the setup. See [Installing the daemon](#installing-the-daemon).
|
||||
|
||||
The motivation behind this is explained in the technical blog post [Rethinking the GNOME clipboard issues](https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/)
|
||||
|
||||
## Features
|
||||
|
|
@ -34,8 +43,8 @@ Unknown MIME types are ignored (a strict allowlist).
|
|||
|
||||
**Appearance.**
|
||||
|
||||
- Automatic light/dark theme: `Auto` follows the system color scheme; `Light`
|
||||
and `Dark` force one.
|
||||
- Light and dark styling follows the GNOME Shell automatically, matching the
|
||||
rest of the shell UI.
|
||||
- Configurable panel position (top/center/bottom by left/center/right), width,
|
||||
and maximum height.
|
||||
- Optional "move an item to the top" when you paste it.
|
||||
|
|
@ -78,12 +87,6 @@ Unknown MIME types are ignored (a strict allowlist).
|
|||
- Keyboard navigation (arrow keys, `Esc` to close), click-outside to dismiss,
|
||||
per-row delete, and "Clear all".
|
||||
|
||||
## How it looks
|
||||
|
||||
The idea is to have a simple but responsive UI that fits into the project's goals. Actually, Strata looks like it:
|
||||
|
||||
<img width="1470" height="1227" alt="image" src="https://github.com/user-attachments/assets/07a17d08-4d7f-4afb-bafc-9346814e376a" />
|
||||
|
||||
## Architecture
|
||||
|
||||
Strata is **two components**, and you need **both** for it to work:
|
||||
|
|
@ -104,6 +107,32 @@ GNOME Shell (GJS) ──D-Bus──▶ strata-daemon ──▶ SQLite (~/.lo
|
|||
└──▶ thumbnails (~/.cache/strata)
|
||||
```
|
||||
|
||||
## Installing the daemon
|
||||
|
||||
Strata is two parts: the GNOME Shell extension and a small background daemon
|
||||
(`strata-daemon`) that does the storage, search, and thumbnailing. Installing
|
||||
the extension from [GNOME Extensions](https://extensions.gnome.org/extension/10291/strata/)
|
||||
is only half the setup; **without the daemon the extension does nothing.**
|
||||
|
||||
Install the daemon with whichever fits your system:
|
||||
|
||||
- **Arch (AUR):** `paru -S strata-daemon` (or `strata-daemon-git` for the latest `main`).
|
||||
- **From source** (needs Rust/Cargo 1.74+):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Edu4rdSHL/Strata.git
|
||||
cd Strata
|
||||
make install-daemon # builds and installs to ~/.local/bin
|
||||
```
|
||||
|
||||
Make sure `~/.local/bin` is in your `$PATH`.
|
||||
|
||||
Then log out and back in (Wayland) or `Alt+F2` → `r` (X11). The extension finds
|
||||
`strata-daemon` in `$PATH` and starts it automatically; no separate service is
|
||||
needed.
|
||||
|
||||
For distro packages and the systemd user service, see [Install](#install).
|
||||
|
||||
## Requirements
|
||||
|
||||
- GNOME Shell 50. Older releases may work if built from source but are not tested or supported.
|
||||
|
|
@ -197,6 +226,19 @@ the wire protocol and a `busctl` example.
|
|||
The shipped UI is a GNOME Shell extension. Ports to other desktops only
|
||||
need a new front-end against the same D-Bus interface.
|
||||
|
||||
## How it looks
|
||||
|
||||
A responsive panel that drops down from the top bar and follows the GNOME
|
||||
shell's look.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/panel-desktop.png" alt="Strata's clipboard panel open on the GNOME desktop" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/panel.png" alt="Close-up of the Strata panel: a URL with its hostname, an image thumbnail, color swatches, and text entries" width="400">
|
||||
</p>
|
||||
|
||||
## Deeper reading
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md): design goals, process model,
|
||||
|
|
|
|||
170
assets/ego.svg
Normal file
170
assets/ego.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/panel-desktop.png
Normal file
BIN
assets/panel-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
BIN
assets/panel.png
Normal file
BIN
assets/panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
149
eslint.config.js
Normal file
149
eslint.config.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// SPDX-License-Identifier: CC0-1.0
|
||||
// SPDX-FileCopyrightText: No rights reserved
|
||||
|
||||
import js from '@eslint/js';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
ARGV: 'readonly',
|
||||
Debugger: 'readonly',
|
||||
GIRepositoryGType: 'readonly',
|
||||
globalThis: 'readonly',
|
||||
imports: 'readonly',
|
||||
Intl: 'readonly',
|
||||
log: 'readonly',
|
||||
logError: 'readonly',
|
||||
pkg: 'readonly',
|
||||
print: 'readonly',
|
||||
printerr: 'readonly',
|
||||
window: 'readonly',
|
||||
TextEncoder: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
// GNOME Shell Only
|
||||
global: 'readonly',
|
||||
_: 'readonly',
|
||||
C_: 'readonly',
|
||||
N_: 'readonly',
|
||||
ngettext: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// See: https://eslint.org/docs/latest/rules/#possible-problems
|
||||
'array-callback-return': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-constant-binary-expression': 'error',
|
||||
'no-constructor-return': 'error',
|
||||
'no-new-native-nonconstructor': 'error',
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-self-compare': 'error',
|
||||
'no-template-curly-in-string': 'error',
|
||||
'no-unmodified-loop-condition': 'error',
|
||||
'no-unreachable-loop': 'error',
|
||||
'no-unused-private-class-members': 'error',
|
||||
'no-use-before-define': [
|
||||
'error',
|
||||
{
|
||||
functions: false,
|
||||
classes: true,
|
||||
variables: true,
|
||||
allowNamedExports: true,
|
||||
},
|
||||
],
|
||||
// See: https://eslint.org/docs/latest/rules/#suggestions
|
||||
'block-scoped-var': 'error',
|
||||
'complexity': 'warn',
|
||||
'consistent-return': 'error',
|
||||
'default-param-last': 'error',
|
||||
'eqeqeq': 'error',
|
||||
'no-array-constructor': 'error',
|
||||
'no-caller': 'error',
|
||||
'no-extend-native': 'error',
|
||||
'no-extra-bind': 'error',
|
||||
'no-extra-label': 'error',
|
||||
'no-iterator': 'error',
|
||||
'no-label-var': 'error',
|
||||
'no-loop-func': 'error',
|
||||
'no-multi-assign': 'warn',
|
||||
'no-new-object': 'error',
|
||||
'no-new-wrappers': 'error',
|
||||
'no-proto': 'error',
|
||||
'no-shadow': 'warn',
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
varsIgnorePattern: '^_',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'no-var': 'warn',
|
||||
'unicode-bom': 'error',
|
||||
// GJS Restrictions
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: 'Debugger',
|
||||
message: 'Internal use only',
|
||||
},
|
||||
{
|
||||
name: 'GIRepositoryGType',
|
||||
message: 'Internal use only',
|
||||
},
|
||||
{
|
||||
name: 'log',
|
||||
message: 'Use console.log()',
|
||||
},
|
||||
{
|
||||
name: 'logError',
|
||||
message: 'Use console.warn() or console.error()',
|
||||
},
|
||||
],
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'imports',
|
||||
property: 'format',
|
||||
message: 'Use template strings',
|
||||
},
|
||||
{
|
||||
object: 'pkg',
|
||||
property: 'initFormat',
|
||||
message: 'Use template strings',
|
||||
},
|
||||
{
|
||||
object: 'Lang',
|
||||
property: 'copyProperties',
|
||||
message: 'Use Object.assign()',
|
||||
},
|
||||
{
|
||||
object: 'Lang',
|
||||
property: 'bind',
|
||||
message: 'Use arrow notation or Function.prototype.bind()',
|
||||
},
|
||||
{
|
||||
object: 'Lang',
|
||||
property: 'Class',
|
||||
message: 'Use ES6 classes',
|
||||
},
|
||||
],
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'MethodDefinition[key.name="_init"] CallExpression[arguments.length<=1][callee.object.type="Super"][callee.property.name="_init"]',
|
||||
message: 'Use constructor() and super()',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "strata-extension",
|
||||
"version": "0.9.0",
|
||||
"description": "Lint tooling for the Strata GNOME Shell extension",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint 'strata@edu4rdshl.dev'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.0",
|
||||
"eslint": "^10.0.0"
|
||||
}
|
||||
}
|
||||
2
strata-daemon/Cargo.lock
generated
2
strata-daemon/Cargo.lock
generated
|
|
@ -1205,7 +1205,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "strata-daemon"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "strata-daemon"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
edition = "2021"
|
||||
description = "Strata clipboard manager backend daemon"
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,7 @@ import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
|||
|
||||
import { StrataProxy, BUS_NAME, OBJECT_PATH } from './dbus.js';
|
||||
import { StrataPanel } from './ui/panel.js';
|
||||
|
||||
function logError(label, err) {
|
||||
if (err !== undefined) {
|
||||
try { Gio.DBusError.strip_remote_error(err); } catch (_) {}
|
||||
}
|
||||
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
|
||||
console.error(`[Strata] ${label}${tail}`);
|
||||
}
|
||||
import { logError } from './util.js';
|
||||
|
||||
export default class StrataExtension extends Extension {
|
||||
/** @type {Gio.Subprocess | null} */
|
||||
|
|
@ -29,7 +22,6 @@ export default class StrataExtension extends Extension {
|
|||
_daemonRestartAttempts = 0;
|
||||
_shuttingDown = false;
|
||||
_daemonRestartTimerId = null;
|
||||
_daemonKillTimerId = null;
|
||||
/** True while a GetNameOwner check before spawning is in flight, so an
|
||||
* overlapping respawn attempt can't spawn a second daemon. */
|
||||
_spawnPending = false;
|
||||
|
|
@ -43,18 +35,12 @@ export default class StrataExtension extends Extension {
|
|||
_itemDeletedId = null;
|
||||
_historyClearedId = null;
|
||||
|
||||
/** @type {number | null} focused-window signal connection ID */
|
||||
_focusSignalId = null;
|
||||
|
||||
/** @type {string} WM class of the currently focused app (lower-cased) */
|
||||
_currentFocusedApp = '';
|
||||
|
||||
/** @type {object | null} PanelMenu.Button indicator */
|
||||
_indicator = null;
|
||||
|
||||
/** @type {number | null} Meta.Selection owner-changed signal ID */
|
||||
_selectionChangedId = null;
|
||||
|
||||
/** @type {number | null} Debounce timer for clipboard reads */
|
||||
_clipboardDebounceId = null;
|
||||
|
||||
|
|
@ -71,29 +57,16 @@ export default class StrataExtension extends Extension {
|
|||
enable() {
|
||||
this._shuttingDown = false;
|
||||
this._daemonRestartAttempts = 0;
|
||||
// Cancel any pending force-kill timer left over from a previous disable
|
||||
// (enable→disable→enable within 1.5s) so it can't outlive this cycle.
|
||||
if (this._daemonKillTimerId) {
|
||||
GLib.Source.remove(this._daemonKillTimerId);
|
||||
this._daemonKillTimerId = null;
|
||||
}
|
||||
|
||||
this._settings = this.getSettings();
|
||||
this._excludedApps = this._settings.get_strv('excluded-apps');
|
||||
this._excludedAppsChangedId = this._settings.connect('changed::excluded-apps', () => {
|
||||
this._excludedApps = this._settings.get_strv('excluded-apps');
|
||||
});
|
||||
|
||||
this._excludedApps = this._readExcluded();
|
||||
this._readSizeLimits();
|
||||
this._configChangedIds = [
|
||||
this._settings.connect('changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
this._settings.connect('changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
this._settings.connect('changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
];
|
||||
|
||||
// Load the light theme overrides into the Shell theme context. They stay
|
||||
// inert until the panel toggles the `.strata-theme-light` class (panel.js).
|
||||
this._loadThemeStylesheet();
|
||||
this._settings.connectObject(
|
||||
'changed::excluded-apps', () => { this._excludedApps = this._readExcluded(); },
|
||||
'changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); },
|
||||
'changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); },
|
||||
'changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); },
|
||||
this);
|
||||
|
||||
this._addIndicator();
|
||||
this._spawnDaemon();
|
||||
|
|
@ -110,33 +83,18 @@ export default class StrataExtension extends Extension {
|
|||
GLib.Source.remove(this._daemonRestartTimerId);
|
||||
this._daemonRestartTimerId = null;
|
||||
}
|
||||
// Clean up in reverse order.
|
||||
this._unregisterShortcut();
|
||||
this._disconnectClipboardMonitor();
|
||||
this._disconnectFocusTracking();
|
||||
this._disconnectSignals();
|
||||
this._clearIdleSources();
|
||||
if (this._proxyOwnerId && this._proxy) {
|
||||
this._proxy.disconnect(this._proxyOwnerId);
|
||||
this._proxyOwnerId = 0;
|
||||
}
|
||||
if (this._configChangedIds && this._settings) {
|
||||
for (const id of this._configChangedIds) this._settings.disconnect(id);
|
||||
this._configChangedIds = null;
|
||||
}
|
||||
if (this._excludedAppsChangedId && this._settings) {
|
||||
this._settings.disconnect(this._excludedAppsChangedId);
|
||||
this._excludedAppsChangedId = null;
|
||||
}
|
||||
this._proxy?.disconnectObject(this);
|
||||
this._settings?.disconnectObject(this);
|
||||
this._panel?.destroy();
|
||||
this._panel = null;
|
||||
if (this._indicatorClickId && this._indicator) {
|
||||
this._indicator.disconnect(this._indicatorClickId);
|
||||
this._indicatorClickId = null;
|
||||
}
|
||||
this._indicator?.disconnectObject(this);
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
this._unloadThemeStylesheet();
|
||||
this._stopDaemon();
|
||||
this._proxy = null;
|
||||
this._settings = null;
|
||||
|
|
@ -151,20 +109,19 @@ export default class StrataExtension extends Extension {
|
|||
style_class: 'system-status-icon',
|
||||
});
|
||||
this._indicator.add_child(icon);
|
||||
this._indicatorClickId = this._indicator.connect('button-press-event', () => {
|
||||
this._indicator.connectObject('button-press-event', () => {
|
||||
this._panel?.toggle();
|
||||
return false; // EVENT_PROPAGATE
|
||||
});
|
||||
}, this);
|
||||
Main.panel.addToStatusArea('strata', this._indicator);
|
||||
}
|
||||
|
||||
|
||||
_connectClipboardMonitor() {
|
||||
const selection = global.display.get_selection();
|
||||
this._selectionChangedId = selection.connect('owner-changed', (_sel, type) => {
|
||||
global.display.get_selection().connectObject('owner-changed', (_sel, type) => {
|
||||
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return;
|
||||
this._scheduleClipboardRead();
|
||||
});
|
||||
}, this);
|
||||
}
|
||||
|
||||
_disconnectClipboardMonitor() {
|
||||
|
|
@ -175,10 +132,7 @@ export default class StrataExtension extends Extension {
|
|||
this._clipboardDebounceId = null;
|
||||
}
|
||||
this._clipboardTransferPending = false;
|
||||
if (this._selectionChangedId !== null) {
|
||||
global.display.get_selection().disconnect(this._selectionChangedId);
|
||||
this._selectionChangedId = null;
|
||||
}
|
||||
global.display.get_selection().disconnectObject(this);
|
||||
}
|
||||
|
||||
/** Debounce entry point - coalesces rapid clipboard changes (e.g. from
|
||||
|
|
@ -235,7 +189,6 @@ export default class StrataExtension extends Extension {
|
|||
/** Pick the best MIME type to store from the offered list (mirrors Rust pick_mime). */
|
||||
_pickMime(mimes) {
|
||||
const PREFERRED = [
|
||||
// Raster images (size-capped at MAX_IMAGE).
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/bmp', 'image/tiff', 'image/x-icon',
|
||||
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
|
||||
|
|
@ -281,7 +234,7 @@ export default class StrataExtension extends Extension {
|
|||
try {
|
||||
_conn.call_finish(result);
|
||||
// Name already owned (e.g. systemd user service) - don't spawn a second instance.
|
||||
} catch (_) {
|
||||
} catch {
|
||||
this._doSpawnDaemon();
|
||||
}
|
||||
}
|
||||
|
|
@ -324,7 +277,7 @@ export default class StrataExtension extends Extension {
|
|||
urgency: MessageTray.Urgency.HIGH,
|
||||
});
|
||||
source.addNotification(notification);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Message tray may not be available (e.g. during early startup) - already logged above.
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +309,10 @@ export default class StrataExtension extends Extension {
|
|||
|
||||
_scheduleDaemonRestart() {
|
||||
if (this._shuttingDown) return;
|
||||
if (this._daemonRestartTimerId !== null) {
|
||||
GLib.Source.remove(this._daemonRestartTimerId);
|
||||
this._daemonRestartTimerId = null;
|
||||
}
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||
const backoffMs = 1000 * Math.pow(2, Math.max(0, this._daemonRestartAttempts - 1));
|
||||
this._daemonRestartTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, backoffMs, () => {
|
||||
|
|
@ -367,20 +324,9 @@ export default class StrataExtension extends Extension {
|
|||
|
||||
_stopDaemon() {
|
||||
if (!this._daemon) return;
|
||||
// Capture reference immediately so the timer doesn't kill a newly-spawned daemon.
|
||||
const daemonToStop = this._daemon;
|
||||
const daemon = this._daemon;
|
||||
this._daemon = null;
|
||||
try {
|
||||
// Graceful shutdown via D-Bus first.
|
||||
this._proxy?.ShutdownRemote(() => {});
|
||||
} catch (_) {}
|
||||
// Give it 1.5s then force-terminate. Track the source so a re-enable
|
||||
// within that window can cancel it (see enable()).
|
||||
this._daemonKillTimerId = GLib.timeout_add(GLib.PRIORITY_LOW, 1500, () => {
|
||||
this._daemonKillTimerId = null;
|
||||
try { daemonToStop.send_signal(15); } catch (_) {} // SIGTERM
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
daemon.send_signal(15); // SIGTERM
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -402,8 +348,8 @@ export default class StrataExtension extends Extension {
|
|||
// again on every owner transition so a respawned
|
||||
// daemon picks up the latest values.
|
||||
this._pushConfig();
|
||||
this._proxyOwnerId = proxy.connect('notify::g-name-owner',
|
||||
() => { if (proxy.g_name_owner) this._pushConfig(); });
|
||||
proxy.connectObject('notify::g-name-owner',
|
||||
() => { if (proxy.g_name_owner) this._pushConfig(); }, this);
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
@ -440,29 +386,56 @@ export default class StrataExtension extends Extension {
|
|||
const cachePath =
|
||||
`${GLib.get_user_cache_dir()}/strata/thumbnails/${id}.png`;
|
||||
GLib.unlink(cachePath);
|
||||
} catch (_) {}
|
||||
} catch { /* no cached thumbnail for this id */ }
|
||||
this._addIdleSource(() => this._panel?.removeItem(id));
|
||||
});
|
||||
|
||||
this._historyClearedId = this._proxy.connectSignal('HistoryCleared',
|
||||
() => {
|
||||
try {
|
||||
const dir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
|
||||
const d = Gio.File.new_for_path(dir);
|
||||
if (d.query_exists(null)) {
|
||||
const en = d.enumerate_children(
|
||||
'standard::name', Gio.FileQueryInfoFlags.NONE, null);
|
||||
let info;
|
||||
while ((info = en.next_file(null))) {
|
||||
try { d.get_child(info.get_name()).delete(null); } catch (_) {}
|
||||
}
|
||||
en.close(null);
|
||||
}
|
||||
} catch (e) { logError('cache clear failed', e); }
|
||||
this._clearThumbnailCache();
|
||||
this._addIdleSource(() => this._panel?.clearItems());
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete every cached thumbnail without blocking the compositor:
|
||||
* enumerate and unlink asynchronously in batches. */
|
||||
_clearThumbnailCache() {
|
||||
const dir = Gio.File.new_for_path(
|
||||
`${GLib.get_user_cache_dir()}/strata/thumbnails`);
|
||||
dir.enumerate_children_async(
|
||||
'standard::name', Gio.FileQueryInfoFlags.NONE,
|
||||
GLib.PRIORITY_DEFAULT, null,
|
||||
(d, res) => {
|
||||
let en;
|
||||
try {
|
||||
en = d.enumerate_children_finish(res);
|
||||
} catch {
|
||||
return; // dir missing or unreadable
|
||||
}
|
||||
const step = () => {
|
||||
en.next_files_async(32, GLib.PRIORITY_DEFAULT, null, (e, r) => {
|
||||
let infos;
|
||||
try {
|
||||
infos = e.next_files_finish(r);
|
||||
} catch {
|
||||
infos = [];
|
||||
}
|
||||
if (infos.length === 0) {
|
||||
e.close_async(GLib.PRIORITY_DEFAULT, null, () => {});
|
||||
return;
|
||||
}
|
||||
for (const info of infos) {
|
||||
e.get_child(info).delete_async(
|
||||
GLib.PRIORITY_DEFAULT, null,
|
||||
(f, rr) => { try { f.delete_finish(rr); } catch { /* best-effort */ } });
|
||||
}
|
||||
step();
|
||||
});
|
||||
};
|
||||
step();
|
||||
});
|
||||
}
|
||||
|
||||
_disconnectSignals() {
|
||||
if (this._itemAddedId && this._proxy) {
|
||||
this._proxy.disconnectSignal(this._itemAddedId);
|
||||
|
|
@ -513,24 +486,25 @@ export default class StrataExtension extends Extension {
|
|||
});
|
||||
}
|
||||
|
||||
_readExcluded() {
|
||||
return this._settings.get_strv('excluded-apps').map(s => s.toLowerCase());
|
||||
}
|
||||
|
||||
_isExcluded(appClass) {
|
||||
if (!appClass) return false;
|
||||
return this._excludedApps.some(ex => appClass.includes(ex.toLowerCase()));
|
||||
return this._excludedApps.some(ex => appClass.includes(ex));
|
||||
}
|
||||
|
||||
|
||||
_connectFocusTracking() {
|
||||
this._focusSignalId = global.display.connect('notify::focus-window', () => {
|
||||
global.display.connectObject('notify::focus-window', () => {
|
||||
const win = global.display.focus_window;
|
||||
this._currentFocusedApp = (win?.get_wm_class() ?? '').toLowerCase();
|
||||
});
|
||||
}, this);
|
||||
}
|
||||
|
||||
_disconnectFocusTracking() {
|
||||
if (this._focusSignalId !== null) {
|
||||
global.display.disconnect(this._focusSignalId);
|
||||
this._focusSignalId = null;
|
||||
}
|
||||
global.display.disconnectObject(this);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -551,29 +525,4 @@ export default class StrataExtension extends Extension {
|
|||
_unregisterShortcut() {
|
||||
Main.wm.removeKeybinding('keyboard-shortcut');
|
||||
}
|
||||
|
||||
|
||||
/** Load light.css once. It is scoped under `.strata-theme-light` and stays
|
||||
* inert until the panel adds that class. We do not subscribe to the theme
|
||||
* context's 'changed' signal because load_stylesheet itself emits it. */
|
||||
_loadThemeStylesheet() {
|
||||
try {
|
||||
const themeContext = St.ThemeContext.get_for_stage(global.stage);
|
||||
this._lightCssFile = this.dir.get_child('light.css');
|
||||
this._stTheme = themeContext.get_theme();
|
||||
this._stTheme.load_stylesheet(this._lightCssFile);
|
||||
} catch (e) {
|
||||
logError('Failed to load light.css', e);
|
||||
}
|
||||
}
|
||||
|
||||
_unloadThemeStylesheet() {
|
||||
try {
|
||||
this._stTheme?.unload_stylesheet(this._lightCssFile);
|
||||
} catch (e) {
|
||||
logError('Failed to unload light.css', e);
|
||||
}
|
||||
this._stTheme = null;
|
||||
this._lightCssFile = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
/* Strata light theme.
|
||||
*
|
||||
* Loaded into the Shell theme context by extension.js and activated by toggling
|
||||
* the `.strata-theme-light` class on the panel root box (see ui/panel.js).
|
||||
* Every rule is scoped under `.strata-theme-light` so it is strictly more
|
||||
* specific than its counterpart in stylesheet.css and wins deterministically,
|
||||
* regardless of stylesheet load order. The dark theme (stylesheet.css) is left
|
||||
* untouched: with no class present, none of these rules apply.
|
||||
*
|
||||
* Colors follow GNOME Adwaita (blue #3584e4, link #1a5fb4, amber #b5830a,
|
||||
* red #e01b24) and are picked for contrast on a light panel.
|
||||
*
|
||||
* Performance rule (same as stylesheet.css): NO `transition: all`, NO
|
||||
* Clutter/CSS animations. Only background-color transitions on hover.
|
||||
*/
|
||||
|
||||
/* ── Panel container ────────────────────────────────────────────────────── */
|
||||
.strata-panel.strata-theme-light {
|
||||
background-color: rgba(250, 250, 250, 0.98);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.strata-theme-light .strata-title {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-clear-btn {
|
||||
border-color: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-clear-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* ── Search box ─────────────────────────────────────────────────────────── */
|
||||
.strata-theme-light .strata-search {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
caret-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-search:focus {
|
||||
border-color: rgba(53, 132, 228, 0.8);
|
||||
background-color: rgba(0, 0, 0, 0.035);
|
||||
}
|
||||
|
||||
/* Placeholder ("Search...") text - legible dim gray on the light panel. */
|
||||
.strata-theme-light .strata-search-hint {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Clipboard item: hover / focus (Adwaita blue) ───────────────────────── */
|
||||
.strata-theme-light .strata-item:hover,
|
||||
.strata-theme-light .strata-item-hovered {
|
||||
background-color: rgba(53, 132, 228, 0.14);
|
||||
border-color: rgba(53, 132, 228, 0.45);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item:focus,
|
||||
.strata-theme-light .strata-item-focused {
|
||||
background-color: rgba(53, 132, 228, 0.16);
|
||||
border-color: rgba(53, 132, 228, 0.65);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item:hover:focus,
|
||||
.strata-theme-light .strata-item-focused.strata-item-hovered {
|
||||
background-color: rgba(53, 132, 228, 0.24);
|
||||
border-color: rgba(53, 132, 228, 0.78);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item:focus .strata-item-text,
|
||||
.strata-theme-light .strata-item-focused .strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
/* ── Active item (warm amber, darkened for light bg) ────────────────────── */
|
||||
.strata-theme-light .strata-item-active {
|
||||
background-color: rgba(181, 131, 10, 0.16);
|
||||
border-color: rgba(181, 131, 10, 0.50);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-active .strata-item-text {
|
||||
color: rgba(120, 82, 0, 1.0);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-active.strata-item-hovered {
|
||||
background-color: rgba(181, 131, 10, 0.24);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-active.strata-item-focused,
|
||||
.strata-theme-light .strata-item-active:focus {
|
||||
background-color: rgba(53, 132, 228, 0.20);
|
||||
border-color: rgba(53, 132, 228, 0.65);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-active.strata-item-focused .strata-item-text,
|
||||
.strata-theme-light .strata-item-active:focus .strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item:active {
|
||||
background-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
/* ── Item icon / thumbnail ──────────────────────────────────────────────── */
|
||||
.strata-theme-light .strata-item-icon {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-thumb {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ── Text content ───────────────────────────────────────────────────────── */
|
||||
.strata-theme-light .strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-url {
|
||||
color: rgba(26, 95, 180, 1.0);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-subtext {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Delete button (Adwaita red) ────────────────────────────────────────── */
|
||||
.strata-theme-light .strata-item:hover .strata-item-delete,
|
||||
.strata-theme-light .strata-item:focus .strata-item-delete,
|
||||
.strata-theme-light .strata-item-focused .strata-item-delete,
|
||||
.strata-theme-light .strata-item-hovered .strata-item-delete {
|
||||
color: rgba(224, 27, 36, 0.75);
|
||||
}
|
||||
|
||||
.strata-theme-light .strata-item-delete:hover {
|
||||
background-color: rgba(224, 27, 36, 0.13);
|
||||
color: rgba(224, 27, 36, 1.0);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "Strata",
|
||||
"description": "A fast clipboard manager. Storage, search and image decoding run in a separate Rust daemon.",
|
||||
"uuid": "strata@edu4rdshl.dev",
|
||||
"version": 9,
|
||||
"version": 11,
|
||||
"shell-version": ["50"],
|
||||
"settings-schema": "org.gnome.shell.extensions.strata",
|
||||
"url": "https://github.com/Edu4rdSHL/Strata"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import Adw from 'gi://Adw';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gdk from 'gi://Gdk';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
|
@ -80,29 +79,6 @@ export default class StrataPreferences extends ExtensionPreferences {
|
|||
|
||||
const appearanceGroup = new Adw.PreferencesGroup({ title: 'Appearance' });
|
||||
|
||||
const themes = [
|
||||
{ id: 'auto', label: 'Automatic' },
|
||||
{ id: 'light', label: 'Light' },
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
];
|
||||
const themeRow = new Adw.ComboRow({
|
||||
title: 'Theme',
|
||||
subtitle: 'Automatic follows the system light/dark preference',
|
||||
model: Gtk.StringList.new(themes.map(t => t.label)),
|
||||
});
|
||||
const currentTheme = settings.get_string('theme');
|
||||
const currentThemeIdx = themes.findIndex(t => t.id === currentTheme);
|
||||
themeRow.selected = currentThemeIdx >= 0 ? currentThemeIdx : 0;
|
||||
themeRow.connect('notify::selected', () => {
|
||||
settings.set_string('theme', themes[themeRow.selected].id);
|
||||
});
|
||||
settings.connect('changed::theme', () => {
|
||||
const idx = themes.findIndex(t => t.id === settings.get_string('theme'));
|
||||
if (idx >= 0 && themeRow.selected !== idx)
|
||||
themeRow.selected = idx;
|
||||
});
|
||||
appearanceGroup.add(themeRow);
|
||||
|
||||
const panelWidthRow = new Adw.SpinRow({
|
||||
title: 'Panel width',
|
||||
subtitle: 'Width of the clipboard panel in pixels',
|
||||
|
|
@ -211,7 +187,6 @@ export default class StrataPreferences extends ExtensionPreferences {
|
|||
for (const app of currentApps)
|
||||
model.append(app);
|
||||
|
||||
/** Sync the StringList back to GSettings. */
|
||||
const saveModel = () => {
|
||||
const apps = [];
|
||||
for (let i = 0; i < model.get_n_items(); i++) {
|
||||
|
|
|
|||
|
|
@ -52,17 +52,6 @@
|
|||
<description>When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list.</description>
|
||||
</key>
|
||||
|
||||
<key name="theme" type="s">
|
||||
<choices>
|
||||
<choice value="auto"/>
|
||||
<choice value="light"/>
|
||||
<choice value="dark"/>
|
||||
</choices>
|
||||
<default>'auto'</default>
|
||||
<summary>Panel color theme</summary>
|
||||
<description>Auto follows the system light/dark color scheme (org.gnome.desktop.interface color-scheme). Light and Dark force a fixed theme.</description>
|
||||
</key>
|
||||
|
||||
<key name="panel-position" type="s">
|
||||
<choices>
|
||||
<choice value="top-center"/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Strata clipboard manager stylesheet.
|
||||
/* Strata dark theme (the default; loaded when the system color scheme is dark).
|
||||
*
|
||||
* Performance rule: NO `transition: all`, NO Clutter/CSS animations.
|
||||
* Only background-color transitions on hover (GPU-composited, zero JS).
|
||||
129
strata@edu4rdshl.dev/stylesheet-light.css
Normal file
129
strata@edu4rdshl.dev/stylesheet-light.css
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/* Strata light theme.
|
||||
*
|
||||
* GNOME Shell loads this instead of stylesheet-dark.css when the system color
|
||||
* scheme is light. It imports the dark base and overrides the colors below, so
|
||||
* the overrides win by load order. Colors follow GNOME Adwaita (blue #3584e4,
|
||||
* link #1a5fb4, amber, red #e01b24), picked for contrast on a light panel.
|
||||
*/
|
||||
|
||||
@import url("stylesheet-dark.css");
|
||||
|
||||
.strata-panel {
|
||||
background-color: rgba(250, 250, 250, 0.98);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.strata-title {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.strata-clear-btn {
|
||||
border-color: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-clear-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.strata-search {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
caret-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.strata-search:focus {
|
||||
border-color: rgba(53, 132, 228, 0.8);
|
||||
background-color: rgba(0, 0, 0, 0.035);
|
||||
}
|
||||
|
||||
/* Placeholder ("Search...") text - legible dim gray on the light panel. */
|
||||
.strata-search-hint {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-item:hover,
|
||||
.strata-item-hovered {
|
||||
background-color: rgba(53, 132, 228, 0.14);
|
||||
border-color: rgba(53, 132, 228, 0.45);
|
||||
}
|
||||
|
||||
.strata-item:focus,
|
||||
.strata-item-focused {
|
||||
background-color: rgba(53, 132, 228, 0.16);
|
||||
border-color: rgba(53, 132, 228, 0.65);
|
||||
}
|
||||
|
||||
.strata-item:hover:focus,
|
||||
.strata-item-focused.strata-item-hovered {
|
||||
background-color: rgba(53, 132, 228, 0.24);
|
||||
border-color: rgba(53, 132, 228, 0.78);
|
||||
}
|
||||
|
||||
.strata-item:focus .strata-item-text,
|
||||
.strata-item-focused .strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.strata-item-active {
|
||||
background-color: rgba(181, 131, 10, 0.16);
|
||||
border-color: rgba(181, 131, 10, 0.50);
|
||||
}
|
||||
|
||||
.strata-item-active .strata-item-text {
|
||||
color: rgba(120, 82, 0, 1.0);
|
||||
}
|
||||
|
||||
.strata-item-active.strata-item-hovered {
|
||||
background-color: rgba(181, 131, 10, 0.24);
|
||||
}
|
||||
|
||||
.strata-item-active.strata-item-focused,
|
||||
.strata-item-active:focus {
|
||||
background-color: rgba(53, 132, 228, 0.20);
|
||||
border-color: rgba(53, 132, 228, 0.65);
|
||||
}
|
||||
|
||||
.strata-item-active.strata-item-focused .strata-item-text,
|
||||
.strata-item-active:focus .strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.strata-item:active {
|
||||
background-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
.strata-item-icon {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-item-thumb {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.strata-item-text {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.strata-item-url {
|
||||
color: rgba(26, 95, 180, 1.0);
|
||||
}
|
||||
|
||||
.strata-item-subtext {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.strata-item:hover .strata-item-delete,
|
||||
.strata-item:focus .strata-item-delete,
|
||||
.strata-item-focused .strata-item-delete,
|
||||
.strata-item-hovered .strata-item-delete {
|
||||
color: rgba(224, 27, 36, 0.75);
|
||||
}
|
||||
|
||||
.strata-item-delete:hover {
|
||||
background-color: rgba(224, 27, 36, 0.13);
|
||||
color: rgba(224, 27, 36, 1.0);
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import St from 'gi://St';
|
|||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
import { logError } from '../util.js';
|
||||
|
||||
const TEXT_PREVIEW_LEN = 140;
|
||||
const THUMB_SIZE = 48;
|
||||
|
||||
|
|
@ -22,24 +24,23 @@ function iconForMime(mimeType) {
|
|||
return 'edit-copy-symbolic';
|
||||
}
|
||||
|
||||
/** Detect if a string looks like a URL. */
|
||||
function isUrl(text) {
|
||||
return /^https?:\/\/.+/i.test(text.trim());
|
||||
}
|
||||
|
||||
/** Detect if a string looks like a CSS/hex color. */
|
||||
function isColor(text) {
|
||||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
|
||||
}
|
||||
|
||||
export const ClipboardItem = GObject.registerClass({
|
||||
GTypeName: 'StrataClipboardItem',
|
||||
Signals: {
|
||||
'activate': {},
|
||||
'delete': {},
|
||||
},
|
||||
}, class ClipboardItem extends St.Button {
|
||||
_init(id, mimeType, preview, opts = {}) {
|
||||
super._init({
|
||||
}, class extends St.Button {
|
||||
constructor(id, mimeType, preview, opts = {}) {
|
||||
super({
|
||||
style_class: 'strata-item',
|
||||
x_expand: true,
|
||||
can_focus: true,
|
||||
|
|
@ -56,13 +57,12 @@ export const ClipboardItem = GObject.registerClass({
|
|||
x_expand: true,
|
||||
});
|
||||
|
||||
// Left: icon or image thumbnail
|
||||
row.add_child(this._buildLeading(mimeType, preview));
|
||||
const isImage = mimeType.startsWith('image/');
|
||||
const urlLike = !isImage && isUrl(preview ?? '');
|
||||
const colorLike = !isImage && !urlLike && isColor(preview ?? '');
|
||||
row.add_child(this._buildLeading(mimeType, preview, isImage, colorLike));
|
||||
row.add_child(this._buildContent(preview, isImage, urlLike, colorLike));
|
||||
|
||||
// Center: text preview
|
||||
row.add_child(this._buildContent(mimeType, preview));
|
||||
|
||||
// Right: delete button
|
||||
const deleteBtn = new St.Button({
|
||||
style_class: 'strata-item-delete',
|
||||
icon_name: 'edit-delete-symbolic',
|
||||
|
|
@ -91,11 +91,11 @@ export const ClipboardItem = GObject.registerClass({
|
|||
});
|
||||
}
|
||||
|
||||
_buildLeading(mimeType, preview) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
_buildLeading(mimeType, preview, isImage, colorLike) {
|
||||
if (isImage) {
|
||||
return this._buildThumbnail(this._id);
|
||||
}
|
||||
if (isColor(preview)) {
|
||||
if (colorLike) {
|
||||
return this._buildColorSwatch(preview);
|
||||
}
|
||||
const icon = new St.Icon({
|
||||
|
|
@ -122,7 +122,7 @@ export const ClipboardItem = GObject.registerClass({
|
|||
const applyStyle = () => {
|
||||
try {
|
||||
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
|
||||
} catch (_) { /* container was destroyed mid-flight */ }
|
||||
} catch { /* container was destroyed mid-flight */ }
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -160,18 +160,18 @@ export const ClipboardItem = GObject.registerClass({
|
|||
this._thumbCache?.set(id, cachePath);
|
||||
applyStyle();
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail write error:', e);
|
||||
logError('Thumbnail write error', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail fetch handler error:', e);
|
||||
logError('Thumbnail fetch handler error', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail render error:', e);
|
||||
logError('Thumbnail render error', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
return container;
|
||||
|
|
@ -187,7 +187,7 @@ export const ClipboardItem = GObject.registerClass({
|
|||
x_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
container.add_child(icon);
|
||||
} catch (_) {}
|
||||
} catch { /* container destroyed before the icon was added */ }
|
||||
}
|
||||
|
||||
_buildColorSwatch(hex) {
|
||||
|
|
@ -200,7 +200,7 @@ export const ClipboardItem = GObject.registerClass({
|
|||
});
|
||||
}
|
||||
|
||||
_buildContent(mimeType, preview) {
|
||||
_buildContent(preview, isImage, urlLike, colorLike) {
|
||||
preview = preview ?? '';
|
||||
const box = new St.BoxLayout({
|
||||
vertical: true,
|
||||
|
|
@ -208,20 +208,20 @@ export const ClipboardItem = GObject.registerClass({
|
|||
style_class: 'strata-item-content',
|
||||
});
|
||||
|
||||
let mainText = '';
|
||||
let mainText;
|
||||
let subText = '';
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (isImage) {
|
||||
// Generic label - the thumbnail identifies the image, and the
|
||||
// on-clipboard format (often PNG even for a copied GIF/WebP) is an
|
||||
// implementation detail that misleads more than it informs.
|
||||
mainText = 'Image';
|
||||
} else if (isUrl(preview)) {
|
||||
} else if (urlLike) {
|
||||
mainText = preview.trim();
|
||||
try {
|
||||
subText = new URL(preview.trim()).hostname;
|
||||
} catch (_) {}
|
||||
} else if (isColor(preview)) {
|
||||
subText = GLib.Uri.parse(preview.trim(), GLib.UriFlags.NONE).get_host() ?? '';
|
||||
} catch { /* not a parseable URI */ }
|
||||
} else if (colorLike) {
|
||||
mainText = preview.trim().toUpperCase();
|
||||
subText = 'Color';
|
||||
} else {
|
||||
|
|
@ -233,7 +233,7 @@ export const ClipboardItem = GObject.registerClass({
|
|||
|
||||
const labelMain = new St.Label({
|
||||
text: mainText || '(empty)',
|
||||
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
|
||||
style_class: `strata-item-text${urlLike ? ' strata-item-url' : ''}`,
|
||||
x_expand: true,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/* panel.js - Strata clipboard popup panel. */
|
||||
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
import St from 'gi://St';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Meta from 'gi://Meta';
|
||||
|
|
@ -9,25 +8,18 @@ import Shell from 'gi://Shell';
|
|||
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import { ClipboardItem } from './clipboardItem.js';
|
||||
import { logError } from '../util.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150;
|
||||
const LOAD_MORE_THRESHOLD = 200;
|
||||
|
||||
function logError(label, err) {
|
||||
if (err !== undefined) {
|
||||
try { Gio.DBusError.strip_remote_error(err); } catch (_) {}
|
||||
}
|
||||
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
|
||||
console.error(`[Strata] ${label}${tail}`);
|
||||
}
|
||||
|
||||
export class StrataPanel {
|
||||
constructor(proxy, settings) {
|
||||
this._proxy = proxy;
|
||||
this._settings = settings;
|
||||
this._pageSize = settings.get_int('page-size');
|
||||
this._pageSizeChangedId = settings.connect('changed::page-size',
|
||||
() => { this._pageSize = settings.get_int('page-size'); });
|
||||
settings.connectObject('changed::page-size',
|
||||
() => { this._pageSize = settings.get_int('page-size'); }, this);
|
||||
/** @type {{ id: string, mimeType: string, preview: string }[]} */
|
||||
this._items = [];
|
||||
/** @type {Map<string, ClipboardItem>} id → widget */
|
||||
|
|
@ -53,16 +45,6 @@ export class StrataPanel {
|
|||
|
||||
this._buildUI();
|
||||
|
||||
// Theme: apply light/dark to the panel box and react to changes.
|
||||
// The light.css overrides (loaded by extension.js) only take effect
|
||||
// while the `.strata-theme-light` class is present on `_box`.
|
||||
this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
|
||||
this._applyTheme();
|
||||
this._themeChangedId = this._settings.connect('changed::theme',
|
||||
() => this._applyTheme());
|
||||
this._colorSchemeChangedId = this._interfaceSettings.connect('changed::color-scheme',
|
||||
() => this._applyTheme());
|
||||
|
||||
// Trigger the initial load as soon as the daemon owns its bus name.
|
||||
// At extension boot the daemon may still be starting (its D-Bus name
|
||||
// not yet owned); the proxy fires notify::g-name-owner when that
|
||||
|
|
@ -70,26 +52,8 @@ export class StrataPanel {
|
|||
// and only then issue GetHistory. If the daemon is already up, the
|
||||
// owner is already set and we load immediately.
|
||||
this._tryInitialLoad();
|
||||
this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
|
||||
() => this._tryInitialLoad());
|
||||
}
|
||||
|
||||
/** True when the effective theme is light. `auto` follows the system
|
||||
* color-scheme (anything other than prefer-dark counts as light). */
|
||||
_effectiveIsLight() {
|
||||
const mode = this._settings.get_string('theme');
|
||||
if (mode === 'light') return true;
|
||||
if (mode === 'dark') return false;
|
||||
return this._interfaceSettings.get_string('color-scheme') !== 'prefer-dark';
|
||||
}
|
||||
|
||||
/** Toggle the light-theme class on the panel root box. */
|
||||
_applyTheme() {
|
||||
if (!this._box) return;
|
||||
if (this._effectiveIsLight())
|
||||
this._box.add_style_class_name('strata-theme-light');
|
||||
else
|
||||
this._box.remove_style_class_name('strata-theme-light');
|
||||
this._proxy.connectObject('notify::g-name-owner',
|
||||
() => this._tryInitialLoad(), this);
|
||||
}
|
||||
|
||||
_tryInitialLoad() {
|
||||
|
|
@ -103,7 +67,6 @@ export class StrataPanel {
|
|||
|
||||
|
||||
_buildUI() {
|
||||
// Overlay container - sits above all windows.
|
||||
this._overlay = new St.Widget({
|
||||
layout_manager: new Clutter.FixedLayout(),
|
||||
visible: false,
|
||||
|
|
@ -190,7 +153,6 @@ export class StrataPanel {
|
|||
this._searchEntry.get_clutter_text().connect('text-changed', () => {
|
||||
this._scheduleSearch(this._searchEntry.get_text());
|
||||
});
|
||||
// Down arrow from search box moves focus to the first item.
|
||||
this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
|
||||
if (event.get_key_symbol() === Clutter.KEY_Down) {
|
||||
this._focusItem(0);
|
||||
|
|
@ -212,11 +174,9 @@ export class StrataPanel {
|
|||
});
|
||||
this._scrollView.set_child(this._itemList);
|
||||
|
||||
// Load more items when scrolled near the bottom.
|
||||
const vadj = this._scrollView.get_vadjustment();
|
||||
if (vadj) {
|
||||
vadj.connect('notify::value', () => this._maybeLoadMore());
|
||||
// Also re-check when the list grows (a new page just appended).
|
||||
vadj.connect('notify::upper', () => this._maybeLoadMore());
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +231,6 @@ export class StrataPanel {
|
|||
close() {
|
||||
if (!this._visible) return;
|
||||
this._visible = false;
|
||||
// Clear hover state since the panel is closing.
|
||||
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
|
||||
this._hoveredWidget = null;
|
||||
if (this._grab) {
|
||||
|
|
@ -293,7 +252,6 @@ export class StrataPanel {
|
|||
this._widgets.delete(id);
|
||||
}
|
||||
|
||||
// While searching, only render if the new item matches the active query.
|
||||
if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -320,6 +278,7 @@ export class StrataPanel {
|
|||
|
||||
removeItem(id) {
|
||||
this._items = this._items.filter(i => i.id !== id);
|
||||
this._thumbCache.delete(id);
|
||||
// Keep the search snapshot in sync so a deleted/pruned item sitting in
|
||||
// the not-yet-rendered tail can't be re-created as a phantom row when
|
||||
// scrolling appends the next page. Adjust _searchRendered if the removed
|
||||
|
|
@ -337,7 +296,6 @@ export class StrataPanel {
|
|||
if (wasHovered) this._hoveredWidget = null;
|
||||
widget.destroy();
|
||||
this._widgets.delete(id);
|
||||
// Re-assign active to the new first visible item.
|
||||
if (wasActive) {
|
||||
const first = this._getVisibleItems()[0];
|
||||
if (first) this._setActiveWidget(first);
|
||||
|
|
@ -353,27 +311,13 @@ export class StrataPanel {
|
|||
this._hoveredWidget = null;
|
||||
this._activeWidget = null;
|
||||
this._widgets.clear();
|
||||
this._thumbCache.clear();
|
||||
this._itemList.destroy_all_children();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._pageSizeChangedId) {
|
||||
this._settings.disconnect(this._pageSizeChangedId);
|
||||
this._pageSizeChangedId = 0;
|
||||
}
|
||||
if (this._themeChangedId) {
|
||||
this._settings.disconnect(this._themeChangedId);
|
||||
this._themeChangedId = 0;
|
||||
}
|
||||
if (this._colorSchemeChangedId && this._interfaceSettings) {
|
||||
this._interfaceSettings.disconnect(this._colorSchemeChangedId);
|
||||
this._colorSchemeChangedId = 0;
|
||||
}
|
||||
this._interfaceSettings = null;
|
||||
if (this._nameOwnerId) {
|
||||
this._proxy.disconnect(this._nameOwnerId);
|
||||
this._nameOwnerId = 0;
|
||||
}
|
||||
this._settings.disconnectObject(this);
|
||||
this._proxy.disconnectObject(this);
|
||||
if (this._searchDebounceId) {
|
||||
GLib.Source.remove(this._searchDebounceId);
|
||||
this._searchDebounceId = null;
|
||||
|
|
@ -395,14 +339,15 @@ export class StrataPanel {
|
|||
const BATCH = 20;
|
||||
for (let i = 0; i < items.length; i += BATCH) {
|
||||
if (!this._overlay) return false;
|
||||
await new Promise(resolve =>
|
||||
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
|
||||
await new Promise(resolve => {
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
const end = Math.min(i + BATCH, items.length);
|
||||
for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
|
||||
resolve();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
this._loadedOffset = offset + items.length;
|
||||
this._hasMore = items.length >= limit;
|
||||
|
|
@ -445,7 +390,6 @@ export class StrataPanel {
|
|||
// snapshot (no re-query, so a scroll cannot race the search).
|
||||
this._renderSearchPage(this._searchEpoch);
|
||||
} else {
|
||||
// Browse mode: pull the next page from the daemon.
|
||||
if (!this._hasMore) return;
|
||||
this._loadingMore = true;
|
||||
this._loadHistory(this._loadedOffset, this._pageSize)
|
||||
|
|
@ -482,12 +426,14 @@ export class StrataPanel {
|
|||
if (i === 0) {
|
||||
renderChunk();
|
||||
} else {
|
||||
await new Promise(resolve =>
|
||||
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
|
||||
await new Promise(resolve => {
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
renderChunk();
|
||||
resolve();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
this._searchRendered = end;
|
||||
|
|
@ -543,14 +489,12 @@ export class StrataPanel {
|
|||
return widget;
|
||||
}
|
||||
|
||||
/** Focus the item at visible-list index `idx` (clamped). */
|
||||
_focusItem(idx) {
|
||||
const items = this._getVisibleItems();
|
||||
if (items.length === 0) return;
|
||||
global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
|
||||
}
|
||||
|
||||
/** All currently-visible item actors in order. */
|
||||
_getVisibleItems() {
|
||||
return this._itemList.get_children().filter(c => c.visible);
|
||||
}
|
||||
|
|
@ -717,13 +661,11 @@ export class StrataPanel {
|
|||
}
|
||||
|
||||
_moveItemToTop(id, widget) {
|
||||
// Move data model entry to front.
|
||||
const idx = this._items.findIndex(i => i.id === id);
|
||||
if (idx > 0) {
|
||||
const [entry] = this._items.splice(idx, 1);
|
||||
this._items.unshift(entry);
|
||||
}
|
||||
// Move actor to position 0 in the list.
|
||||
this._itemList.set_child_at_index(widget, 0);
|
||||
}
|
||||
|
||||
|
|
@ -750,7 +692,6 @@ export class StrataPanel {
|
|||
const MARGIN = 16; // px gap from screen edge
|
||||
|
||||
let x, y;
|
||||
// Horizontal
|
||||
if (position.endsWith('left'))
|
||||
x = monitor.x + MARGIN;
|
||||
else if (position.endsWith('right'))
|
||||
|
|
|
|||
11
strata@edu4rdshl.dev/util.js
Normal file
11
strata@edu4rdshl.dev/util.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/* util.js - shared helpers for the Strata extension. */
|
||||
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
export function logError(label, err) {
|
||||
if (err instanceof GLib.Error)
|
||||
Gio.DBusError.strip_remote_error(err);
|
||||
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
|
||||
console.error(`[Strata] ${label}${tail}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue