diff --git a/csspanels@dr.drummie/CHANGELOG.md b/csspanels@dr.drummie/CHANGELOG.md new file mode 100644 index 00000000..b18bf7b4 --- /dev/null +++ b/csspanels@dr.drummie/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +All notable changes to CSS Panels are documented in this file. + +## [2.0.9] - 2026-04-21 + +### Changed + +- System tray indicator left click now opens CSS Panels settings directly (`xlet-settings extension csspanels@dr.drummie`) instead of the generic Extensions Manager (`cinnamon-settings extensions`). +- System tray indicator hover effect now matches panel applets: registered with `HoverStyleManager` on creation, unregistered on destroy — receives the same `background-color !important` highlight as other panel applets. Removed manual `enter-event`/`leave-event` opacity handlers. +- `HoverStyleManager`: new `hookExternalActor(actor)` / `unhookExternalActor(actor)` public API for registering actors outside the standard applet hierarchy. External actors tracked in `_externalActors[]` and automatically re-hooked on `refresh()` cycles. + +### Fixed + +- `restoreOriginalStyles()` in `panelStyler.js`: added optional skip of `scheduleRefreshPanels()` — avoids an unnecessary panel refresh cycle when invoked from `HoverStyleManager` during cleanup. + +## [2.0.8] - 2026-04-19 + +### Fixed + +- Theme detection performance: three-state cache for GTK CSS file read in `themeDetector.js` — avoids repeated synchronous file scan on every `isDarkModePreferred()` call (hot path in CSS generation). Cache invalidated on theme change. +- `global.logError()` call signature in `signalHandler.js`: was called with two arguments; GJS API accepts one — merged into a single string. +- `global.log()` regression from 2.0.7: 8 error/warning call sites in `stylerBase.js` and `extension.js` were logged as error / warning in debug mode - restored to info level. +- Unhandled promise in `wallpaperMonitor.js`: added `.catch()` handler to `_onWallpaperChanged()` fire-and-forget call. +- `hexToRgb()` in `themeUtils.js`: added support for CSS `#RGB` 3-digit shorthand (e.g. `#f08`). +- `restoreOriginalMethods()` in `alttabStyler.js`: added identity guard to prevent accidental loss of an intermediate monkey-patch. + +### Documentation + +- Known limitation: near-monochrome dark wallpapers (e.g. eclipse images) produce near-black panel color — mathematically correct behavior, documented as low-priority with workaround. + +## [2.0.7] - 2026-04-19 + +### Added + +- **Desklet Styling**: Apply transparency, blur, and glow effects to desktop widgets (desklets) — toggle in Advanced settings. +- **Start Menu Sidebar Styling**: Optionally apply the popup color to the Cinnamon start menu sidebar (`menu@cinnamon.org`) — disabled by default, sidebar keeps theme color. +- **Dark/Light Mode Override**: New control to globally override dark/light mode detection — useful for mixed themes (e.g. Mint-Y-Aqua) where the panel is dark but the GTK theme has no `-Dark` suffix. +- **Wallpaper Extraction Mode**: Choose between `Standard (weighted average)` and `Contrast (polar tones)` algorithms for panel color extraction. + +### Fixed + +- Safe color parsing: extension no longer crashes on invalid or malformed color strings from settings. +- Theme detection race condition: 100ms debounce prevents stale color detection when theme changes fire before GTK CSS is fully loaded. +- Desklet styling: corrected style target from `desklet.actor` to `desklet.content` — background was invisible because the inner container covered it. +- Desklet live toggle: use `DeskletManager.definitions` (live array) instead of `getDefinitions()` — desklet styling now works on every toggle without Cinnamon restart. + +## [2.0.3] - 2026-04-17 + +### Fixed + +- Cinnamon Spices CI compliance: removed metadata fields that fail validation +- Settings lifecycle: proper `finalize()` call on extension disable — all bindings and signals cleanly released +- Monkey patch idempotency: `disable()` is now safe to call multiple times (Cinnamon can call it in error/reload scenarios) +- OSD styling: fixed monkey patch that leaked after extension disable (missing `disable()` method) +- System tray tooltip: fixed restore on indicator destroy + +## [2.0.2] - 2026-04-16 + +### Added + +- **Wallpaper Color Extraction**: extract dominant colors from the current wallpaper and apply them to panel background, popup menus, border, tint, and shadow +- **Full-Auto Mode** (experimental): every wallpaper change updates all shell colors live +- **Manual Extract Button**: apply wallpaper colors on demand without enabling automatic detection + +### Fixed + +- Wallpaper extraction: correct pixel sampling using GdkPixbuf rowstride (wrong colors on some images) +- Wallpaper extraction: URI decoding for paths with spaces or special characters +- Wallpaper extraction: manual extraction now works even without active wallpaper monitor +- Shadow color: corrected settings key used during wallpaper extraction +- Secondary color selection: improved contrast-ratio algorithm for popup color (replaces naive palette[1]) +- First-run defaults: sensible out-of-box appearance — Frosted Glass template, OSD and App Switcher styling enabled by default + +## [2.0.1] - 2026-04-12 + +### Fixed + +- Hover highlight: fixed signal accumulation after repeated open/close cycles on taskbar items +- Settings: "Detect from theme" button no longer requires auto-apply to be enabled +- Wallpaper extraction: fixed hash logic that prevented retry on transient errors + +## [2.0.0] - 2026-04-12 + +### Added + +- **Wallpaper Color System**: GdkPixbuf-based extraction of dominant colors, applied to all shell elements +- **Hover & Active Color Override**: panel applets, taskbar items, and system tray use dynamically generated highlight colors derived from the extension's panel color — no more theme color bleed-through +- **Glow Effect Mode**: three-way control — `Inset` (classic glossy), `Outset` (ambient glow), `None`; replaces the old border-width approach with no icon-shifting artifacts +- **Sub-menu lateral shadow**: popup sub-menus styled with lateral shadow only (no top/bottom bleed) +- **Theme Integration**: auto-apply accent colors on GTK theme change; "Detect from theme" button resets wallpaper state for a clean baseline +- **Desktop context menu styling**: optional propagation of popup styles to right-click desktop menus + +### Changed + +- Settings reorganized into 4 logical pages: **Theme**, **Appearance**, **Visual Effects**, **Advanced** +- Glow controls consolidated: `glow-mode` combobox + `glow-blur` / `glow-intensity` spinbuttons +- Border radius default reduced to 6px; maximum reduced to 12px (values above 12 cause artifacts in most themes) +- Effect templates expanded: Frosted Glass, Wet Glass, Foggy Glass, Clear Crystal — each in light and dark variants + +### Fixed + +- Extension disable isolation: a failure in one styler no longer blocks cleanup of all others +- Theme change race condition: 100ms debounce prevents stale color detection on rapid theme switches +- OSD monkey patch context: correct `this` binding — OSD styling was broken after refactor + +## [1.9.2] + +- Added tooltips and app switchers styling. +- Fixed transparency bug when panel color is not overridden. +- Refactored most of the code and improved debug logging for troubleshooting. + +## [1.8.9] + +- Added support for styling all panels (same style of main panel is applied onto other panels as well). +- Improved debug logging for troubleshooting. + +## [1.8.8] + +- Initial release with options to style main panel, popups, notifications and OSD's. diff --git a/csspanels@dr.drummie/README.md b/csspanels@dr.drummie/README.md new file mode 100644 index 00000000..b2330162 --- /dev/null +++ b/csspanels@dr.drummie/README.md @@ -0,0 +1,203 @@ +# CSS Panels + +**A Cinnamon extension for dynamic control of panels and popups colors and visual effects.** + +![screenshot.png](screenshot.png) + +## Features + +- **Panel Transparency**: Adjust the opacity of all panels with real-time preview (same panel style applied to all panels). +- **Menu Transparency**: Control transparency of popup menus and some popup-based controls for a consistent visual appearance. +- **Visual Effect Controls**: Customize saturation, contrast, brightness, and opacity multipliers for the transparency layer. Blur radius is configurable and applied where compositor support allows. +- **Border Radius**: Apply rounded corners to panels and menus. +- **Tint Overlay**: Add color tints to the transparency layer for personalized appearance. +- **Glow Effect**: Inset or outset glow at panel/menu edges (three modes: inset, outset, none). +- **Hover & Active Color Override**: Panel applets, taskbar items, and system tray elements use dynamically generated highlight colors derived from the panel color instead of the default theme color. +- **Use Styles for Notifications and OSDs**: Optional propagation of popup panel settings to notification banner and OSD. +- **Use Styles for App Switchers and Tooltips**: Optional propagation of popup panel settings to App Switchers and Tooltips (tooltips disabled by default for theme color consistency). +- **Start Menu Sidebar Styling**: Optionally apply the popup color to the Cinnamon start menu sidebar (menu@cinnamon.org). Disabled by default — sidebar keeps theme color. +- **Desklet Styling**: Apply transparency, blur, and glow effects to desklets. Check Compatibility section for limitations. Disabled by default — toggle in Advanced settings. +- **Wallpaper Color Extraction**: Automatically extract dominant colors from the current wallpaper and apply them to panel, menus, border, tint, and shadow — live on wallpaper change or via manual button. +- **System Tray Indicator**: Optional quick-access icon that opens extension settings directly (hidden by default — enable in Advanced settings). +- **Theme Integration**: Automatic detection of theme accent colors. +- **Debug Logging**: Enable detailed logging for troubleshooting. + +## Installation + +### From Cinnamon Extensions + +1. Open **Cinnamon Settings** > **Extensions**. +2. Search for "CSS Panels" and install. + +### Manual Installation + +1. Download the extension ZIP from the releases page. +2. Extract to `~/.local/share/cinnamon/extensions/csspanels@dr.drummie`. +3. Restart Cinnamon (Alt+F2, type `r`, Enter) or log out/in. +4. Enable the extension in **Cinnamon Settings** > **Extensions**. + +## Usage + +- Access settings via **Cinnamon Settings** > **Extensions** > **CSS Panels**. +- Enable the system tray indicator in Advanced settings for quick access to settings. +- Apply effect presets for instant visual styles. + +## Settings Overview + +The extension provides comprehensive control over transparency, color theming, and appearance through a multi-page settings interface organized into logical sections. + +### Theme Settings Page + +**Theme Integration** + +- **Auto-apply accent colors on theme change**: Automatically detect and apply accent colors when changing GTK themes. +- **Detect and apply accent from current theme**: Manual button to extract colors from active theme. Also resets any active wallpaper/override color state for a clean theme baseline. +- **Dark/light mode override**: Globally overrides dark/light mode detection for the entire extension — affects sidebar color fallback, accent color generation, and wallpaper extraction tone. `Auto (follow system/theme)` follows the active GTK color scheme and theme name. `Force dark` is recommended for mixed themes (e.g. Mint-Y-Aqua) where the panel is dark but the GTK theme has no -Dark suffix. +- **Border Radius**: Corner rounding for panels and menus (0-12px, default: 6px). +- **Apply Border Radius to Main Panel**: Enable rounded corners on taskbar. + +**Wallpaper Colors** + +- **Enable wallpaper detection**: Activates wallpaper color extraction. Automatically enables the panel color override so extracted colors apply visually. +- **Wallpaper manages all shell colors (experimental)**: When enabled, every wallpaper change also updates blur/accent settings (border color, background tint, shadow color). Requires wallpaper detection to be active. +- **Extract colors from wallpaper**: Manual button to extract and apply wallpaper colors immediately. If "Wallpaper manages all shell colors" is enabled, also updates border, tint, and shadow colors; otherwise only panel and popup colors are applied. +- **Wallpaper color extraction mode**: Choose the panel color extraction algorithm — `Standard (weighted average)` uses a weighted average of mid-tone pixels (smooth results); `Contrast (polar tones)` samples the darkest or lightest 25% of pixels to produce a color with stronger inherent contrast against the wallpaper (default). + +**Effect Templates** + +- **Effect Template**: Select preset templates (Frosted Glass, Wet Glass, Foggy Glass, Clear Crystal — each in light/dark variants). +- **Apply selected template**: Button to apply chosen template to all blur settings. + +### Appearance Settings Page + +**Basic Appearance Controls** + +- **Panel Opacity**: Adjust transparency of all panels (10-100%, step 5%). +- **Menu Opacity**: Adjust transparency of popup menus and some popup-based controls (10-100%, step 5%). Note: some Mint theme menus have hardcoded backgrounds that override this. +- **Override Panel Color**: Enable custom panel background color (checkbox). +- **Choose Override Panel Color**: Color picker for custom panel color (requires override enabled). +- **Override Popup Color**: Enable separate custom color for popup menus and popup-based controls (checkbox). +- **Choose Override Popup Color**: Color picker for custom popup color (requires override enabled). + +**Glow Effect Controls** + +- **Glow Effect Mode**: Three-way control — `Inset` (glow at edges/corners, classic glossy look), `Outset` (glow at center fading outward, ambient glow), `None` (no glow). See [Troubleshooting](#troubleshooting) for outset mode tips. +- **Glow Blur Size**: Spread/size of the glow (4-40px, spinbutton control). +- **Glow Intensity (Opacity)**: Brightness/visibility of glow (0.05-0.5, spinbutton control). + +### Visual Effects Page + +**Visual Effect Controls** + +- **Blur Radius**: Controls the CSS `blur()` value sent to the compositor (1-50px, default: 22px). Note: actual blur rendering depends on compositor support — on Cinnamon/Muffin this value is accepted but may not visually blur content. +- **Saturation Multiplier**: Color vibrancy (0.4-2.0, default: 0.95). +- **Contrast Multiplier**: Light/dark difference (0.4-2.0, default: 0.75). +- **Brightness Multiplier**: Overall lightness (0.4-2.0, default: 0.65). +- **Background Color/Tint**: Semi-transparent accent tint overlay (color picker). Automatically populated from active GTK theme or wallpaper extraction. +- **Border Color**: Color of element borders (color picker). Also used as glow color fallback. Auto-populated from theme or wallpaper. +- **Transition Duration**: Animation speed for visual effect transitions (0.0-2.0s, default: 0.3s). +- **Effect Layer Opacity**: Transparency of the visual effect layer (0.1-1.0, default: 0.8). +- **Accent Shadow/Glow Color**: Shadow color for box-shadow effects on all elements. Auto-populated from theme or wallpaper. +- **Shadow Spread**: Shadow effect intensity (0.1-1.0, default: 0.4). + +### Advanced Settings Page + +**Extended UI Styling** + +- **Style system notifications**: Apply visual effect styles to notification banners. +- **Style OSD elements**: Apply visual effect styles to On-Screen Display (volume, brightness, Caps Lock, etc.). +- **Style tooltip elements**: Apply visual effect styles to panel item tooltips. +- **Style Alt-Tab switcher elements**: Apply visual effect styles to application switcher. +- **Style start menu sidebar**: Apply the popup color override to the Cinnamon start menu (menu@cinnamon.org) sidebar. When disabled (default), sidebar uses the theme color. Has no effect if the original Cinnamon menu applet is not active. +- **Style desklet elements**: Apply transparency, blur, and glow effects to desktop widgets (desklets). + +**System Tray Indicator** + +- **Show system tray indicator**: Toggle visibility of tray icon. Click opens extension settings directly. + +**Debugging** + +- **Enable debug logging**: Detailed logging for troubleshooting (check `journalctl -f` or Looking Glass). + +### Color Override Logic + +- **Auto Detection Mode**: When both override switches are disabled, the panel color is detected from the current theme and propagated to popup menus for a consistent appearance. +- **Panel Override Mode**: When "Override Panel Color" is enabled, the selected panel color is applied to the main panel and — unless a popup override is enabled — to popup menus as well. +- **Popup Override Mode**: When "Override Popup Color" is enabled, popup menus use their own custom color while the panel uses either the panel override color or the auto-detected theme color. +- **Immediate Application**: Changes to override switches or color pickers apply immediately to the panel and any active popup menus (no Cinnamon restart required). + +### Wallpaper Color System + +- **Automatic Extraction**: Enable wallpaper detection to have the extension extract dominant colors from your current wallpaper using `GdkPixbuf` pixel analysis. +- **Smart Color Selection**: The extractor identifies the most prominent dark (panel) and light (popup) tones, plus accent variants for border, tint, and shadow. In **Contrast** mode, the panel color is derived from the polar extreme of the pixel brightness distribution (darkest 25% in dark mode, lightest 25% in light mode), and the popup color matches the panel tone at menu opacity. +- **Full-Auto Mode**: When active, every wallpaper change updates all color settings — panel, popup, border, tint, and shadow — live. +- **Manual Extract**: The "Extract colors from wallpaper" button applies panel and popup colors immediately. Border, tint, and shadow colors are only updated if "Wallpaper manages all shell colors" is also enabled. +- **Data Source Pattern**: Extraction only populates color picker values; actual styling happens through the standard settings callback chain (user can still tweak values manually after extraction). +- **Prerequisite**: Wallpaper detection automatically enables the panel color override when turned on (otherwise extracted panel colors would be ignored). Popup color override is intentionally left off by default — the popup inherits the panel color automatically, and you can enable popup override separately for independent customization. +- **Theme Tip for Full-Auto Mode**: When using full-auto mode, a neutral GTK theme (e.g. Mint-Y-Grey) is recommended. Window title bars, scrollbars, and other native UI elements use the GTK theme accent color and cannot be controlled by this extension — a neutral theme avoids visual clashes with the dynamically extracted wallpaper colors. + +### Glow Effect System + +- **Independent from Borders**: Glow works WITHOUT physical borders (no icon-shifting artifacts on panels). +- **Smart Color Fallback**: Uses `blur-border-color` → `blur-background` → theme white/black automatically. +- **Three Modes**: Inset (classic glossy), Outset (ambient reverse glow), None. +- **Applies to all elements**: Panel, popup, notification, OSD, tooltip, Alt-Tab switcher, desklets. +- **Live Updates**: Changes apply instantly without Cinnamon restart. + +## Compatibility + +- **Cinnamon Version**: 6.4, 6.6 +- **Multiversion**: Yes +- **Extension Conflicts**: May conflict with other extensions that modify the same UI elements + (panels, popup menus, notifications, OSD). Running multiple extensions that monkey-patch + Cinnamon's popup or panel system simultaneously can cause visual glitches or broken styling. + Disable conflicting extensions before using CSS Panels. + +**Desklet Compatibility**: Desklets are third-party widgets with their own styling systems. + CSS Panels can apply transparency, blur, and glow effects, but many desklets have hardcoded + background colors that cannot be overridden from outside. For such desklets, check their + own settings — if the desklet provides a background color option, setting it to fully + transparent (alpha: 0) will allow CSS Panels effects to show through. Results vary by + desklet implementation. + +## Troubleshooting + +- If effects don't apply, check theme compatibility. +- If Border Radius is not detected or valid, set it manually. +- Enable debug logging and check `journalctl -f` for errors (or use LG). +- Reset settings if issues persist. +- Actual background blur requires compositor shader support (e.g. BlurCinnamon) — this extension uses CSS effects only (transparency, glow, color). The `blur()` value is passed to the compositor but may not visually render on standard Cinnamon/Muffin. +- Experiment — you could use the color chooser to select desired color and transparency from existing elements on the screen. +- If wallpaper colors seem wrong, try switching to a different wallpaper and back, or use the manual extract button. +- **Custom colors reset after restart?** The extension re-detects theme colors on every load (consequence of auto detection). To preserve your custom color overrides, disable **Auto-apply accent colors on theme change** in Theme Settings — otherwise the extension will overwrite them with detected theme values on next startup. Note: wallpaper color extraction is controlled separately via **Enable wallpaper detection** and follows its own trigger logic. +- **Outset glow not visible or looks off?** The glow system is optimized for `Inset` mode. In `Outset` mode, results depend heavily on settings — try increasing **Glow Intensity**, reducing **Glow Blur Size**, or adjusting **Shadow Spread** and **Panel Opacity** to get the desired ambient effect. + +## Contributing + +- Report issues on GitHub. +- Check translations and suggest better ones. + +## License + +This extension is licensed under the GPL-3.0 License. + +## Credits + +- Inspired by BlurCinnamon@klangman. Developed by drdrummie. +- Icon downloaded from Post-production icons created by Smashicons - Flaticon + +## Technical Details + +- **Architecture**: Modular design with Strategy Pattern for component styling +- **Code Organization**: Centralized constants module (`constants.js`) for all magic numbers and strings +- **Monkey Patching**: Non-invasive interception of Cinnamon UI methods +- **Modern CSS**: Generates inline CSS with `backdrop-filter`, `box-shadow`, and color filters. Note: `backdrop-filter` blur is passed to the compositor but may not render on Cinnamon/Muffin — transparency, glow, and color effects work reliably. +- **Wallpaper Extraction**: GdkPixbuf-based pixel sampling and quantization via `colorPalette.js` +- **See**: [docs/how-csspanels-work.md](docs/how-csspanels-work.md) for technical documentation +- **Advanced Customization**: Advanced users can tweak behavior by editing `constants.js` directly in the extension directory — hover intensities (`HOVER_INTENSITY`, `ACTIVE_INTENSITY`), shadow multipliers, color fallbacks. Changes take effect after reloading the extension. Proceed at your own risk. + +--- + +**Note**: Best results with **Mint-Y** themes. **Mint-X** works well. **Mint-L** works but requires manual color customization — automatic adaptation on theme change is not fully supported yet. Fluent GTK themes are also supported but results may vary. + +Version: 2.0.9 | Last Edited: 2026-04-21 diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/alttabStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/alttabStyler.js new file mode 100644 index 00000000..7bac7328 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/alttabStyler.js @@ -0,0 +1,713 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const AppSwitcher = imports.ui.appSwitcher.appSwitcher; +const { ClassicSwitcher } = imports.ui.appSwitcher.classicSwitcher; +const AppSwitcher3D = imports.ui.appSwitcher.appSwitcher3D; +const Mainloop = imports.mainloop; +const Gio = imports.gi.Gio; +const Clutter = imports.gi.Clutter; +const StylerBase = require("./stylerBase"); +const { TIMING, CSS_CLASSES, STYLING, SETTINGS_KEYS } = require("./constants"); + +/** + * AltTab Styler handles Alt-Tab switcher transparency and blur effects + * Uses monkey patching to intercept Alt-Tab switcher display and apply CSS styling + */ +class AltTabStyler extends StylerBase { + constructor(extension) { + super(extension, "AltTabStyler"); + this.activeSwitchers = new Map(); + this.activeThumbnails = new Map(); + + // Debouncing for window title styling optimization + this.titleStylingTimeout = null; + + // Store original methods + this.originalAppSwitcherShow = null; + this.originalAppSwitcherHide = null; + this.originalClassicSwitcherShow = null; + this.originalClassicSwitcherHide = null; + this.originalAppSwitcher3DInit = null; + this.originalAppSwitcher3DHide = null; + + // Store thumbnail-related methods for ClassicSwitcher + this.originalClassicSwitcherCreateThumbnails = null; + this.originalClassicSwitcherDestroyThumbnails = null; + this.originalClassicSwitcherShowWindowPreview = null; + this.originalClassicSwitcherClearPreview = null; + + // Store AppSwitcher3D methods + this.originalAppSwitcher3DSetCurrentWindow = null; + + // Patched function refs for identity checking on restore + this._patchedAppSwitcher3DInit = null; + this._patchedAppSwitcher3DHide = null; + this._patchedAppSwitcher3DSetCurrentWindow = null; + this._patchedClassicSwitcherShow = null; + this._patchedClassicSwitcherHide = null; + this._patchedClassicSwitcherCreateThumbnails = null; + this._patchedClassicSwitcherDestroyThumbnails = null; + this._patchedAppSwitcherShow = null; + this._patchedAppSwitcherHide = null; + } + + /** + * Enable Alt-Tab styling using monkey patching approach + */ + enable() { + super.enable(); + + try { + const settings = new Gio.Settings({ schema: "org.cinnamon" }); + const switcherStyle = settings.get_string(SETTINGS_KEYS.ALTTAB_SWITCHER_STYLE); + this.debugLog("Current Alt-Tab switcher style:", switcherStyle); + + // Monkey patch different switcher types based on detected style + this.monkeyPatchSwitchers(switcherStyle); + } catch (e) { + this.debugLog("Error reading Alt-Tab switcher style:", e); + // Fallback to patching all switcher types + this.monkeyPatchSwitchers("unknown"); + } + + this.debugLog("AltTab styler enabled with event-driven approach"); + } + + /** + * Monkey patch switcher classes based on switcher style + */ + monkeyPatchSwitchers(switcherStyle) { + // Patch AppSwitcher3D (for coverflow/timeline styles) + if (AppSwitcher3D && AppSwitcher3D.AppSwitcher3D) { + this.patchAppSwitcher3D(); + } + + // Patch ClassicSwitcher (for classic/icons style) + if (ClassicSwitcher) { + this.patchClassicSwitcher(); + } + + // Patch general AppSwitcher if available + if (AppSwitcher && AppSwitcher.AppSwitcher) { + this.patchAppSwitcher(); + } + } + + /** + * Patch AppSwitcher3D init and hide methods + */ + patchAppSwitcher3D() { + // Store original methods + this.originalAppSwitcher3DInit = AppSwitcher3D.AppSwitcher3D.prototype._init; + this.originalAppSwitcher3DHide = AppSwitcher3D.AppSwitcher3D.prototype._hide; + this.originalAppSwitcher3DSetCurrentWindow = AppSwitcher3D.AppSwitcher3D.prototype._setCurrentWindow; + + // Create reference to this for use in patched methods + const stylerInstance = this; + + // Patch _init method + this._patchedAppSwitcher3DInit = function (...params) { + // Call original init + stylerInstance.originalAppSwitcher3DInit.apply(this, params); + + // Apply styling + if (this._background && this.actor) { + stylerInstance.debugLog("AppSwitcher3D initialized, applying styles"); + stylerInstance.styleSwitcher({ actor: this.actor }, true); // Use panel background + stylerInstance.activeSwitchers.set(this.actor, { + style: this.actor.get_style(), + styleClasses: this.actor.get_style_class_name(), + switcherInstance: this, + }); + } + }; + AppSwitcher3D.AppSwitcher3D.prototype._init = this._patchedAppSwitcher3DInit; + + // Patch hide method + this._patchedAppSwitcher3DHide = function (...params) { + // Clean up styling before hiding + if (this.actor && stylerInstance.activeSwitchers.has(this.actor)) { + stylerInstance.debugLog("AppSwitcher3D hiding, cleaning up styles"); + const originalData = stylerInstance.activeSwitchers.get(this.actor); + stylerInstance.restoreSwitcherStyle(this.actor, originalData); + stylerInstance.activeSwitchers.delete(this.actor); + } + + // Call original hide + stylerInstance.originalAppSwitcher3DHide.apply(this, params); + }; + AppSwitcher3D.AppSwitcher3D.prototype._hide = this._patchedAppSwitcher3DHide; + + // Patch _setCurrentWindow method to style window title + this._patchedAppSwitcher3DSetCurrentWindow = function (...params) { + // Call original method + stylerInstance.originalAppSwitcher3DSetCurrentWindow.apply(this, params); + + // Style the newly created window title + if (this._windowTitle) { + stylerInstance.debugLog("AppSwitcher3D window title created, applying styling"); + stylerInstance.styleWindowTitle(this._windowTitle, this); + } + }; + AppSwitcher3D.AppSwitcher3D.prototype._setCurrentWindow = this._patchedAppSwitcher3DSetCurrentWindow; + } + + /** + * Find switcher-list element within a switcher actor + * @param {Object} actor - The switcher actor to search in + * @returns {Object|null} The switcher-list element or null + */ + findSwitcherList(actor) { + function search(currentActor) { + if (currentActor && currentActor.get_style_class_name) { + const className = currentActor.get_style_class_name(); + if ( + className && + className.includes(CSS_CLASSES.SWITCHER_LIST) && + !className.includes(CSS_CLASSES.SWITCHER_LIST_ITEM) + ) { + return currentActor; + } + } + if (currentActor && currentActor.get_children) { + for (let child of currentActor.get_children()) { + let found = search(child); + if (found) return found; + } + } + return null; + } + return search(actor); + } + + /** + * Patch ClassicSwitcher show and hide methods + */ + patchClassicSwitcher() { + // Store original methods + this.originalClassicSwitcherShow = ClassicSwitcher.prototype._show; + this.originalClassicSwitcherHide = ClassicSwitcher.prototype._hide; + + // Store thumbnail-related methods (working approach) + this.originalClassicSwitcherCreateThumbnails = ClassicSwitcher.prototype._createThumbnails; + this.originalClassicSwitcherDestroyThumbnails = ClassicSwitcher.prototype._destroyThumbnails; + + const stylerInstance = this; + + // Patch show method + this._patchedClassicSwitcherShow = function (...params) { + // Call original show + stylerInstance.originalClassicSwitcherShow.apply(this, params); + + // Find and style the switcher-list element instead of the whole actor + if (this.actor) { + const switcherList = stylerInstance.findSwitcherList(this.actor); + if (switcherList) { + stylerInstance.debugLog("ClassicSwitcher shown, applying styles to switcher-list"); + stylerInstance.styleSwitcher({ actor: switcherList }); + stylerInstance.activeSwitchers.set(switcherList, { + style: switcherList.get_style(), + styleClasses: switcherList.get_style_class_name(), + switcherInstance: this, + }); + } else { + stylerInstance.debugLog("ClassicSwitcher: switcher-list not found, skipping styling"); + } + } + }; + ClassicSwitcher.prototype._show = this._patchedClassicSwitcherShow; + + // Patch hide method + this._patchedClassicSwitcherHide = function (...params) { + // Clean up styling before hiding + if (this.actor) { + const switcherList = stylerInstance.findSwitcherList(this.actor); + if (switcherList && stylerInstance.activeSwitchers.has(switcherList)) { + stylerInstance.debugLog("ClassicSwitcher hiding, cleaning up styles from switcher-list"); + const originalData = stylerInstance.activeSwitchers.get(switcherList); + stylerInstance.restoreSwitcherStyle(switcherList, originalData); + stylerInstance.activeSwitchers.delete(switcherList); + } + } + + // Call original hide + stylerInstance.originalClassicSwitcherHide.apply(this, params); + }; + ClassicSwitcher.prototype._hide = this._patchedClassicSwitcherHide; + + // Patch _createThumbnails method (working approach) + this._patchedClassicSwitcherCreateThumbnails = function (...params) { + // Call original method + stylerInstance.originalClassicSwitcherCreateThumbnails.apply(this, params); + + // Style the newly created thumbnails + if (this._thumbnails && this._thumbnails.actor) { + stylerInstance.debugLog("Thumbnails created, applying styling"); + stylerInstance.styleThumbnails(this._thumbnails.actor, this); + } + }; + ClassicSwitcher.prototype._createThumbnails = this._patchedClassicSwitcherCreateThumbnails; + + // Patch _destroyThumbnails method (working approach) + this._patchedClassicSwitcherDestroyThumbnails = function (...params) { + // Clean up thumbnail styling before destroying + if (this._thumbnails && this._thumbnails.actor) { + stylerInstance.cleanupThumbnails(this._thumbnails.actor); + } + + // Call original method + stylerInstance.originalClassicSwitcherDestroyThumbnails.apply(this, params); + }; + ClassicSwitcher.prototype._destroyThumbnails = this._patchedClassicSwitcherDestroyThumbnails; + } + + /** + * Patch general AppSwitcher if needed + */ + patchAppSwitcher() { + if (!AppSwitcher.AppSwitcher.prototype._show || !AppSwitcher.AppSwitcher.prototype._hide) { + return; // Methods don't exist + } + + // Store original methods + this.originalAppSwitcherShow = AppSwitcher.AppSwitcher.prototype._show; + this.originalAppSwitcherHide = AppSwitcher.AppSwitcher.prototype._hide; + + const stylerInstance = this; + + // Patch show method + this._patchedAppSwitcherShow = function (...params) { + // Call original show + stylerInstance.originalAppSwitcherShow.apply(this, params); + + // Apply styling + if (this.actor) { + stylerInstance.debugLog("AppSwitcher shown, applying styles"); + stylerInstance.styleSwitcher({ actor: this.actor }); + stylerInstance.activeSwitchers.set(this.actor, { + style: this.actor.get_style(), + styleClasses: this.actor.get_style_class_name(), + switcherInstance: this, + }); + } + }; + AppSwitcher.AppSwitcher.prototype._show = this._patchedAppSwitcherShow; + + // Patch hide method + this._patchedAppSwitcherHide = function (...params) { + // Clean up styling before hiding + if (this.actor && stylerInstance.activeSwitchers.has(this.actor)) { + stylerInstance.debugLog("AppSwitcher hiding, cleaning up styles"); + const originalData = stylerInstance.activeSwitchers.get(this.actor); + stylerInstance.restoreSwitcherStyle(this.actor, originalData); + stylerInstance.activeSwitchers.delete(this.actor); + } + + // Call original hide + stylerInstance.originalAppSwitcherHide.apply(this, params); + }; + AppSwitcher.AppSwitcher.prototype._hide = this._patchedAppSwitcherHide; + } + + /** + * Disable Alt-Tab styling and restore original methods + */ + disable() { + this.debugLog("AltTabStyler: Starting disable cleanup"); + + // Clear debounce timeout + if (this.titleStylingTimeout) { + imports.mainloop.source_remove(this.titleStylingTimeout); + this.titleStylingTimeout = null; + } + + // Clean up all active switchers + this.activeSwitchers.forEach((originalData, switcherActor) => { + try { + this.restoreSwitcherStyle(switcherActor, originalData); + } catch (e) { + this.debugLog("Error restoring switcher during disable:", e); + } + }); + this.activeSwitchers.clear(); + + // Restore original methods + this.restoreOriginalMethods(); + + // Clean up all active thumbnails + this.activeThumbnails.forEach((originalData, thumbnailElement) => { + try { + this.restoreThumbnailStyle(thumbnailElement, originalData); + } catch (e) { + this.debugLog("Error restoring thumbnail during disable:", e); + } + }); + this.activeThumbnails.clear(); + + this.debugLog("AltTabStyler: Disable cleanup completed"); + super.disable(); + } + + /** + * Restore all original methods + */ + restoreOriginalMethods() { + // Restore AppSwitcher3D methods only if our patch is still active + if (AppSwitcher3D?.AppSwitcher3D) { + if (AppSwitcher3D.AppSwitcher3D.prototype._init === this._patchedAppSwitcher3DInit) { + AppSwitcher3D.AppSwitcher3D.prototype._init = this.originalAppSwitcher3DInit; + } + if (AppSwitcher3D.AppSwitcher3D.prototype._hide === this._patchedAppSwitcher3DHide) { + AppSwitcher3D.AppSwitcher3D.prototype._hide = this.originalAppSwitcher3DHide; + } + if (this._patchedAppSwitcher3DSetCurrentWindow && + AppSwitcher3D.AppSwitcher3D.prototype._setCurrentWindow === this._patchedAppSwitcher3DSetCurrentWindow) { + AppSwitcher3D.AppSwitcher3D.prototype._setCurrentWindow = this.originalAppSwitcher3DSetCurrentWindow; + } + } + + // Restore ClassicSwitcher methods only if our patch is still active + if (ClassicSwitcher) { + if (this._patchedClassicSwitcherShow && + ClassicSwitcher.prototype._show === this._patchedClassicSwitcherShow) { + ClassicSwitcher.prototype._show = this.originalClassicSwitcherShow; + } + if (this._patchedClassicSwitcherHide && + ClassicSwitcher.prototype._hide === this._patchedClassicSwitcherHide) { + ClassicSwitcher.prototype._hide = this.originalClassicSwitcherHide; + } + if (this._patchedClassicSwitcherCreateThumbnails && + ClassicSwitcher.prototype._createThumbnails === this._patchedClassicSwitcherCreateThumbnails) { + ClassicSwitcher.prototype._createThumbnails = this.originalClassicSwitcherCreateThumbnails; + } + if (this._patchedClassicSwitcherDestroyThumbnails && + ClassicSwitcher.prototype._destroyThumbnails === this._patchedClassicSwitcherDestroyThumbnails) { + ClassicSwitcher.prototype._destroyThumbnails = this.originalClassicSwitcherDestroyThumbnails; + } + } + + // Restore AppSwitcher methods only if our patch is still active + if (AppSwitcher?.AppSwitcher) { + if (this._patchedAppSwitcherShow && + AppSwitcher.AppSwitcher.prototype._show === this._patchedAppSwitcherShow) { + AppSwitcher.AppSwitcher.prototype._show = this.originalAppSwitcherShow; + } + if (this._patchedAppSwitcherHide && + AppSwitcher.AppSwitcher.prototype._hide === this._patchedAppSwitcherHide) { + AppSwitcher.AppSwitcher.prototype._hide = this.originalAppSwitcherHide; + } + } + } + + /** + * Apply styles to switcher with configurable background type + * @param {Object} switcher - The switcher object containing actor + * @param {boolean} isPanel - If true, uses panel color/opacity; if false, uses menu color/opacity + */ + styleSwitcher(switcher, isPanel = false) { + if (!switcher || !switcher.actor) { + this.debugLog("Invalid switcher or actor"); + return; + } + + try { + let panelColor = this.extension.themeDetector.getPanelBaseColor(); + let switcherColor, effectiveOpacity; + + if (isPanel) { + // Use panel color and panel opacity for background + switcherColor = panelColor; + effectiveOpacity = this.extension.panelOpacity; + } else { + // Use effective popup color and menu opacity for better readability + switcherColor = this.extension.themeDetector.getEffectivePopupColor(); + effectiveOpacity = this.extension.menuOpacity; + } + + // Apply common blur styling with Alt-Tab-specific additional styles + const config = { + backgroundColor: `rgba(${switcherColor.r}, ${switcherColor.g}, ${switcherColor.b}, ${effectiveOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: isPanel ? 1.0 : this.getAdjustedBorderRadius("alttab"), + blurRadius: this.getAdjustedBlurRadius("alttab"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager + const altTabCSS = this.extension.blurTemplateManager.generateAltTabCSS(config); + switcher.actor.set_style(altTabCSS); + + this.debugLog("Alt-Tab switcher styled via template generation"); + } catch (e) { + this.debugLog("Error styling switcher:", e); + } + } + + /** + * Restore original switcher styling (existing method remains the same) + */ + restoreSwitcherStyle(switcherActor, originalData) { + try { + if (switcherActor) { + switcherActor.set_style(originalData.style || ""); + if (originalData.styleClasses) { + switcherActor.set_style_class_name(originalData.styleClasses); + } + + // Remove our style classes + switcherActor.remove_style_class_name(CSS_CLASSES.ALTTAB_BLUR); + switcherActor.remove_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + switcherActor.remove_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + } + } catch (e) { + this.debugLog("Error restoring switcher style:", e); + } + } + + /** + * Refresh all currently active switchers (simplified since we're event-driven now) + */ + refreshActiveSwitchers() { + try { + this.debugLog(`Refreshing ${this.activeSwitchers.size} active switchers`); + this.activeSwitchers.forEach((originalData, switcherActor) => { + if (switcherActor && switcherActor.visible) { + this.styleSwitcher({ actor: switcherActor }); + } + }); + } catch (e) { + this.debugLog("Error refreshing active switchers:", e); + } + } + + /** + * Check if a switcher should be styled + * @param {Object} switcher - The switcher to check + * @returns {boolean} True if switcher should be styled + */ + shouldStyleSwitcher(switcher) { + // Style all Alt-Tab switchers for now - can be extended with filtering logic + return switcher && switcher.actor; + } + + /** + * Cleanup a switcher element when it's no longer visible + * @param {Object} switcher - The switcher to cleanup + */ + cleanupSwitcher(switcher) { + if (!switcher || !switcher.actor) return; + + const element = switcher.actor; + if (this.activeSwitchers.has(element)) { + this.debugLog(`Cleaning up switcher: ${element.get_style_class_name()}`); + const originalData = this.activeSwitchers.get(element); + this.restoreSwitcherStyle(element, originalData); + + // Clear cleanup timeout if exists + if (originalData.cleanupTimeout) { + Mainloop.source_remove(originalData.cleanupTimeout); + } + + this.activeSwitchers.delete(element); + } + } + + /** + * Style thumbnail elements using working approach from old implementation + * @param {Object} thumbnailActor - The thumbnail container actor + * @param {Object} switcherInstance - The ClassicSwitcher instance + */ + styleThumbnails(thumbnailActor, switcherInstance) { + if (!thumbnailActor) return; + + try { + // Find all thumbnail elements using the working approach + const thumbnailElements = this.findThumbnailElements(thumbnailActor); + thumbnailElements.forEach((element) => { + this.applyThumbnailStyling(element); + this.activeThumbnails.set(element, { + style: element.get_style(), + styleClasses: element.get_style_class_name(), + switcherInstance: switcherInstance, + }); + }); + + this.debugLog(`Styled ${thumbnailElements.length} thumbnail elements`); + } catch (e) { + this.debugLog("Error styling thumbnails:", e); + } + } + + /** + * Find thumbnail elements using working approach + * @param {Object} thumbnailActor - The thumbnail container + * @returns {Array} Array of thumbnail elements + */ + findThumbnailElements(thumbnailActor) { + const thumbnails = []; + + function searchForThumbnails(actor) { + if (actor && actor.get_style_class_name) { + const className = actor.get_style_class_name(); + // Target the preview container (switcher-list-item-container) instead of thumbnail + if (className && className.includes("switcher-list") && !className.includes("switcher-list-item")) { + thumbnails.push(actor); + return; // Don't search children once we find the container + } + } + if (actor && actor.get_children) { + actor.get_children().forEach(searchForThumbnails); + } + } + + if (thumbnailActor) { + searchForThumbnails(thumbnailActor); + } + + return thumbnails; + } + + /** + * Apply styling to preview container element + * @param {Object} previewContainer - The preview container element to style + */ + applyThumbnailStyling(previewContainer) { + if (!previewContainer) return; + + try { + let previewColor = this.extension.themeDetector.getEffectivePopupColor(); + + const config = { + backgroundColor: `rgba(${previewColor.r}, ${previewColor.g}, ${previewColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("alttab"), + blurRadius: this.getAdjustedBlurRadius("alttab"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager + const altTabCSS = this.extension.blurTemplateManager.generateAltTabCSS(config); + previewContainer.set_style(altTabCSS); + + this.debugLog("Preview container styled via template generation"); + } catch (e) { + this.debugLog("Error styling preview container:", e); + } + } + + /** + * Clean up styling for thumbnails + * @param {Object} thumbnailActor - The thumbnail actor to cleanup + */ + cleanupThumbnails(thumbnailActor) { + if (!thumbnailActor) return; + + const thumbnailElements = this.findThumbnailElements(thumbnailActor); + thumbnailElements.forEach((element) => { + if (this.activeThumbnails.has(element)) { + const originalData = this.activeThumbnails.get(element); + this.restoreThumbnailStyle(element, originalData); + this.activeThumbnails.delete(element); + } + }); + this.debugLog(`Cleaned up ${thumbnailElements.length} thumbnail elements`); + } + + /** + * Restore original thumbnail styling + * @param {Object} thumbnailElement - The thumbnail element + * @param {Object} originalData - Original styling data + */ + restoreThumbnailStyle(thumbnailElement, originalData) { + try { + if (thumbnailElement) { + thumbnailElement.set_style(originalData.style || ""); + if (originalData.styleClasses) { + thumbnailElement.set_style_class_name(originalData.styleClasses); + } + + thumbnailElement.remove_style_class_name(CSS_CLASSES.ALTTAB_BLUR); + thumbnailElement.remove_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + thumbnailElement.remove_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + } + } catch (e) { + this.debugLog("Error restoring thumbnail style:", e); + } + } + + /** + * Style AppSwitcher3D window title label + * @param {St.Label} windowTitle - The window title label to style + * @param {Object} switcherInstance - The AppSwitcher3D instance + */ + styleWindowTitle(windowTitle, switcherInstance) { + if (!windowTitle) return; + + // Clear previous timeout if exists + if (this.titleStylingTimeout) { + imports.mainloop.source_remove(this.titleStylingTimeout); + } + + // Debounce the styling operation + this.titleStylingTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this.performWindowTitleStyling(windowTitle, switcherInstance); + this.titleStylingTimeout = null; + return false; + }); + } + + /** + * Perform the actual window title styling (extracted for debouncing) + * @param {St.Label} windowTitle - The window title label to style + * @param {Object} switcherInstance - The AppSwitcher3D instance + */ + performWindowTitleStyling(windowTitle, switcherInstance) { + if (!windowTitle) return; + + try { + // Always use effective popup color and menu opacity for window title (better readability) + let titleColor = this.extension.themeDetector.getEffectivePopupColor(); + let titleOpacity = this.extension.menuOpacity; + + const config = { + backgroundColor: `rgba(${titleColor.r}, ${titleColor.g}, ${titleColor.b}, ${titleOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("alttab"), + blurRadius: this.getAdjustedBlurRadius("alttab"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager + const altTabCSS = this.extension.blurTemplateManager.generateAltTabCSS(config); + windowTitle.set_style(altTabCSS); + + // Store for cleanup + this.activeThumbnails.set(windowTitle, { + style: windowTitle.get_style(), + styleClasses: windowTitle.get_style_class_name(), + switcherInstance: switcherInstance, + }); + + this.debugLog("AppSwitcher3D window title styled via template generation"); + } catch (e) { + this.debugLog("Error styling window title:", e); + } + } +} + +module.exports = AltTabStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/blurTemplateManager.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/blurTemplateManager.js new file mode 100644 index 00000000..9b42e05c --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/blurTemplateManager.js @@ -0,0 +1,1212 @@ +/** + * Blur Template Manager handles blur effect templates + * Provides predefined blur templates for easy styling + * Generates inline CSS strings for direct actor.set_style() injection + */ +class BlurTemplateManager { + /** + * Initialize Blur Template Manager + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + this.extension = extension; + this.templates = this.initializeTemplates(); + + // Template cache for performance (LRU cache) + this._templateCache = new Map(); + this._cacheOrder = []; // Track access order for LRU + this._maxCacheSize = 50; + this._cacheHits = 0; + this._cacheMisses = 0; + } + + /** + * Initialize all blur templates + * @returns {Object} Map of template names to their settings + */ + initializeTemplates() { + return { + // Frosted Glass group + "frosted-glass": { + blurRadius: 15, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(255, 255, 255, 0.2)", + blurBorderColor: "rgba(255, 255, 255, 0.4)", + blurBorderWidth: 0, + blurTransition: 0.5, + blurOpacity: 0.9, + }, + "frosted-glass-dark": { + blurRadius: 22, + blurSaturate: 0.95, + blurContrast: 0.75, + blurBrightness: 0.65, + blurBackground: "rgba(0, 0, 0, 0.3)", + blurBorderColor: "rgba(255, 255, 255, 0.15)", + + blurTransition: 0.3, + blurOpacity: 0.8, + }, + "frosted-glass-orange-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(255, 165, 0, 0.15)", + blurBorderColor: "rgba(255, 140, 0, 0.3)", + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-orange-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(255, 69, 0, 0.2)", + blurBorderColor: "rgba(255, 140, 0, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + "frosted-glass-blue-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(0, 123, 255, 0.15)", + blurBorderColor: "rgba(0, 86, 179, 0.3)", + + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-blue-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(0, 51, 160, 0.2)", + blurBorderColor: "rgba(0, 86, 179, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + "frosted-glass-green-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(40, 167, 69, 0.15)", + blurBorderColor: "rgba(21, 87, 36, 0.3)", + + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-green-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(21, 87, 36, 0.2)", + blurBorderColor: "rgba(21, 87, 36, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + "frosted-glass-purple-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(102, 51, 153, 0.15)", + blurBorderColor: "rgba(75, 0, 130, 0.3)", + + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-purple-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(75, 0, 130, 0.2)", + blurBorderColor: "rgba(75, 0, 130, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + "frosted-glass-red-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(220, 53, 69, 0.15)", + blurBorderColor: "rgba(176, 42, 55, 0.3)", + + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-red-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(176, 42, 55, 0.2)", + blurBorderColor: "rgba(176, 42, 55, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + "frosted-glass-pink-light": { + blurRadius: 18, + blurSaturate: 1.4, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(255, 105, 180, 0.15)", + blurBorderColor: "rgba(255, 20, 147, 0.3)", + + blurTransition: 0.4, + blurOpacity: 0.88, + }, + "frosted-glass-pink-dark": { + blurRadius: 24, + blurSaturate: 0.9, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(255, 20, 147, 0.2)", + blurBorderColor: "rgba(255, 20, 147, 0.15)", + + blurTransition: 0.5, + blurOpacity: 0.8, + }, + // Wet Glass group + "wet-glass": { + blurRadius: 25, + blurSaturate: 1.5, + blurContrast: 1.2, + blurBrightness: 1.1, + blurBackground: "rgba(255, 255, 255, 0.1)", + blurBorderColor: "rgba(255, 255, 255, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.8, + }, + "wet-glass-dark": { + blurRadius: 28, + blurSaturate: 0.9, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(0, 0, 0, 0.4)", + blurBorderColor: "rgba(255, 255, 255, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-orange-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(255, 165, 0, 0.1)", + blurBorderColor: "rgba(255, 140, 0, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-orange-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(255, 69, 0, 0.4)", + blurBorderColor: "rgba(255, 140, 0, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-blue-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(0, 123, 255, 0.1)", + blurBorderColor: "rgba(0, 86, 179, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-blue-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(0, 51, 160, 0.4)", + blurBorderColor: "rgba(0, 86, 179, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-green-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(40, 167, 69, 0.1)", + blurBorderColor: "rgba(21, 87, 36, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-green-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(21, 87, 36, 0.4)", + blurBorderColor: "rgba(21, 87, 36, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-purple-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(102, 51, 153, 0.1)", + blurBorderColor: "rgba(75, 0, 130, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-purple-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(75, 0, 130, 0.4)", + blurBorderColor: "rgba(75, 0, 130, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-red-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(220, 53, 69, 0.1)", + blurBorderColor: "rgba(176, 42, 55, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-red-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(176, 42, 55, 0.4)", + blurBorderColor: "rgba(176, 42, 55, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + "wet-glass-pink-light": { + blurRadius: 26, + blurSaturate: 1.6, + blurContrast: 1.3, + blurBrightness: 1.2, + blurBackground: "rgba(255, 105, 180, 0.1)", + blurBorderColor: "rgba(255, 20, 147, 0.2)", + + blurTransition: 0.3, + blurOpacity: 0.85, + }, + "wet-glass-pink-dark": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.7, + blurBrightness: 0.6, + blurBackground: "rgba(255, 20, 147, 0.4)", + blurBorderColor: "rgba(255, 20, 147, 0.1)", + + blurTransition: 0.4, + blurOpacity: 0.7, + }, + // Foggy Glass group + "foggy-glass": { + blurRadius: 30, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(255, 255, 255, 0.4)", + blurBorderColor: "rgba(255, 255, 255, 0.3)", + + blurTransition: 1.0, + blurOpacity: 0.7, + }, + "foggy-glass-dark": { + blurRadius: 35, + blurSaturate: 0.7, + blurContrast: 0.6, + blurBrightness: 0.5, + blurBackground: "rgba(0, 0, 0, 0.5)", + blurBorderColor: "rgba(255, 255, 255, 0.05)", + + blurTransition: 1.2, + blurOpacity: 0.6, + }, + "foggy-glass-orange-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(255, 165, 0, 0.25)", + blurBorderColor: "rgba(255, 140, 0, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-orange-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(255, 69, 0, 0.4)", + blurBorderColor: "rgba(255, 140, 0, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + "foggy-glass-blue-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(0, 123, 255, 0.25)", + blurBorderColor: "rgba(0, 86, 179, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-blue-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(0, 51, 160, 0.4)", + blurBorderColor: "rgba(0, 86, 179, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + "foggy-glass-green-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(40, 167, 69, 0.25)", + blurBorderColor: "rgba(21, 87, 36, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-green-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(21, 87, 36, 0.4)", + blurBorderColor: "rgba(21, 87, 36, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + "foggy-glass-purple-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(102, 51, 153, 0.25)", + blurBorderColor: "rgba(75, 0, 130, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-purple-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(75, 0, 130, 0.4)", + blurBorderColor: "rgba(75, 0, 130, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + "foggy-glass-red-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(220, 53, 69, 0.25)", + blurBorderColor: "rgba(176, 42, 55, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-red-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(176, 42, 55, 0.4)", + blurBorderColor: "rgba(176, 42, 55, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + "foggy-glass-pink-light": { + blurRadius: 32, + blurSaturate: 1.0, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(255, 105, 180, 0.25)", + blurBorderColor: "rgba(255, 20, 147, 0.25)", + + blurTransition: 0.8, + blurOpacity: 0.75, + }, + "foggy-glass-pink-dark": { + blurRadius: 37, + blurSaturate: 0.6, + blurContrast: 0.5, + blurBrightness: 0.4, + blurBackground: "rgba(255, 20, 147, 0.4)", + blurBorderColor: "rgba(255, 20, 147, 0.05)", + + blurTransition: 1.0, + blurOpacity: 0.6, + }, + // Clear Crystal group + "clear-crystal": { + blurRadius: 10, + blurSaturate: 1.0, + blurContrast: 1.0, + blurBrightness: 1.0, + blurBackground: "rgba(255, 255, 255, 0.0)", + blurBorderColor: "rgba(255, 255, 255, 0.5)", + + blurTransition: 0.2, + blurOpacity: 1.0, + }, + "clear-crystal-dark": { + blurRadius: 12, + blurSaturate: 0.9, + blurContrast: 0.9, + blurBrightness: 0.8, + blurBackground: "rgba(0, 0, 0, 0.0)", + blurBorderColor: "rgba(255, 255, 255, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-orange-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(255, 165, 0, 0.0)", + blurBorderColor: "rgba(255, 140, 0, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-orange-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(255, 69, 0, 0.0)", + blurBorderColor: "rgba(255, 140, 0, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-blue-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(0, 123, 255, 0.0)", + blurBorderColor: "rgba(0, 86, 179, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-blue-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(0, 51, 160, 0.0)", + blurBorderColor: "rgba(0, 86, 179, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-green-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(40, 167, 69, 0.0)", + blurBorderColor: "rgba(21, 87, 36, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-green-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(21, 87, 36, 0.0)", + blurBorderColor: "rgba(21, 87, 36, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-purple-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(102, 51, 153, 0.0)", + blurBorderColor: "rgba(75, 0, 130, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-purple-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(75, 0, 130, 0.0)", + blurBorderColor: "rgba(75, 0, 130, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-red-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(220, 53, 69, 0.0)", + blurBorderColor: "rgba(176, 42, 55, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-red-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(176, 42, 55, 0.0)", + blurBorderColor: "rgba(176, 42, 55, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + "clear-crystal-pink-light": { + blurRadius: 12, + blurSaturate: 1.2, + blurContrast: 1.1, + blurBrightness: 1.0, + blurBackground: "rgba(255, 105, 180, 0.0)", + blurBorderColor: "rgba(255, 20, 147, 0.4)", + + blurTransition: 0.2, + blurOpacity: 0.95, + }, + "clear-crystal-pink-dark": { + blurRadius: 14, + blurSaturate: 0.8, + blurContrast: 0.8, + blurBrightness: 0.7, + blurBackground: "rgba(255, 20, 147, 0.0)", + blurBorderColor: "rgba(255, 20, 147, 0.2)", + + blurTransition: 0.2, + blurOpacity: 0.9, + }, + }; + } + + // ===== CSS GENERATION METHODS ===== + + /** + * Generate backdrop-filter CSS string + * @param {number} radius - Blur radius in pixels + * @param {number} saturate - Saturation multiplier (0.0-2.0) + * @param {number} contrast - Contrast multiplier (0.0-2.0) + * @param {number} brightness - Brightness multiplier (0.0-2.0) + * @returns {string} Backdrop-filter CSS or empty string if disabled + */ + getBackdropFilter(radius, saturate, contrast, brightness) { + if (radius <= 0 || !this.extension.cssManager.hasBackdropFilter) { + return ""; + } + + return `backdrop-filter: blur(${radius}px) saturate(${saturate}) contrast(${contrast}) brightness(${brightness});`; + } + + /** + * Generate dynamic shadow CSS with independent inset glow system (Phase 2.5E) + * Supports both user-controlled inset glow (panels) and formula-based inset (other elements) + * + * @param {string} elementType - Type of element ('panel', 'popup', 'notification', 'osd', 'tooltip', 'alttab') + * @param {number} borderWidth - Border width in pixels (affects adaptive minimum for panels) + * @param {Object|null} insetGlowConfig - Inset glow configuration object (null = Phase 2.5D fallback) + * @returns {string} Complete box-shadow CSS rule + * + * @example + * // Panel with Phase 2.5E inset glow + * _generateShadowCSS('panel', 2, { enabled: true }) + * // Uses enable-inset-glow setting + user blur/intensity/color + * + * @example + * // Popup with Phase 2.5D formula-based inset + * _generateShadowCSS('popup', 2, null) + * // Uses formula-based inset when borderWidth > 0 + */ + _generateShadowCSS(elementType, borderWidth, glowConfig = null, shadowMode = 'normal') { + const { STYLING } = require("./constants"); + + // Get shadow settings + const shadowSpread = this.extension.settings.getValue("shadow-spread"); + const shadowColor = this.extension.settings.getValue("accent-shadow-color"); + + // Calculate outer shadow + const baseBlur = Math.round(shadowSpread * STYLING.SHADOW_BASE_MULTIPLIER); + const multiplier = STYLING.SHADOW_BLUR_MULTIPLIERS[elementType] || 1.0; + const outerBlur = Math.round(baseBlur * multiplier); + + // Get glow mode (new setting: inset/outset/none) + const glowMode = this.extension.settings.getValue("glow-mode") || "none"; + + // Check if glow is enabled for this element + const shouldApplyGlow = glowMode !== "none"; + + this.extension.debugLog( + `[GlowMode] _generateShadowCSS: elementType=${elementType}, ` + + `glowMode=${glowMode}, shouldApply=${shouldApplyGlow}` + ); + + // Start with outer shadow; 'sides' mode uses lateral offsets only (no top/bottom bleed) + const shadowValue = shadowMode === 'sides' + ? `${outerBlur}px 0 ${outerBlur}px ${shadowColor}, -${outerBlur}px 0 ${outerBlur}px ${shadowColor}` + : `0 ${STYLING.SHADOW_VERTICAL_OFFSET}px ${outerBlur}px ${shadowColor}`; + let css = `box-shadow: ${shadowValue}`; + let glowEffect = ""; + + // Apply glow effect if enabled; skip on sub-menus (sides mode) to avoid center glow artifact + if (shouldApplyGlow && shadowMode !== 'sides') { + let glowBlur = this.extension.settings.getValue("glow-blur") || STYLING.INSET_GLOW_BLUR_DEFAULT; + const glowIntensity = this.extension.settings.getValue("glow-intensity") || STYLING.INSET_GLOW_INTENSITY_DEFAULT; + + // Clamp to valid range + glowBlur = Math.max(Math.min(glowBlur, STYLING.INSET_GLOW_BLUR_MAX), STYLING.INSET_GLOW_BLUR_MIN); + + // Get glow color from blur-border-color + let glowColorSetting = this.extension.settings.getValue("blur-border-color"); + + // Smart fallback chain + if (!glowColorSetting || glowColorSetting === "rgba(255, 255, 255, 1.0)") { + const isDarkMode = this.extension.themeDetector.isDarkModePreferred(); + const blurBorderColor = this.extension.settings.getValue("blur-border-color"); + const blurBackground = this.extension.settings.getValue("blur-background"); + + // Priority 1: Use accent border color + if (blurBorderColor && blurBorderColor !== "rgba(255, 255, 255, 0.3)") { + glowColorSetting = blurBorderColor; + } + // Priority 2: Use tint layer background + else if (blurBackground && blurBackground !== "rgba(255, 255, 255, 0.3)") { + glowColorSetting = blurBackground; + } + // Priority 3: Theme-appropriate fallback + else { + glowColorSetting = isDarkMode + ? "rgba(255, 255, 255, 1.0)" + : "rgba(0, 0, 0, 1.0)"; + } + } + + // Parse glow color and apply intensity + let glowRgba; + if (glowColorSetting && (glowColorSetting.includes("rgb(") || glowColorSetting.includes("rgba("))) { + if (glowColorSetting.includes("rgba(")) { + glowRgba = glowColorSetting.replace(/[\d.]+\)$/g, `${glowIntensity})`); + } else if (glowColorSetting.includes("rgb(")) { + glowRgba = glowColorSetting.replace(/\)$/, `, ${glowIntensity})`).replace("rgb(", "rgba("); + } + } else { + glowRgba = `${STYLING.INSET_GLOW_FALLBACK_COLOR}, ${glowIntensity})`; + this.extension.debugLog(`[GlowMode] Color parsing failed, using fallback`); + } + + // Apply glow based on mode + if (glowMode === "inset") { + glowEffect = `, inset 0 0 ${glowBlur}px ${glowRgba}`; + this.extension.debugLog( + `[GlowMode] Inset glow for ${elementType}: blur=${glowBlur}px, intensity=${glowIntensity}` + ); + } else if (glowMode === "outset") { + // Outer glow - shadow extends outward from the element + glowEffect = `, 0 0 ${glowBlur}px ${glowRgba}`; + this.extension.debugLog( + `[GlowMode] Outset glow for ${elementType}: blur=${glowBlur}px, intensity=${glowIntensity}` + ); + } + } + + css += glowEffect + " !important;"; + + this.extension.debugLog( + `Shadow generated for ${elementType}: outer=${outerBlur}px, ` + + `glow=${glowEffect ? "yes" : "none"}, spread=${shadowSpread}` + ); + + return css; + } + + /** + * Generate complete panel CSS for single-actor approach (Phase 2.5E) + * Combines background, blur, border, and shadow with independent inset glow system + * @param {Object} config - Configuration object + * @param {string} config.backgroundColor - Background color (rgba string) + * @param {number} config.borderRadius - Border radius in pixels + * @param {number} config.blurRadius - Blur radius + * @param {number} config.blurSaturate - Saturation multiplier + * @param {number} config.blurContrast - Contrast multiplier + * @param {number} config.blurBrightness - Brightness multiplier + * @param {string} config.borderColor - Border color (rgba string) + * @param {number} config.borderWidth - Border width in pixels + * @param {number} config.transition - Transition duration in seconds + * @returns {string} Complete inline CSS string + * @since Phase 2.5E + */ + generatePanelCSS(config) { + return this._generateElementCSS("panel", config, false); + } + + /** + * Generate panel shadow layer CSS (outer shadow only) + * Used for multi-actor panel system where shadow is on separate layer + * @param {Object} config - Configuration object + * @param {number} config.borderRadius - Border radius in pixels + * @param {number} config.borderWidth - Border width (determines shadow type) + * @param {number} config.transition - Transition duration in seconds + * @returns {string} Inline CSS string for shadow layer + */ + generatePanelShadowLayerCSS(config) { + const cacheKey = `panel_shadow_${config.borderRadius}_${config.borderWidth}_${config.transition}`; + + if (this._templateCache.has(cacheKey)) { + this._cacheHits++; + this._updateCacheAccess(cacheKey); + return this._templateCache.get(cacheKey); + } + + this._cacheMisses++; + + const { STYLING } = require("./constants"); + + // Get user settings + const shadowSpread = this.extension.settings.getValue("shadow-spread"); + const shadowColor = this.extension.settings.getValue("accent-shadow-color"); + + // Calculate shadow blur (outer shadow only, no inset glow) + const baseBlur = Math.round(shadowSpread * STYLING.SHADOW_BASE_MULTIPLIER); + const multiplier = STYLING.SHADOW_BLUR_MULTIPLIERS.panel || 1.0; + const outerBlur = Math.round(baseBlur * multiplier); + + // Build shadow layer CSS + let css = ` + background-color: transparent; + border-radius: ${config.borderRadius}px; + box-shadow: 0 ${STYLING.SHADOW_VERTICAL_OFFSET}px ${outerBlur}px ${shadowColor} !important; + transition: all ${config.transition}s ease; + contain: layout style paint; + transform: translateZ(0); + `; + + this._addToCache(cacheKey, css); + return css; + } + + /** + * Generate panel background layer CSS (background + blur + border + inset glow) + * Used for multi-actor panel system where background is on separate layer + * @param {Object} config - Configuration object (same structure as generatePanelCSS) + * @returns {string} Inline CSS string for background layer + */ + generatePanelBackgroundLayerCSS(config) { + const cacheKey = `panel_bg_${config.backgroundColor}_${config.opacity}_${config.borderRadius}_${config.blurRadius}_${config.blurSaturate}_${config.blurContrast}_${config.blurBrightness}_${config.borderColor}_${config.borderWidth}_${config.transition}`; + + if (this._templateCache.has(cacheKey)) { + this._cacheHits++; + this._updateCacheAccess(cacheKey); + return this._templateCache.get(cacheKey); + } + + this._cacheMisses++; + + const { STYLING } = require("./constants"); + + // Generate backdrop-filter + const backdropFilter = this.getBackdropFilter( + config.blurRadius, + config.blurSaturate, + config.blurContrast, + config.blurBrightness + ); + + // Build background layer CSS + let css = ` + background-color: ${config.backgroundColor}; + border-radius: ${config.borderRadius}px; + ${backdropFilter} + transition: all ${config.transition}s ease; + `; + + // Add border if width > 0 + if (config.borderWidth > 0) { + css += `border: ${config.borderWidth}px solid ${config.borderColor};`; + + // Add inset glow for glossy effect (only when border present) + const shadowSpread = this.extension.settings.getValue("shadow-spread"); + const baseBlur = Math.round(shadowSpread * STYLING.SHADOW_BASE_MULTIPLIER); + const multiplier = STYLING.SHADOW_BLUR_MULTIPLIERS.panel || 1.0; + const outerBlur = Math.round(baseBlur * multiplier); + const insetBlur = Math.round(outerBlur * STYLING.SHADOW_INSET_MULTIPLIER); + const insetGlowColor = this.extension.blurBackground; + + css += `box-shadow: inset 0 0 ${insetBlur}px ${insetGlowColor} !important;`; + } + + // Performance optimizations + css += ` + contain: layout style paint; + will-change: backdrop-filter, background-color; + transform: translateZ(0); + `; + + this._addToCache(cacheKey, css); + return css; + } + + /** + * Generate popup menu CSS with blur effects + * @param {Object} config - Configuration object (same structure as generatePanelCSS) + * @returns {string} Complete inline CSS string + */ + generatePopupCSS(config) { + return this._generateElementCSS("popup", config, true); + } + + /** + * Generate notification CSS with blur effects + * @param {Object} config - Configuration object + * @returns {string} Complete inline CSS string + */ + generateNotificationCSS(config) { + return this._generateElementCSS("notification", config, true); + } + + /** + * Generate OSD (On-Screen Display) CSS with blur effects + * @param {Object} config - Configuration object + * @returns {string} Complete inline CSS string + */ + generateOSDCSS(config) { + return this._generateElementCSS("osd", config, true); + } + + /** + * Generate tooltip CSS with blur effects + * @param {Object} config - Configuration object + * @returns {string} Complete inline CSS string + */ + generateTooltipCSS(config) { + return this._generateElementCSS("tooltip", config, true); + } + + /** + * Generate Alt-Tab switcher CSS with blur effects + * @param {Object} config - Configuration object + * @returns {string} Complete inline CSS string + */ + generateAltTabCSS(config) { + return this._generateElementCSS("alttab", config, true); + } + + /** + * Generate desklet CSS with blur and transparency effects + * @param {Object} config - Configuration object + * @returns {string} Complete inline CSS string + */ + generateDeskletCSS(config) { + return this._generateElementCSS("desklet", config, true); + } + + // ===== CACHE MANAGEMENT METHODS ===== + + /** + * Add CSS to cache with LRU eviction + * @param {string} key - Cache key + * @param {string} css - CSS string to cache + */ + _addToCache(key, css) { + // Evict oldest entry if cache is full + if (this._templateCache.size >= this._maxCacheSize) { + const oldestKey = this._cacheOrder.shift(); + this._templateCache.delete(oldestKey); + } + + this._templateCache.set(key, css); + this._cacheOrder.push(key); + } + + /** + * Update cache access order (move to end = most recently used) + * @param {string} key - Cache key that was accessed + */ + _updateCacheAccess(key) { + const index = this._cacheOrder.indexOf(key); + if (index > -1) { + this._cacheOrder.splice(index, 1); + this._cacheOrder.push(key); + } + } + + /** + * Clear template cache (call on theme/wallpaper changes) + */ + clearCache() { + this._templateCache.clear(); + this._cacheOrder = []; + this._cacheHits = 0; + this._cacheMisses = 0; + this.extension.debugLog("Template cache cleared"); + } + + /** + * Get cache statistics for debugging + * @returns {Object} Cache statistics + */ + getCacheStats() { + const total = this._cacheHits + this._cacheMisses; + const hitRate = total > 0 ? ((this._cacheHits / total) * 100).toFixed(1) : 0; + + return { + size: this._templateCache.size, + maxSize: this._maxCacheSize, + hits: this._cacheHits, + misses: this._cacheMisses, + hitRate: hitRate + "%", + }; + } + + /** + * Log cache statistics + */ + logCacheStats() { + const stats = this.getCacheStats(); + this.extension.debugLog( + `Template cache stats: ${stats.size}/${stats.maxSize} entries, ${stats.hits} hits, ${stats.misses} misses, ${stats.hitRate} hit rate` + ); + } + + /** + * Base method for generating element CSS with blur effects + * Consolidates common logic from generatePanelCSS, generatePopupCSS, generateNotificationCSS, generateOSDCSS, generateTooltipCSS, generateAltTabCSS + * @param {string} elementType - Element type ('panel'|'popup'|'notification'|'osd'|'tooltip'|'alttab') + * @param {Object} config - Configuration object with backgroundColor, opacity, borderRadius, blurRadius, etc. + * @param {boolean} includeOpacityInCacheKey - Whether to include opacity in cache key (panel=false, others=true) + * @returns {string} Complete inline CSS string + * @private + */ + _generateElementCSS(elementType, config, includeOpacityInCacheKey) { + // Get glow mode setting (new: inset/outset/none) + const glowMode = this.extension.settings.getValue("glow-mode") || "none"; + const glowEnabled = glowMode !== "none"; + + // Build glow config for all elements when glow is enabled + const glowConfig = glowEnabled + ? { + mode: glowMode, + blur: this.extension.settings.getValue("glow-blur"), + intensity: this.extension.settings.getValue("glow-intensity"), + color: this.extension.settings.getValue("blur-border-color"), + } + : null; + + // Generate cache key with prefix and optional opacity + const glowKey = glowConfig ? `_glow_${glowMode}_${glowConfig.blur}_${glowConfig.intensity}` : "_noglow"; + const opacityKey = includeOpacityInCacheKey ? `_${config.opacity}` : ""; + const borderWidthKey = `_${config.borderWidth}`; + const shadowModeKey = config.shadowMode ? `_${config.shadowMode}` : ""; + // Use borderRadiusCSS key if available; replace spaces to keep key clean + const borderRadiusKey = config.borderRadiusCSS !== undefined + ? config.borderRadiusCSS.replace(/\s/g, '_') + : String(config.borderRadius); + const cacheKey = `${elementType}_${config.backgroundColor}${opacityKey}_${borderRadiusKey}_${config.blurRadius}_${config.blurSaturate}_${config.blurContrast}_${config.blurBrightness}_${config.borderColor}${borderWidthKey}_${config.transition}${glowKey}${shadowModeKey}`; + + if (this._templateCache.has(cacheKey)) { + this._cacheHits++; + this._updateCacheAccess(cacheKey); + return this._templateCache.get(cacheKey); + } + + this._cacheMisses++; + + // Generate backdrop-filter + const backdropFilter = this.getBackdropFilter( + config.blurRadius, + config.blurSaturate, + config.blurContrast, + config.blurBrightness + ); + + const shadowCSS = this._generateShadowCSS(elementType, config.borderWidth, glowConfig, config.shadowMode || 'normal'); + + // Determine border-radius CSS value: use precomputed string or generate from number + const borderRadiusValue = config.borderRadiusCSS !== undefined + ? config.borderRadiusCSS + : `${config.borderRadius}px`; + + // Build CSS string + let css = ` + background-color: ${config.backgroundColor}; + border-radius: ${borderRadiusValue}; + ${backdropFilter} + ${shadowCSS} + transition: all ${config.transition}s ease; + `; + + // Border is now deprecated - only add if explicitly needed for non-panel elements + // and only if borderWidth > 0 (which should be rare with hardcoded 0 default) + if (elementType !== "panel" && config.borderWidth > 0) { + css += `border: ${config.borderWidth}px solid ${config.borderColor};`; + } + + // Add panel-specific CSS (performance optimizations) + if (elementType === "panel") { + css += ` + contain: layout style paint; + will-change: backdrop-filter, background-color; + transform: translateZ(0); + `; + } + + // Add popup-specific CSS + if (elementType === "popup") { + css += ` + contain: layout style paint; + will-change: backdrop-filter, background-color; + `; + } + + this._addToCache(cacheKey, css); + return css; + } + + // ===== TEMPLATE SYSTEM METHODS ===== + + /** + * Apply a template to the extension settings + * @param {string} templateName - Name of the template to apply + */ + applyTemplate(templateName) { + this.extension.debugLog(`Applying blur template: ${templateName}`); + + const selectedTemplate = this.templates[templateName]; + if (!selectedTemplate) { + this.extension.debugLog(`Invalid template selected: ${templateName}`); + return; + } + + try { + // Apply template values to settings + Object.keys(selectedTemplate).forEach((key) => { + const settingKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + this.extension.settings.setValue(settingKey, selectedTemplate[key]); + }); + + this.extension.debugLog(`Blur template ${templateName} applied successfully`); + + // Update CSS variables to apply changes immediately + this.extension.cssManager.updateAllVariables(); + this.extension.panelStyler.applyPanelStyles(); + } catch (e) { + this.extension.debugLog("Error applying blur template:", e); + } + } + + /** + * Get list of available templates + * @returns {Array} Array of template names + */ + getAvailableTemplates() { + return Object.keys(this.templates); + } + + /** + * Get template configuration + * @param {string} templateName - Name of the template + * @returns {Object|null} Template configuration or null if not found + */ + getTemplate(templateName) { + return this.templates[templateName] || null; + } + + /** + * Cleanup cache and reset statistics + + * Should be called when extension is disabled to free memory + */ + cleanup() { + this.extension.debugLog( + `Clearing template cache (${this._templateCache.size} entries, ` + + `${this._cacheHits} hits, ${this._cacheMisses} misses, ` + + `hit rate: ${this._getCacheHitRate()}%)` + ); + + this._templateCache.clear(); + this._cacheOrder = []; + this._cacheHits = 0; + this._cacheMisses = 0; + + this.extension.debugLog("Template cache cleanup complete"); + } + + /** + * Get cache hit rate for debugging + * @returns {string} Hit rate percentage + * @private + */ + _getCacheHitRate() { + const total = this._cacheHits + this._cacheMisses; + if (total === 0) return "0.0"; + return ((this._cacheHits / total) * 100).toFixed(1); + } +} + +module.exports = BlurTemplateManager; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/colorPalette.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/colorPalette.js new file mode 100644 index 00000000..9a1af868 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/colorPalette.js @@ -0,0 +1,790 @@ +/** + * ColorPalette - Wallpaper color extraction and palette analysis + * + * Extracts dominant colors from image files using GdkPixbuf pixel sampling + * and quantization. Provides color selection helpers for accent, background, + * and secondary popup colors. + * + * Adapted from savjs/colorPalette.js for CSSPanels production architecture. + * Removes disk cache, GNOME-specific logger, and background monitoring logic. + * + * @module colorPalette + */ + +const { GdkPixbuf } = imports.gi; +const { ThemeUtils } = require('./themeUtils'); +const { WALLPAPER_COLORS } = require('./constants'); + +/** + * Color palette extractor with in-memory LRU cache + */ +class ColorPalette { + /** + * @param {object} extension - CSSPanels extension instance (for debugLog) + */ + constructor(extension) { + this.extension = extension; + this._cache = new Map(); // In-memory only, max 5 entries (LRU) + this._maxCacheSize = 5; + } + + // ===== PUBLIC API ===== + + /** + * Extract dominant colors from an already-loaded GdkPixbuf. + * + * Pixbuf-accepting variant of extractColorsFromImage() — avoids a second + * disk read when the caller already holds the pixbuf in memory. + * The pixbuf is NOT disposed here; the caller owns its lifecycle. + * + * @param {GdkPixbuf.Pixbuf} pixbuf - Pre-loaded pixbuf + * @param {number} [maxColors=8] - Maximum number of colors to return + * @param {boolean} [preferLight=false] - If true, prefer light colors + * @returns {Array>} Array of [r, g, b] color arrays sorted by frequency + */ + extractFromPixbuf(pixbuf, maxColors = 8, preferLight = false) { + if (!pixbuf) { + return this.getDefaultPalette(); + } + return this.analyzePixbuf(pixbuf, maxColors, preferLight); + } + + /** + * Compute dominant tone from an already-loaded GdkPixbuf. + * + * Pixbuf-accepting variant of extractDominantTone() — avoids a second + * disk read when the caller already holds the pixbuf in memory. + * The pixbuf is NOT disposed here; the caller owns its lifecycle. + * + * @param {GdkPixbuf.Pixbuf} pixbuf - Pre-loaded pixbuf + * @param {boolean} [isDarkMode=false] - When true, skip very bright pixels + * @returns {Array} [r, g, b] weighted average color + */ + analyzePixbufForTone(pixbuf, isDarkMode = false) { + if (!pixbuf) { + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + try { + const width = pixbuf.get_width(); + const height = pixbuf.get_height(); + const nChannels = pixbuf.get_n_channels(); + const rowstride = pixbuf.get_rowstride(); + const pixels = pixbuf.get_pixels(); + const hasAlpha = pixbuf.get_has_alpha(); + + const gridStep = Math.max(1, Math.round( + Math.sqrt((width * height) / WALLPAPER_COLORS.COLOR_ANALYSIS_TARGET_SAMPLES) + )); + + const thresholds = WALLPAPER_COLORS.BRIGHTNESS_THRESHOLDS[isDarkMode ? 'dark' : 'light']; + const brightnessMin = thresholds.min; + const brightnessMax = thresholds.max; + + let totalR = 0, totalG = 0, totalB = 0, count = 0; + + for (let y = 0; y < height; y += gridStep) { + for (let x = 0; x < width; x += gridStep) { + const offset = y * rowstride + x * nChannels; + const r = pixels[offset]; + const g = pixels[offset + 1]; + const b = pixels[offset + 2]; + const a = hasAlpha ? pixels[offset + 3] : 255; + + if (a < 128) continue; + + const brightness = ThemeUtils.getHSP(r, g, b); + if (brightness < brightnessMin || brightness > brightnessMax) continue; + + totalR += r; + totalG += g; + totalB += b; + count++; + } + } + + if (count === 0) { + this._debugLog(`analyzePixbufForTone: no valid pixels, using fallback`); + return isDarkMode ? [46, 52, 64] : [220, 220, 220]; + } + + const result = [ + Math.round(totalR / count), + Math.round(totalG / count), + Math.round(totalB / count), + ]; + + this._debugLog( + `Dominant tone (from pixbuf): rgb(${result.join(', ')}) from ${count} pixels ` + + `(${isDarkMode ? 'dark' : 'light'} mode)` + ); + + return result; + } catch (e) { + this._debugLog(`analyzePixbufForTone error: ${e.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + } + + /** + * Extract dominant colors from an image file + * + * Accepts a plain file path (NOT a file:// URI). Loads the image scaled + * to max dimension, analyzes pixels, and returns sorted color palette. + * Results are cached per-path with LRU eviction (max 5 entries). + * + * @param {string} path - Plain file path to image (e.g. /home/user/wallpaper.jpg) + * @param {number} [maxColors=8] - Maximum number of colors to return + * @param {boolean} [preferLight=false] - If true, prefer light colors; if false, prefer dark + * @returns {Array>} Array of [r, g, b] color arrays, sorted by frequency + */ + extractColorsFromImage(path, maxColors = 8, preferLight = false) { + const cacheKey = `${path}:${preferLight ? 'light' : 'dark'}`; + + // Return cached result if available (promote to most-recent) + if (this._cache.has(cacheKey)) { + const cached = this._cache.get(cacheKey); + // LRU: re-insert to make it most-recent + this._cache.delete(cacheKey); + this._cache.set(cacheKey, cached); + this._debugLog(`Cache hit for ${path}`); + return cached; + } + + try { + const MAX_DIMENSION = WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION; + let pixbuf; + + try { + // GdkPixbuf.new_from_file_at_scale is synchronous but acceptable here: + // - only invoked on user-triggered wallpaper extraction, not in the event loop + // - GdkPixbuf has no stable async API in GJS/Cinnamon context + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, + MAX_DIMENSION, + MAX_DIMENSION, + true // preserve aspect ratio + ); + } catch (loadErr) { + this._debugLog(`Failed to load image ${path}: ${loadErr.message}`); + return this.getDefaultPalette(); + } + + if (!pixbuf) { + this._debugLog(`Pixbuf is null for ${path}`); + return this.getDefaultPalette(); + } + + const palette = this.analyzePixbuf(pixbuf, maxColors, preferLight); + + // LRU: evict oldest entry if cache is full + if (this._cache.size >= this._maxCacheSize) { + const oldestKey = this._cache.keys().next().value; + this._cache.delete(oldestKey); + this._debugLog(`Cache evicted oldest entry (${oldestKey})`); + } + + this._cache.set(cacheKey, palette); + this._debugLog(`Cached palette for ${path} (${palette.length} colors)`); + + return palette; + } catch (e) { + this._debugLog(`Error extracting colors from ${path}: ${e.message}`); + return this.getDefaultPalette(); + } + } + + /** + * Analyze a GdkPixbuf to extract dominant colors via pixel sampling and quantization + * + * Disposes the pixbuf after analysis to prevent memory accumulation. + * Returns colors sorted by pixel frequency (most dominant first). + * + * @param {GdkPixbuf.Pixbuf} pixbuf - Image pixbuf to analyze + * @param {number} maxColors - Maximum number of colors to extract + * @param {boolean} [preferLight=false] - If true, extract light tones; if false, dark tones + * @returns {Array>} Array of [r, g, b] arrays sorted by frequency + */ + analyzePixbuf(pixbuf, maxColors, preferLight = false) { + // Track which pixbuf needs disposal after analysis + let needsDispose = false; + let pixbufToDispose = null; + + // Resize large images for better performance + const MAX_DIMENSION = WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION; + if (pixbuf.get_width() > MAX_DIMENSION || pixbuf.get_height() > MAX_DIMENSION) { + const scale = MAX_DIMENSION / Math.max(pixbuf.get_width(), pixbuf.get_height()); + const resizedPixbuf = pixbuf.scale_simple( + Math.round(pixbuf.get_width() * scale), + Math.round(pixbuf.get_height() * scale), + GdkPixbuf.InterpType.BILINEAR + ); + this._debugLog(`Resized image to ${resizedPixbuf.get_width()}x${resizedPixbuf.get_height()} for analysis`); + + // Dispose original full-size pixbuf immediately (can be 10-50MB uncompressed). + // Prevents holding both full-size + resized in memory simultaneously. + try { + pixbuf.run_dispose(); + } catch (e) { + this._debugLog(`Error disposing original pixbuf: ${e.message}`); + } + + // Use resized pixbuf for analysis + pixbuf = resizedPixbuf; + pixbufToDispose = resizedPixbuf; + needsDispose = true; + } else { + // No resize needed, but still dispose after analysis + pixbufToDispose = pixbuf; + needsDispose = true; + } + + const width = pixbuf.get_width(); + const height = pixbuf.get_height(); + const nChannels = pixbuf.get_n_channels(); + const rowstride = pixbuf.get_rowstride(); // bytes per row (may include padding) + const pixels = pixbuf.get_pixels(); + const hasAlpha = pixbuf.get_has_alpha(); + + // Compute grid step so that total samples ≈ TARGET_SAMPLES across the image. + // Using sqrt keeps step proportional to both dimensions (avoids skinny grids). + const gridStep = Math.max(1, Math.round(Math.sqrt((width * height) / WALLPAPER_COLORS.COLOR_ANALYSIS_TARGET_SAMPLES))); + const colorMap = new Map(); + + let skippedTransparent = 0; + let skippedBlackWhite = 0; + let processedPixels = 0; + + // Brightness thresholds based on theme preference + const thresholds = WALLPAPER_COLORS.BRIGHTNESS_THRESHOLDS[preferLight ? 'light' : 'dark']; + const brightnessMin = thresholds.min; + const brightnessMax = thresholds.max; + + for (let y = 0; y < height; y += gridStep) { + for (let x = 0; x < width; x += gridStep) { + // Use rowstride for correct byte offset — rowstride may differ from width * nChannels + const offset = y * rowstride + x * nChannels; + const r = pixels[offset]; + const g = pixels[offset + 1]; + const b = pixels[offset + 2]; + const a = hasAlpha ? pixels[offset + 3] : 255; + + // Skip transparent pixels (alpha < 128) + if (a < 128) { + skippedTransparent++; + continue; + } + + // Calculate perceived brightness and skip out-of-range tones + const brightness = ThemeUtils.getHSP(r, g, b); + if (brightness < brightnessMin || brightness > brightnessMax) { + skippedBlackWhite++; + continue; + } + + // Skip grayscale/desaturated pixels + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + if (delta < WALLPAPER_COLORS.COLOR_MIN_SATURATION_DELTA) { + skippedBlackWhite++; + continue; + } + + // Quantize to cluster similar colors + const colorKey = this.quantizeColor(r, g, b, WALLPAPER_COLORS.COLOR_QUANTIZATION_STEP); + colorMap.set(colorKey, (colorMap.get(colorKey) || 0) + 1); + processedPixels++; + } + } + + this._debugLog( + `Analyzed ${processedPixels} pixels (${preferLight ? 'light' : 'dark'} mode), ` + + `skipped ${skippedTransparent} transparent, ${skippedBlackWhite} out-of-range` + ); + + // Sort by frequency and extract top N colors + const sortedColors = Array.from(colorMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, maxColors) + .map(([colorKey]) => this.parseColorKey(colorKey)); + + // Dispose pixbuf after analysis to prevent memory accumulation + if (needsDispose && pixbufToDispose) { + try { + pixbufToDispose.run_dispose(); + this._debugLog(`Disposed pixbuf after analysis (${width}x${height})`); + } catch (e) { + this._debugLog(`Error disposing pixbuf: ${e.message}`); + } + } + + return sortedColors.length > 0 ? sortedColors : this.getDefaultPalette(); + } + + /** + * Get the best accent color from a palette + * + * Scores colors by saturation (60%) and perceived brightness (40%). + * Prefers vibrant colors at medium brightness. + * + * @param {Array>} palette - Array of [r, g, b] colors + * @returns {Array} Best [r, g, b] accent color + */ + getBestAccentColor(palette) { + if (!palette || palette.length === 0) { + return [136, 192, 208]; // Nord frost fallback + } + + let bestColor = null; + let bestScore = -1; + + for (const color of palette) { + const [r, g, b] = color; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + const saturation = max === 0 ? 0 : delta / max; + + const brightness = ThemeUtils.getHSP(r, g, b); + + const brightnessScore = 1 - Math.abs(brightness - 140) / 140; + const saturationScore = saturation; + + const score = saturationScore * 0.6 + brightnessScore * 0.4; + + if (score > bestScore) { + bestScore = score; + bestColor = color; + } + } + + // Minimum saturation threshold — if best score is too low the palette is + // essentially greyscale (B&W wallpaper). Fall back to DEFAULT_ACCENT so + // we don't apply a random near-grey as an accent color. + const MIN_ACCENT_SCORE = 0.15; + if (bestScore < MIN_ACCENT_SCORE || !bestColor) { + this._debugLog(`Best accent score ${bestScore.toFixed(3)} below threshold — using DEFAULT_ACCENT fallback`); + const { DEFAULT_COLORS } = require('./constants'); + const fa = DEFAULT_COLORS.DEFAULT_ACCENT; + return [fa.r, fa.g, fa.b]; + } + + this._debugLog(`Best accent color: rgb(${bestColor.join(', ')}) score=${bestScore.toFixed(3)}`); + return bestColor; + } + + /** + * Get the best background color from a palette + * + * Finds the color closest to the target brightness (60 for dark, 200 for light). + * + * @param {Array>} palette - Array of [r, g, b] colors + * @param {boolean} [preferDark=false] - If true, target dark tones; if false, light tones + * @returns {Array} Best [r, g, b] background color + */ + getBestBackgroundColor(palette, preferDark = false) { + if (!palette || palette.length === 0) { + return preferDark ? [46, 52, 64] : [236, 239, 244]; + } + + const targetBrightness = preferDark ? 60 : 200; + let bestColor = palette[0]; + let bestDiff = Infinity; + + for (const color of palette) { + const brightness = ThemeUtils.getHSP(...color); + const diff = Math.abs(brightness - targetBrightness); + + if (diff < bestDiff) { + bestDiff = diff; + bestColor = color; + } + } + + this._debugLog(`Best background color: rgb(${bestColor.join(', ')}) (${preferDark ? 'dark' : 'light'} mode)`); + return bestColor; + } + + /** + * Get secondary color for popup background with contrast validation + * + * Iterates the palette to find the first candidate that has (a) sufficient + * contrast against the expected foreground (white/black) and (b) a minimum + * visual distance from dominantColor so popup looks distinct from panel. + * Falls back to a shaded dominant if no qualifying candidate is found. + * + * @param {Array>} palette - Array of [r, g, b] arrays from extractColorsFromImage + * @param {Array} dominantColor - [r, g, b] dominant (panel) color + * @param {boolean} isDarkMode - Current theme mode + * @returns {Array} [r, g, b] secondary color suitable for popup background + */ + getSecondaryColor(palette, dominantColor, isDarkMode) { + const expectedFg = isDarkMode ? [255, 255, 255] : [0, 0, 0]; + const shadeFactor = isDarkMode + ? WALLPAPER_COLORS.POPUP_SHADE_FALLBACK_DARK + : WALLPAPER_COLORS.POPUP_SHADE_FALLBACK_LIGHT; + + // Minimum RGB distance from dominant to avoid near-duplicate popup color + const MIN_DISTANCE = 30; + + for (const candidate of palette) { + const ratio = ThemeUtils.contrastRatio(candidate, expectedFg); + if (ratio < WALLPAPER_COLORS.POPUP_MIN_CONTRAST_RATIO) continue; + + // Reject if candidate is visually too close to the dominant (panel) color + const dr = candidate[0] - dominantColor[0]; + const dg = candidate[1] - dominantColor[1]; + const db = candidate[2] - dominantColor[2]; + const distance = Math.sqrt(dr * dr + dg * dg + db * db); + if (distance < MIN_DISTANCE) continue; + + this._debugLog(`Secondary color: contrast ${ratio.toFixed(2)} OK, dist ${distance.toFixed(0)}, shading candidate`); + return ThemeUtils.colorShade(candidate, shadeFactor); + } + + // No qualifying candidate found — shade dominant as fallback + this._debugLog(`Secondary color: no qualifying candidate, shading dominant`); + return ThemeUtils.colorShade(dominantColor, shadeFactor); + } + + /** + * Extract dominant tone from an image for use as panel background color. + * + * Unlike extractColorsFromImage(), this method does NOT filter out grayscale + * or desaturated pixels. It samples the entire image and returns a weighted + * average color that represents the true visual tone of the wallpaper — + * dark grey for dark wallpapers, warm beige for warm ones, etc. + * + * Intended to replace getBestBackgroundColor() as the panel color source, + * so that a near-black wallpaper gives a near-black panel instead of a + * spurious saturated color leaked by the saturation filter. + * + * Uses the same grid-step sampling as analyzePixbuf() for performance + * consistency. Skips fully transparent and pure white/black extremes to + * avoid washing out the result. + * + * @param {string} path - Plain file path to image (e.g. /home/user/wallpaper.jpg) + * @param {boolean} [isDarkMode=false] - When true, skip very bright pixels; when false, skip very dark ones + * @returns {Array} [r, g, b] weighted average color + */ + extractDominantTone(path, isDarkMode = false) { + const cacheKey = `${path}:tone:${isDarkMode ? 'dark' : 'light'}`; + + // Return cached result if available (LRU promote) + if (this._cache.has(cacheKey)) { + const cached = this._cache.get(cacheKey); + this._cache.delete(cacheKey); + this._cache.set(cacheKey, cached); + this._debugLog(`Cache hit for dominant tone ${path}`); + return cached; + } + + try { + const MAX_DIMENSION = WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION; + let pixbuf; + + try { + // GdkPixbuf.new_from_file_at_scale is synchronous but acceptable here: + // - only invoked on user-triggered wallpaper extraction, not in the event loop + // - GdkPixbuf has no stable async API in GJS/Cinnamon context + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, + MAX_DIMENSION, + MAX_DIMENSION, + true + ); + } catch (loadErr) { + this._debugLog(`extractDominantTone: failed to load ${path}: ${loadErr.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + if (!pixbuf) { + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + const width = pixbuf.get_width(); + const height = pixbuf.get_height(); + const nChannels = pixbuf.get_n_channels(); + const rowstride = pixbuf.get_rowstride(); + const pixels = pixbuf.get_pixels(); + const hasAlpha = pixbuf.get_has_alpha(); + + // Same grid step as analyzePixbuf for consistent performance + const gridStep = Math.max(1, Math.round( + Math.sqrt((width * height) / WALLPAPER_COLORS.COLOR_ANALYSIS_TARGET_SAMPLES) + )); + + // Brightness exclusion range — use centralized thresholds from WALLPAPER_COLORS + // Accepts mid-tones only; excludes extremes to align dominant with current theme mode + const thresholds = WALLPAPER_COLORS.BRIGHTNESS_THRESHOLDS[isDarkMode ? 'dark' : 'light']; + const brightnessMin = thresholds.min; + const brightnessMax = thresholds.max; + + // Collect samples for optional trimmed mean + const samples = []; + + for (let y = 0; y < height; y += gridStep) { + for (let x = 0; x < width; x += gridStep) { + const offset = y * rowstride + x * nChannels; + const r = pixels[offset]; + const g = pixels[offset + 1]; + const b = pixels[offset + 2]; + const a = hasAlpha ? pixels[offset + 3] : 255; + + // Skip transparent pixels + if (a < 128) continue; + + // Skip extreme brightness outliers only + const brightness = ThemeUtils.getHSP(r, g, b); + if (brightness < brightnessMin || brightness > brightnessMax) continue; + + // No saturation filter — include grey, neutral, and all mid-tone pixels + samples.push([r, g, b, brightness]); + } + } + + // Dispose pixbuf + try { pixbuf.run_dispose(); } catch (e) { /* ignore */ } + + if (samples.length === 0) { + // Fully black/white image — return safe fallback + this._debugLog(`extractDominantTone: no valid pixels, using fallback`); + return isDarkMode ? [46, 52, 64] : [220, 220, 220]; + } + + // Trimmed mean for dark mode: discard brightest 15% to prevent sky/highlight + // pixels from pulling the average toward lighter values (FIX-8) + let activeSamples = samples; + if (isDarkMode && samples.length > 10) { + samples.sort((a, b) => a[3] - b[3]); + const trimCount = Math.floor(samples.length * 0.15); + activeSamples = samples.slice(0, samples.length - trimCount); + this._debugLog(`Trimmed mean: removed ${trimCount}/${samples.length} brightest samples`); + } + + let totalR = 0, totalG = 0, totalB = 0; + for (const [r, g, b] of activeSamples) { + totalR += r; totalG += g; totalB += b; + } + const count = activeSamples.length; + + const result = [ + Math.round(totalR / count), + Math.round(totalG / count), + Math.round(totalB / count), + ]; + + this._debugLog( + `Dominant tone: rgb(${result.join(', ')}) from ${count} pixels ` + + `(${isDarkMode ? 'dark' : 'light'} mode)` + ); + + // Cache with LRU eviction + if (this._cache.size >= this._maxCacheSize) { + const oldestKey = this._cache.keys().next().value; + this._cache.delete(oldestKey); + } + this._cache.set(cacheKey, result); + + return result; + } catch (e) { + this._debugLog(`extractDominantTone error: ${e.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + } + + /** + * Extract polar tone from an already-loaded GdkPixbuf. + * + * Takes the darkest (dark mode) or lightest (light mode) POLAR_PERCENTILE fraction + * of all pixels sorted by HSP brightness. Unlike analyzePixbufForTone(), this method + * applies no brightness threshold filter — all non-transparent pixels are candidates. + * The resulting color is intentionally "extreme" to produce strong tonal contrast. + * + * The pixbuf is NOT disposed here; the caller owns its lifecycle. + * + * @param {GdkPixbuf.Pixbuf} pixbuf - Pre-loaded pixbuf + * @param {boolean} [isDarkMode=false] - When true, take darkest pixels; when false, lightest + * @returns {Array} [r, g, b] average color from the extreme percentile + */ + extractPolarTone(pixbuf, isDarkMode = false) { + if (!pixbuf) { + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + try { + const width = pixbuf.get_width(); + const height = pixbuf.get_height(); + const nChannels = pixbuf.get_n_channels(); + const rowstride = pixbuf.get_rowstride(); + const pixels = pixbuf.get_pixels(); + const hasAlpha = pixbuf.get_has_alpha(); + + const gridStep = Math.max(1, Math.round( + Math.sqrt((width * height) / WALLPAPER_COLORS.COLOR_ANALYSIS_TARGET_SAMPLES) + )); + + const samples = []; + + for (let y = 0; y < height; y += gridStep) { + for (let x = 0; x < width; x += gridStep) { + const offset = y * rowstride + x * nChannels; + const r = pixels[offset]; + const g = pixels[offset + 1]; + const b = pixels[offset + 2]; + const a = hasAlpha ? pixels[offset + 3] : 255; + + // Skip transparent pixels + if (a < 128) continue; + + // No saturation filter — include grey and neutral pixels + // No brightness threshold filter — include all non-transparent pixels + const brightness = ThemeUtils.getHSP(r, g, b); + samples.push([r, g, b, brightness]); + } + } + + if (samples.length === 0) { + this._debugLog(`extractPolarTone: no valid pixels, using fallback`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + // Sort by brightness ascending + samples.sort((a, b) => a[3] - b[3]); + + // Take bottom (darkest) or top (lightest) POLAR_PERCENTILE fraction + const count = Math.max(1, Math.floor(samples.length * WALLPAPER_COLORS.POLAR_PERCENTILE)); + const slice = isDarkMode + ? samples.slice(0, count) + : samples.slice(samples.length - count); + + let totalR = 0, totalG = 0, totalB = 0; + for (const [r, g, b] of slice) { + totalR += r; totalG += g; totalB += b; + } + + const result = [ + Math.round(totalR / count), + Math.round(totalG / count), + Math.round(totalB / count), + ]; + + this._debugLog( + `Polar tone (${isDarkMode ? 'dark' : 'light'}): rgb(${result.join(', ')}) from ${count}/${samples.length} pixels` + ); + + return result; + } catch (e) { + this._debugLog(`extractPolarTone error: ${e.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + } + + /** + * Extract polar tone from an image file path. + * + * Loads the image from disk, delegates to extractPolarTone(), then disposes + * the pixbuf. Use when no shared pixbuf is available. + * Falls back to a safe color if the image cannot be loaded. + * + * @param {string} path - Plain file path to image (e.g. /home/user/wallpaper.jpg) + * @param {boolean} [isDarkMode=false] - When true, take darkest pixels; when false, lightest + * @returns {Array} [r, g, b] average color from the extreme percentile + */ + extractPolarToneFromPath(path, isDarkMode = false) { + try { + const MAX_DIMENSION = WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION; + let pixbuf; + + try { + // GdkPixbuf.new_from_file_at_scale is synchronous but acceptable here: + // - only invoked on user-triggered wallpaper extraction, not in the event loop + // - GdkPixbuf has no stable async API in GJS/Cinnamon context + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, + MAX_DIMENSION, + MAX_DIMENSION, + true + ); + } catch (loadErr) { + this._debugLog(`extractPolarToneFromPath: failed to load ${path}: ${loadErr.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + if (!pixbuf) { + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + + const result = this.extractPolarTone(pixbuf, isDarkMode); + try { pixbuf.run_dispose(); } catch (e) { /* ignore */ } + + return result; + } catch (e) { + this._debugLog(`extractPolarToneFromPath error: ${e.message}`); + return isDarkMode ? [46, 52, 64] : [236, 239, 244]; + } + } + + // ===== HELPERS ===== + + /** + * Quantize RGB color to reduce similar colors into clusters + * + * Clamps values to valid RGB range (0-255) to prevent overflow. + * + * @param {number} r - Red component (0-255) + * @param {number} g - Green component (0-255) + * @param {number} b - Blue component (0-255) + * @param {number} [step=16] - Quantization step size + * @returns {string} Quantized color key as "r,g,b" + */ + quantizeColor(r, g, b, step = 16) { + const qr = Math.min(255, Math.round(r / step) * step); + const qg = Math.min(255, Math.round(g / step) * step); + const qb = Math.min(255, Math.round(b / step) * step); + return `${qr},${qg},${qb}`; + } + + /** + * Parse a quantized color key back to RGB array + * + * @param {string} key - Color key as "r,g,b" + * @returns {Array} [r, g, b] integer array + */ + parseColorKey(key) { + return key.split(',').map(n => parseInt(n)); + } + + /** + * Get default color palette fallback (Nord theme colors) + * + * @returns {Array>} Array of [r, g, b] Nord polar night and frost colors + */ + getDefaultPalette() { + return [ + [46, 52, 64], // Nord polar night + [59, 66, 82], + [67, 76, 94], + [76, 86, 106], + [136, 192, 208], // Nord frost + [129, 161, 193], + ]; + } + + // ===== PRIVATE ===== + + /** + * Log debug message via extension debug channel + * + * @param {string} message - Message to log + */ + _debugLog(message) { + if (this.extension && this.extension.debugLog) { + this.extension.debugLog(`[ColorPalette] ${message}`); + } + } +} + +module.exports = { ColorPalette }; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/constants.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/constants.js new file mode 100644 index 00000000..5990362b --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/constants.js @@ -0,0 +1,537 @@ +/** + * Constants for CSS Panels Extension + * Central repository for all magic numbers and strings used across the extension + */ + +// ============================================================================ +// TIMING CONSTANTS +// ============================================================================ + +/** + * Timing constants for delays, debouncing, and polling intervals + * @type {Object} + */ +const TIMING = { + // Mainloop timeout delays (milliseconds) + DEBOUNCE_SHORT: 50, // Used for: Quick UI updates, tooltip styling, OSD styling, panel refresh + DEBOUNCE_MEDIUM: 100, // Used for: Panel style application, tooltip cleanup, OSD cleanup + DEBOUNCE_LONG: 200, // Used for: Panel size change detection (radius detection) + DEBOUNCE_PANEL_MONITORING: 500, // Used for: Panel monitoring debouncing on settings change + KEY_TRIGGER_THROTTLE: 500, // Used for: OSD key event throttling (volume/brightness) + + // Polling intervals (milliseconds) + POLL_PANELS_LONG: 10000, // Used for: Fallback panel monitoring when signal not available + + // Cache timeouts (milliseconds) + CACHE_PANEL_CHECK: 5000, // Used for: Panel reference caching in panelStyler + CACHE_THEME_PROPERTIES: 30000, // Used for: Theme properties caching in themeDetector + + // Transition durations (seconds) + TRANSITION_CSS_DEFAULT: 0.3, // Used for: Default CSS transition timing + FADE_OUT_DURATION: 150, // Used for: Element fade-out animation (milliseconds) + + // Wallpaper monitoring (Phase 2.5C) + WALLPAPER_DEBOUNCE: 1000, // Used for: Debouncing wallpaper change detection to prevent rapid-fire triggers +}; + +// ============================================================================ +// DEPTH AND TRAVERSAL LIMITS +// ============================================================================ + +/** + * Maximum traversal depths for DOM/actor tree searches + * Prevents infinite loops and excessive recursion + * @type {Object} + */ +const TRAVERSAL = { + MAX_DEPTH_PANEL: 10, // Used for: Checking if element is in panel (popupStyler, tooltipStyler) + MAX_DEPTH_DESKTOP: 5, // Used for: Desktop area detection in nemoPopupStyler + MAX_DEPTH_NOTIFICATION: 8, // Used for: Notification actor tree search depth limit +}; + +// ============================================================================ +// SIZE CONSTRAINTS +// ============================================================================ + +/** + * Size constraints for element detection and validation + * @type {Object} + */ +const SIZE = { + // OSD element size detection (pixels) + OSD_MIN_WIDTH: 50, // Minimum width for OSD element detection + OSD_MAX_WIDTH: 800, // Maximum width for OSD element detection + OSD_MIN_HEIGHT: 20, // Minimum height for OSD element detection + OSD_MAX_HEIGHT: 400, // Maximum height for OSD element detection + + // Notification element size detection (pixels) + NOTIFICATION_MIN_WIDTH_BASIC: 50, // Basic notification minimum width check + NOTIFICATION_MIN_HEIGHT_BASIC: 30, // Basic notification minimum height check + NOTIFICATION_MAX_WIDTH_BASIC: 800, // Basic notification maximum width check + NOTIFICATION_MAX_HEIGHT_BASIC: 400, // Basic notification maximum height check + NOTIFICATION_MIN_WIDTH: 150, // Typical notification minimum width + NOTIFICATION_MAX_WIDTH: 700, // Typical notification maximum width + NOTIFICATION_MIN_HEIGHT: 50, // Typical notification minimum height + NOTIFICATION_MAX_HEIGHT: 400, // Typical notification maximum height + + // Icon sizes (pixels) + SYSTEM_INDICATOR_ICON_SIZE: 16, // System tray indicator icon size +}; + +// ============================================================================ +// STYLING VALUES +// ============================================================================ + +/** + * Styling values for blur effects, opacity, and visual properties + * @type {Object} + */ +const STYLING = { + // Default blur settings + DEFAULT_BLUR_RADIUS: 20, // Default blur radius in pixels + DEFAULT_PANEL_OPACITY: 0.4, // Default panel opacity (0-1) + DEFAULT_MENU_OPACITY: 0.5, // Default menu/popup opacity (0-1) + DEFAULT_BORDER_RADIUS: 15, // Default border radius in pixels + + // Blur adjustments + BLUR_ADJUSTMENT_MENU: 0.9, // Blur radius multiplier for menu elements + BLUR_ADJUSTMENT_OSD: 1.0, // Blur radius multiplier for OSD elements + BLUR_ADJUSTMENT_TOOLTIP: 0.7, // Blur radius multiplier for tooltip elements + BLUR_ADJUSTMENT_ALTTAB: 1.0, // Blur radius multiplier for Alt-Tab switcher + + // Border radius adjustments + BORDER_ADJUSTMENT_MENU: 1.0, // Border radius multiplier for menu elements + BORDER_ADJUSTMENT_OSD: 1.0, // Border radius multiplier for OSD elements + BORDER_ADJUSTMENT_TOOLTIP: 0.8, // Border radius multiplier for tooltip elements + BORDER_ADJUSTMENT_ALTTAB: 1.0, // Border radius multiplier for Alt-Tab switcher + + // Color adjustments + COLOR_DARKEN_AMOUNT: 10, // RGB value reduction for darkening colors (used in OSD) + + // Opacity values + ICON_OPACITY_NORMAL: 200, // Normal icon opacity (0-255) for system indicator + ICON_OPACITY_HOVER: 255, // Hover icon opacity (0-255) for system indicator + + // Phase 2.5D: Dynamic Shadow System (replaces hardcoded values above) + SHADOW_BASE_MULTIPLIER: 30, // Base shadow blur multiplier (shadowSpread * 30 = blur in px) + SHADOW_INSET_MULTIPLIER: 1.25, // Inset glow blur multiplier (25% stronger than outer) + SHADOW_VERTICAL_OFFSET: 3, // Vertical offset for outer shadow (px) + SUBMENU_MARGIN_OFFSET: 3, // Additional symmetric margin for sub-menu sides (px) + + // Shadow blur ratio multipliers for element types (Phase 2.5D) + SHADOW_BLUR_MULTIPLIERS: { + panel: 1.0, // Primary element - full shadow (e.g., 12px @ 0.4 spread) + popup: 0.8, // Secondary elements - reduced shadow (e.g., 9.6px @ 0.4) + notification: 1.2, // Notifications - enhanced shadow (e.g., 14.4px @ 0.4) + osd: 0.9, // OSD elements - slightly reduced (e.g., 10.8px @ 0.4) + tooltip: 0.7, // Tooltips - subtle shadow (e.g., 8.4px @ 0.4) + alttab: 1.1, // Alt-Tab switcher - prominent shadow (e.g., 13.2px @ 0.4) + desklet: 0.9, // Desktop widgets - slightly reduced shadow (e.g., 10.8px @ 0.4) + }, + + // Alt-Tab specific shadows + ALTTAB_SHADOW_BLUR: 24, // Alt-Tab switcher shadow blur radius + ALTTAB_THUMBNAIL_SHADOW_BLUR: 16, // Alt-Tab thumbnail shadow blur radius + ALTTAB_TITLE_SHADOW_BLUR: 16, // Alt-Tab window title shadow blur radius + ALTTAB_TITLE_SHADOW_OPACITY: 0.3, // Alt-Tab window title shadow opacity + + // Notification specific shadows + NOTIFICATION_SHADOW_OUTER_BLUR: 48, // Notification outer shadow blur radius + NOTIFICATION_SHADOW_OUTER_OFFSET: 12, // Notification outer shadow vertical offset + NOTIFICATION_SHADOW_OUTER_OPACITY: 0.4, // Notification outer shadow opacity + NOTIFICATION_SHADOW_INNER_BLUR: 12, // Notification secondary shadow blur radius + NOTIFICATION_SHADOW_INNER_OFFSET: 4, // Notification secondary shadow vertical offset + NOTIFICATION_SHADOW_INNER_OPACITY: 0.2, // Notification secondary shadow opacity + NOTIFICATION_SHADOW_HIGHLIGHT_OFFSET: 2, // Notification inset highlight offset + NOTIFICATION_SHADOW_HIGHLIGHT_OPACITY: 0.1, // Notification inset highlight opacity + NOTIFICATION_TRANSITION_DURATION: 0.3, // Notification transition duration (seconds) + NOTIFICATION_TRANSITION_CUBIC_BEZIER: "cubic-bezier(0.4, 0, 0.2, 1)", // Notification easing function + + // Notification positioning constraints + NOTIFICATION_POSITION_TOP_OFFSET: 5, // Minimum Y position for top notifications + NOTIFICATION_POSITION_TOP_RIGHT_MAX_Y: 300, // Maximum Y for top-right notifications + NOTIFICATION_POSITION_TOP_CENTER_MAX_Y: 200, // Maximum Y for top-center notifications + + // Adaptive blur brightness thresholds + BRIGHTNESS_THRESHOLD_LIGHT: 150, // RGB brightness threshold for light backgrounds + BRIGHTNESS_THRESHOLD_DARK: 80, // RGB brightness threshold for dark backgrounds + ADAPTIVE_BLUR_MULTIPLIER_LIGHT: 1.3, // Blur multiplier for light backgrounds + ADAPTIVE_BLUR_MULTIPLIER_DARK: 0.8, // Blur multiplier for dark backgrounds + ADAPTIVE_BLUR_MAX: 25, // Maximum adaptive blur radius + ADAPTIVE_BLUR_MIN: 5, // Minimum adaptive blur radius + + // CSS filter values for advanced effects + FILTER_SATURATE_MULTIPLIER: 150, // Saturation percentage for CSS.supports test + FILTER_CONTRAST_MULTIPLIER: 120, // Contrast percentage for CSS.supports test + FILTER_BRIGHTNESS_MULTIPLIER: 110, // Brightness percentage for CSS.supports test + + // Phase 2.5E: Independent Inset Glow System (11. studenoga 2025.) + // User-controlled inset glow independent of physical borders + // Inspired by savjs/index.html demo full customization + INSET_GLOW_DEFAULT_ENABLED: false, // Default: OFF (user opts-in) + INSET_GLOW_BLUR_MIN: 4, // Minimum glow blur (4px) + INSET_GLOW_BLUR_MAX: 40, // Maximum glow blur (40px - matches demo) + INSET_GLOW_BLUR_DEFAULT: 20, // Default blur size (20px - balanced) + INSET_GLOW_BORDER_MULTIPLIER: 4, // borderWidth × 4 = adaptive minimum + INSET_GLOW_INTENSITY_MIN: 0.05, // Minimum intensity (very subtle) + INSET_GLOW_INTENSITY_MAX: 0.5, // Maximum intensity (strong) + INSET_GLOW_INTENSITY_DEFAULT: 0.15, // Default intensity (matches demo) + INSET_GLOW_FALLBACK_COLOR: "rgba(255, 255, 255", // Fallback when no color set +}; + +// ============================================================================ +// VERSION CONSTANTS +// ============================================================================ + +/** + * Version thresholds for feature detection + * @type {Object} + */ +const VERSION = { + CINNAMON_MIN_BACKDROP_FILTER: 5.0, // Minimum Cinnamon version assumed to support backdrop-filter + CINNAMON_DEFAULT_VERSION: 6.0, // Default Cinnamon version for fallback detection +}; + +// ============================================================================ +// CSS CLASS NAMES +// ============================================================================ + +/** + * CSS class names used for styling throughout the extension + * @type {Object} + */ +const CSS_CLASSES = { + // Base styling classes + FADE_OUT: "transparency-fade-out", // Used for: Fade-out animation on element removal + PERSISTENT_OVERLAY: "transparency-persistent-overlay", // Used for: Persistent overlay effects + CUSTOM_PROFILE: "profile-custom", // Used for: Custom profile styling + FALLBACK_BLUR: "transparency-fallback-blur", // Used for: Fallback blur when backdrop-filter not supported + + // Component-specific classes + TOOLTIP_BLUR: "transparency-tooltip-blur", // Used for: Tooltip blur effect + OSD_BLUR: "osd-blur", // Used for: OSD blur effect + ALTTAB_BLUR: "transparency-alttab-blur", // Used for: Alt-Tab switcher blur effect + NOTIFICATION_BLUR: "transparency-notification-blur", // Used for: Notification blur effect + + // Panel classes (for detection) + PANEL: "panel", // Used for: Panel element detection + PANEL_BUTTON: "panel-button", // Used for: Panel button detection + APPLET_BOX: "applet-box", // Used for: Applet container detection + + // Switcher classes (for detection) + SWITCHER_LIST: "switcher-list", // Used for: Alt-Tab switcher list detection + SWITCHER_LIST_ITEM: "switcher-list-item", // Used for: Alt-Tab switcher item detection (to exclude) + + // Desktop classes (for detection) + DESKTOP: "desktop", // Used for: Desktop element detection + NEMO_DESKTOP: "nemo-desktop", // Used for: Nemo file manager desktop detection + NAUTILUS_DESKTOP: "nautilus-desktop", // Used for: Nautilus file manager desktop detection + CAJA_DESKTOP: "caja-desktop", // Used for: Caja file manager desktop detection + + // Tooltip class (for detection) + TOOLTIP: "tooltip", // Used for: Tooltip element detection + + // menu@cinnamon.org applet classes (for sidebar detection and styling) + APPMENU_SIDEBAR: "appmenu-sidebar", // Used for: Left sidebar in menu@cinnamon.org applet + APPMENU_MAIN_BOX: "appmenu-main-box", // Used for: Main horizontal container in menu@cinnamon.org + APPMENU_BACKGROUND: "appmenu-background", // Used for: Root menu actor class in menu@cinnamon.org +}; + +// ============================================================================ +// SIGNAL AND EVENT NAMES +// ============================================================================ + +/** + * Signal and event names for Cinnamon/GTK connections + * @type {Object} + */ +const SIGNALS = { + ACTOR_ADDED: "actor-added", // Used for: Stage monitoring for new actors (OSD, tooltip) + BUTTON_PRESS_EVENT: "button-press-event", // Used for: Desktop right-click detection + THEME_CHANGED: "theme-changed", // Used for: Theme change monitoring + ALLOCATION_CHANGED: "allocation-changed", // Used for: Panel size change monitoring + PANELS_ENABLED_CHANGED: "changed::panels-enabled", // Used for: Panel configuration change monitoring + ACCELERATOR_ACTIVATED: "accelerator-activated", // Used for: Keyboard shortcut monitoring (OSD) +}; + +// ============================================================================ +// SETTINGS KEYS +// ============================================================================ + +/** + * Settings schema keys from settings-schema.json + * @type {Object} + */ +const SETTINGS_KEYS = { + // Basic transparency + PANEL_OPACITY: "panel-opacity", + MENU_OPACITY: "menu-opacity", + BORDER_RADIUS: "border-radius", + AUTO_DETECT_RADIUS: "auto-detect-radius", + APPLY_PANEL_RADIUS: "apply-panel-radius", + + // Color overrides + OVERRIDE_PANEL_COLOR: "override-panel-color", + CHOOSE_OVERRIDE_PANEL_COLOR: "choose-override-panel-color", + OVERRIDE_POPUP_COLOR: "override-popup-color", + CHOOSE_OVERRIDE_POPUP_COLOR: "choose-override-popup-color", + + // Feature toggles + ENABLE_NOTIFICATION_STYLING: "enable-notification-styling", + ENABLE_OSD_STYLING: "enable-osd-styling", + ENABLE_TOOLTIP_STYLING: "enable-tooltip-styling", + ENABLE_ALTTAB_STYLING: "enable-alttab-styling", + ENABLE_APPMENU_SIDEBAR_STYLING: "enable-appmenu-sidebar-styling", + ENABLE_DESKTOP_CONTEXT_STYLING: "enable-desktop-context-styling", + + // System + HIDE_TRAY_ICON: "hide-tray-icon", + DEBUG_LOGGING: "debug-logging", + DARK_LIGHT_OVERRIDE: "dark-light-override", + + // Blur settings + BLUR_RADIUS: "blur-radius", + BLUR_SATURATE: "blur-saturate", + BLUR_CONTRAST: "blur-contrast", + BLUR_BRIGHTNESS: "blur-brightness", + BLUR_BACKGROUND: "blur-background", + BLUR_BORDER_COLOR: "blur-border-color", + BLUR_BORDER_WIDTH: "blur-border-width", + BLUR_TRANSITION: "blur-transition", + BLUR_OPACITY: "blur-opacity", + BLUR_TEMPLATE: "blur-template", + + // Cinnamon system settings + ALTTAB_SWITCHER_STYLE: "alttab-switcher-style", // Cinnamon org.cinnamon schema setting +}; + +// ============================================================================ +// ACTOR ACTION NAMES +// ============================================================================ + +/** + * Action names for Cinnamon actor events + * @type {Object} + */ +const ACTIONS = { + VOLUME: "volume", // Used for: Volume change OSD detection + BRIGHTNESS: "brightness", // Used for: Brightness change OSD detection +}; + +// ============================================================================ +// DEFAULT COLOR VALUES +// ============================================================================ + +/** + * Default color values used throughout the extension + * @type {Object} + */ +const COLORS = { + // Panel defaults + DEFAULT_PANEL_COLOR: "rgba(46, 52, 64, 0.8)", // Default panel color override + DEFAULT_POPUP_COLOR: "rgba(255, 255, 255, 0.9)", // Default popup color override + + // Blur template default + DEFAULT_BLUR_TEMPLATE: "frosted-glass-dark", // Default blur template name + + // Default blur parameters + DEFAULT_BLUR_SATURATE: 1.0, // Default saturation multiplier + DEFAULT_BLUR_CONTRAST: 0.9, // Default contrast multiplier + DEFAULT_BLUR_BRIGHTNESS: 0.9, // Default brightness multiplier + DEFAULT_BLUR_BACKGROUND: "rgba(255, 255, 255, 0.3)", // Default background overlay color + DEFAULT_BLUR_BORDER_COLOR: "rgba(255, 255, 255, 0.3)", // Default border color + DEFAULT_BLUR_BORDER_WIDTH: 0, // Default border width (pixels) - DEPRECATED: glow effect replaces borders + DEFAULT_BLUR_OPACITY: 0.8, // Default blur layer opacity + + // Fallback colors + FALLBACK_BORDER_COLOR: "rgba(255, 255, 255, 0.1)", // Fallback border when setting undefined +}; + +// ============================================================================ +// DEFAULT COLORS (magic numbers) +// ============================================================================ + +/** + * Hardcoded color values that appear throughout the codebase + * These are the magic numbers that should ideally be imported from here + * @type {Object} + */ +const DEFAULT_COLORS = { + // Text colors for auto-generated foreground (themeUtils.js lines 145, 147) + FOREGROUND_LIGHT: [250, 250, 250], // Light text on dark background + FOREGROUND_DARK: [5, 5, 5], // Dark text on light background + HIGH_CONTRAST_WHITE: [255, 255, 255], // High contrast white + HIGH_CONTRAST_BLACK: [0, 0, 0], // High contrast black + + // Fallback colors (themeDetector.js) + FALLBACK_GREY: { r: 128, g: 128, b: 128 }, // Generic fallback + FALLBACK_DARK: { r: 50, g: 50, b: 50 }, // Dark fallback + MINT_Y_DARK_FALLBACK: { r: 46, g: 46, b: 51 }, // Mint-Y-Dark panel + SIDEBAR_LIGHT_FALLBACK: { r: 245, g: 245, b: 245 }, // Light theme sidebar fallback + NORD_PANEL_COLOR: { r: 46, g: 52, b: 64 }, // Nord theme panel + DEFAULT_ACCENT: { r: 136, g: 192, b: 208 }, // Default accent color from extension.js + + // Notification color adjustment (notificationStyler.js line 479) + NOTIFICATION_LIGHTEN_AMOUNT: 10, // RGB increment for notification lightening +}; + +// ============================================================================ +// THEME UTILS COLOR MATHEMATICS +// ============================================================================ + +/** + * Constants for color mathematics and theme detection + * Used by ThemeUtils module for advanced color operations + * @type {Object} + */ +const THEME_UTILS = { + // HSP brightness thresholds + HSP_DARK_THRESHOLD: 127.5, // Threshold for dark/light theme detection (0-255) + + // Auto-generated color intensities + AUTO_HIGHLIGHT_INTENSITY: 0.3, // Default intensity for auto-highlight colors (0-1) + + // WCAG contrast ratios + MIN_CONTRAST_RATIO: { + AA: 4.5, // WCAG AA standard (normal text) + AA_LARGE: 3.0, // WCAG AA for large text + AAA: 7.0, // WCAG AAA standard (enhanced) + AAA_LARGE: 4.5, // WCAG AAA for large text + }, + + // Contrast adjustment parameters + CONTRAST_ADJUSTMENT_STEP: 0.05, // Step size for contrast adjustment iterations (0-1) +}; + +// ============================================================================ +// SYSTEM INDICATOR +// ============================================================================ + +/** + * System indicator constants + * @type {Object} + */ +const SYSTEM_INDICATOR = { + ICON_NAME: "applications-graphics-symbolic", // Icon used for system tray indicator + PADDING_STYLE: "padding-left: 5px; padding-right: 5px;", // Inline style for indicator button + TOOLTIP_OFFSET: 5, // Vertical offset for tooltip positioning (pixels) +}; + +// ============================================================================ +// TIMESTAMP FORMATTING +// ============================================================================ + +/** + * Timestamp formatting constants + * @type {Object} + */ +const TIMESTAMP = { + ISO_TIME_START: 11, // Start index for HH:MM:SS in ISO string + ISO_TIME_END: 19, // End index for HH:MM:SS in ISO string +}; + +// ============================================================================ +// HOVER / ACTIVE COLOR OVERRIDE +// ============================================================================ + +/** + * Constants for hover and active (click) color override on panel/popup elements + * Used by HoverStyleManager to generate a dynamic CSS stylesheet with concrete rgba values + * @type {Object} + */ +const HOVER = { + // Intensity passed to getAutoHighlightColor() for hover state (subtle) + HOVER_INTENSITY: 0.3, + // Intensity passed to getAutoHighlightColor() for active/click state (more visible) + ACTIVE_INTENSITY: 0.5, + // Alpha for inline set_style() hover color override + HOVER_ALPHA: 0.5, + // Filename of the dynamically generated hover CSS file (placed next to stylesheet.css) + HOVER_STYLESHEET_FILENAME: 'hover-override.css', + // CSS selectors for panel elements that use native CSS :hover pseudo-class + PANEL_HOVER_SELECTORS: [ + '.applet-box', + '.panel-launchers .launcher', + '.window-list-item-box', + '.grouped-window-list-item-box', + '.workspace-button', + '.workspace-button:outlined', + ], + // CSS selectors for popup menu items (Cinnamon maps hover → :active via JS) + POPUP_HOVER_SELECTORS: [ + '.popup-menu-item', + '.popup-sub-menu .popup-menu-item', + '.popup-alternating-menu-item', + ], + // CSS selectors for miscellaneous UI elements with hover states + MISC_HOVER_SELECTORS: [ + '.notification-icon-button', + '.notification-button', + '.sound-player StButton', + ], + // Special panel dummy selector that uses :entered pseudo-class (set via JS in panel.js) + PANEL_ENTERED_SELECTOR: '.panel-dummy:entered', +}; + +// ============================================================================ +// WALLPAPER COLOR EXTRACTION +// ============================================================================ + +/** + * Constants for wallpaper color extraction and palette analysis + * Phase 3: ColorPalette integration + * @type {Object} + */ +const WALLPAPER_COLORS = { + // Image analysis settings + COLOR_ANALYSIS_MAX_DIMENSION: 800, // Max px for pixbuf resize (memory management) + COLOR_ANALYSIS_TARGET_SAMPLES: 10000, // Target pixel sample count + COLOR_MIN_SATURATION_DELTA: 30, // Min RGB delta to avoid grayscale pixels + COLOR_QUANTIZATION_STEP: 16, // Quantization step for color clustering + + // Popup secondary color validation + POPUP_MIN_CONTRAST_RATIO: 3.0, // Min contrast ratio before fallback + POPUP_SHADE_FALLBACK_DARK: 0.20, // colorShade factor for dark theme fallback + POPUP_SHADE_FALLBACK_LIGHT: -0.20, // colorShade factor for light theme fallback + + // Brightness thresholds per theme mode (HSP range for accepted pixels) + BRIGHTNESS_THRESHOLDS: { + dark: { min: 30, max: 180 }, // Prefer darker tones for dark themes + light: { min: 80, max: 230 }, // Prefer lighter tones for light themes + }, + + // Dark/Light shade adjustment for dominant (panel) color + PANEL_SHADE_DARK: -0.30, // Darken dominant 30% for dark theme (white text readability) + PANEL_SHADE_LIGHT: 0.20, // Lighten dominant 20% for light theme (black text readability) + + // Target HSL lightness (%) when boosting a too-dark accent to pass validation threshold + ACCENT_BOOST_TARGET_LIGHTNESS: 38, + + // Polar extraction parameters for wallpaper-based panel tinting + POLAR_PERCENTILE: 0.25, // fraction of pixels taken from dark/light extreme + CONTRAST_SHADE_DARK: -0.15, // shade factor for polar panel tone (dark mode) + CONTRAST_SHADE_LIGHT: 0.15, // shade factor for polar panel tone (light mode) +}; + +// ============================================================================ +// EXPORT MODULE +// ============================================================================ + +module.exports = { + TIMING, + TRAVERSAL, + SIZE, + STYLING, + VERSION, + CSS_CLASSES, + SIGNALS, + SETTINGS_KEYS, + ACTIONS, + COLORS, + DEFAULT_COLORS, + THEME_UTILS, + SYSTEM_INDICATOR, + TIMESTAMP, + HOVER, + WALLPAPER_COLORS, +}; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/cssManager.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/cssManager.js new file mode 100644 index 00000000..ee3c422e --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/cssManager.js @@ -0,0 +1,398 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const GLib = imports.gi.GLib; +const { VERSION, STYLING } = require("./constants"); + +/** + * CSS Manager handles all CSS variable management and styling system + * Manages CSS custom properties and browser capability detection + */ +class CSSManager { + /** + * Initialize CSS Manager + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + this.extension = extension; + this.cssVariables = new Map(); + this.hasBackdropFilter = false; + this.hasAdvancedFilters = false; + } + + /** + * Initialize the CSS system with capability detection + */ + initialize() { + this.extension.debugLog("Initializing CSS system..."); + + this.hasBackdropFilter = this.detectBackdropFilterSupport(); + this.hasAdvancedFilters = this.detectAdvancedFilterSupport(); + + this.extension.debugLog(`Backdrop filter support: ${this.hasBackdropFilter}`); + this.extension.debugLog(`Advanced filters support: ${this.hasAdvancedFilters}`); + + if (!this.hasBackdropFilter) { + this.extension.debugLog("Using fallback blur simulation"); + this.enableFallbackMode(); + } + + this.updateAllVariables(); + this.extension.debugLog("CSS system initialized successfully"); + } + + /** + * Detect basic backdrop-filter support + * @returns {boolean} True if backdrop-filter is supported + */ + detectBackdropFilterSupport() { + try { + if (typeof CSS !== "undefined" && CSS.supports) { + let basicSupport = + CSS.supports("backdrop-filter", "blur(10px)") || + CSS.supports("-webkit-backdrop-filter", "blur(10px)"); + + this.extension.debugLog(`CSS.supports backdrop-filter: ${basicSupport}`); + if (basicSupport) return true; + } + + return this.manualBackdropFilterTest(); + } catch (e) { + this.extension.debugLog("Error detecting backdrop-filter support:", e.message); + return this.manualBackdropFilterTest(); + } + } + + /** + * Manual test for backdrop-filter support + * @returns {boolean} True if backdrop-filter works + */ + manualBackdropFilterTest() { + try { + let testElement = new St.Bin({ + style: "backdrop-filter: blur(1px); -webkit-backdrop-filter: blur(1px);", + }); + + let computedStyle = testElement.get_style(); + let hasBackdrop = + computedStyle && + (computedStyle.indexOf("backdrop-filter") !== -1 || + computedStyle.indexOf("-webkit-backdrop-filter") !== -1); + + testElement.destroy(); + + this.extension.debugLog(`Manual backdrop-filter test result: ${hasBackdrop}`); + return hasBackdrop; + } catch (e) { + this.extension.debugLog("Manual backdrop-filter test failed:", e); + + // Final fallback - assume modern Cinnamon versions support it + let cinnamonVersion = this.getCinnamonVersion(); + let supportsBackdrop = cinnamonVersion >= VERSION.CINNAMON_MIN_BACKDROP_FILTER; + + this.extension.debugLog(`Fallback: Cinnamon ${cinnamonVersion} backdrop support: ${supportsBackdrop}`); + return supportsBackdrop; + } + } + + /** + * Get current Cinnamon version + * @returns {number} Cinnamon version number + */ + getCinnamonVersion() { + try { + if (typeof imports.misc.config !== "undefined") { + let version = imports.misc.config.PACKAGE_VERSION; + return parseFloat(version); + } + + if (Main.cinnamonVersion) { + return parseFloat(Main.cinnamonVersion); + } + + return VERSION.CINNAMON_DEFAULT_VERSION; + } catch (e) { + this.extension.debugLog("Could not detect Cinnamon version:", e.message); + return VERSION.CINNAMON_DEFAULT_VERSION; + } + } + /** + * Detect advanced backdrop-filter support + * @returns {boolean} True if advanced filters are supported + */ + detectAdvancedFilterSupport() { + if (!this.hasBackdropFilter) return false; + + try { + if (typeof CSS !== "undefined" && CSS.supports) { + let advancedSupport = + CSS.supports("backdrop-filter", `blur(10px) saturate(${STYLING.FILTER_SATURATE_MULTIPLIER}%)`) && + CSS.supports("backdrop-filter", `contrast(${STYLING.FILTER_CONTRAST_MULTIPLIER}%)`) && + CSS.supports("backdrop-filter", `brightness(${STYLING.FILTER_BRIGHTNESS_MULTIPLIER}%)`); + + if (advancedSupport) { + this.extension.debugLog("Advanced backdrop-filter effects are supported"); + return true; + } + + this.extension.debugLog("Advanced filter CSS.supports failed, using backdrop-filter fallback"); + return this.hasBackdropFilter; + } + } catch (e) { + this.extension.debugLog("Error detecting advanced filter support:", e.message); + } + + return this.hasBackdropFilter; + } + + /** + * Enable fallback mode for systems without backdrop-filter support + */ + enableFallbackMode() { + this.setCSSVariable("fallback-mode", "true"); + } + + /** + * Set a CSS custom property (variable) dynamically + * @param {string} name - The CSS variable name (without -- prefix) + * @param {string} value - The CSS variable value + */ + setCSSVariable(name, value) { + try { + // document.documentElement is not a real DOM in GJS; this is a no-op in Cinnamon + if (typeof document !== "undefined" && document.documentElement) { + document.documentElement.style.setProperty(`--${name}`, value); + } + + // _gtkThemeNode is a private Cinnamon API; guard ensures graceful fallback if absent + try { + if (Main.themeManager && Main.themeManager._gtkThemeNode) { + Main.themeManager._gtkThemeNode.set_property(`--${name}`, value); + } + } catch (e) { + // Silent fail for theme manager - not critical + } + + this.cssVariables.set(name, value); + } catch (e) { + this.extension.debugLog("Failed to set CSS variable:", e.message); + } + } + + /** + * Update all CSS variables based on current settings + */ + updateAllVariables() { + try { + let panelColor = this.extension.themeDetector.getPanelBaseColor(); + let effectiveBorderRadius = this.getEffectiveBorderRadius(); + + // Panel variables + this.setCSSVariable("panel-radius", `${this.extension.applyPanelRadius ? effectiveBorderRadius : 0}px`); + this.setCSSVariable("panel-opacity", this.extension.panelOpacity.toString()); + this.setCSSVariable("panel-bg-rgb", `${panelColor.r}, ${panelColor.g}, ${panelColor.b}`); + + // Blur variables + this.setCSSVariable("blur-radius", `${this.extension.blurRadius}px`); + this.setCSSVariable("blur-saturate", this.extension.blurSaturate.toString()); + this.setCSSVariable("blur-contrast", this.extension.blurContrast.toString()); + this.setCSSVariable("blur-brightness", this.extension.blurBrightness.toString()); + this.setCSSVariable("blur-background", this.extension.blurBackground); + this.setCSSVariable("blur-border-color", this.extension.blurBorderColor); + this.setCSSVariable("blur-border-width", `${this.extension.blurBorderWidth}px`); + this.setCSSVariable("blur-transition", `${Math.round(this.extension.blurTransition * 1000)}ms`); + + // Menu variables + this.setCSSVariable("menu-radius", `${effectiveBorderRadius}px`); + this.setCSSVariable("menu-opacity", this.extension.menuOpacity.toString()); + + // Determine popup/menu color based on override settings + let menuColor = this.extension.themeDetector.getEffectivePopupColor(); + this.setCSSVariable("menu-bg-rgb", `${menuColor.r}, ${menuColor.g}, ${menuColor.b}`); + + // Auto-generate highlight color for menu hover effects + let highlightColor = this.extension.themeDetector.getAutoHighlightColor(0.3); + this.setCSSVariable("menu-highlight-color", highlightColor); + + // Performance and capability variables + this.setCSSVariable("advanced-filters", this.hasAdvancedFilters ? "true" : "false"); + } catch (e) { + this.extension.debugLog("Error updating CSS variables:", e); + } + } + + /** + * Get effective border radius (auto-detected or manual) + * @returns {number} Effective border radius in pixels + */ + getEffectiveBorderRadius() { + let effectiveBorderRadius = this.extension.borderRadius; + + if (this.extension.autoDetectRadius) { + let detectedRadius = this.extension.themeDetector.detectThemeBorderRadius(); + if (detectedRadius !== this.extension.borderRadius && detectedRadius > 0) { + effectiveBorderRadius = detectedRadius; + this.extension.debugLog(`Using auto-detected border-radius: ${effectiveBorderRadius}px`); + } + } + + return effectiveBorderRadius; + } + + /** + * Get adaptive blur radius based on background content + * @returns {number} Optimized blur radius + */ + getAdaptiveBlurRadius() { + let baseRadius = this.extension.blurRadius; + let panelColor = this.extension.themeDetector.getPanelBaseColor(); + + let brightness = (panelColor.r + panelColor.g + panelColor.b) / 3; + + if (brightness > STYLING.BRIGHTNESS_THRESHOLD_LIGHT) { + return Math.min(baseRadius * STYLING.ADAPTIVE_BLUR_MULTIPLIER_LIGHT, STYLING.ADAPTIVE_BLUR_MAX); + } else if (brightness < STYLING.BRIGHTNESS_THRESHOLD_DARK) { + return Math.max(baseRadius * STYLING.ADAPTIVE_BLUR_MULTIPLIER_DARK, STYLING.ADAPTIVE_BLUR_MIN); + } + + return baseRadius; + } + + /** + * Cleanup the CSS system by clearing variables + */ + cleanup() { + try { + this.cssVariables.forEach((value, name) => { + if (typeof document !== "undefined" && document.documentElement) { + document.documentElement.style.removeProperty(`--${name}`); + } + }); + + this.cssVariables.clear(); + this.extension.debugLog("CSS system cleaned up"); + } catch (e) { + this.extension.debugLog("Error cleaning up CSS system:", e); + } + } + + /** + * Log detailed information about a Clutter actor for debugging purposes + * @param {Clutter.Actor} actor - The actor to inspect + * @param {number} depth - Current depth in the actor hierarchy + */ + logActorDetails(actor, depth) { + if (!actor) return; + + this.extension.debugLog( + "Actor details - name:", + actor.name, + "type:", + actor.constructor.name, + "parent:", + actor.get_parent ? actor.get_parent() : null, + "depth:", + depth + ); + } + + /** + * Debug utility: Inspect element structure and CSS properties + * @param {Clutter.Actor} element - Element to inspect + * @param {string} elementName - Name for logging purposes + * @param {number} maxDepth - Maximum depth to traverse (default: 3) + */ + inspectElement(element, elementName = "Element", maxDepth = 3) { + this.extension.debugLog(`=== INSPECTING ${elementName} ===`); + this._inspectElementRecursive(element, 0, maxDepth); + this.extension.debugLog(`=== END INSPECTION ${elementName} ===`); + } + + /** + * Recursive element inspection helper + * @param {Clutter.Actor} element - Current element + * @param {number} depth - Current depth level + * @param {number} maxDepth - Maximum depth to traverse + */ + _inspectElementRecursive(element, depth, maxDepth) { + if (!element || depth > maxDepth) return; + + const indent = " ".repeat(depth); + const elementType = element.constructor.name; + const visible = element.visible ? "visible" : "hidden"; + + try { + // Basic element info + this.extension.debugLog(`${indent}${elementType} (${visible})`); + + // CSS classes if available + if (element.get_style_class_name) { + const styleClasses = element.get_style_class_name(); + if (styleClasses) { + this.extension.debugLog(`${indent} CSS classes: ${styleClasses}`); + } + } + + // Current style if available + if (element.get_style) { + const style = element.get_style(); + if (style) { + this.extension.debugLog( + `${indent} Style: ${style.substring(0, 100)}${style.length > 100 ? "..." : ""}` + ); + } + } + + // Size and position + if (element.width !== undefined && element.height !== undefined) { + this.extension.debugLog(`${indent} Size: ${element.width}x${element.height}`); + } + + // Children count + if (element.get_children) { + const children = element.get_children(); + this.extension.debugLog(`${indent} Children: ${children.length}`); + + // Recurse into children + children.forEach((child, index) => { + this.extension.debugLog(`${indent} Child ${index}:`); + this._inspectElementRecursive(child, depth + 1, maxDepth); + }); + } + } catch (e) { + this.extension.debugLog(`${indent}Error inspecting element: ${e}`); + } + } + + // ===== THEMEUTILS INTEGRATION - NEW METHODS ===== + + /** + * Update highlight color CSS variable for menu hover effects + * Allows manual override of auto-generated highlight color + * + * @param {string} cssColor - CSS rgba string (e.g., "rgba(255, 255, 255, 0.15)") + */ + updateHighlightColor(cssColor) { + if (!cssColor) { + this.extension.debugLog("Warning: updateHighlightColor called with empty color"); + return; + } + + this.extension.debugLog(`Manually updating highlight color: ${cssColor}`); + this.setCSSVariable("menu-highlight-color", cssColor); + } + + /** + * Reset highlight color to auto-generated value + * Useful after manual override + * + * @param {number} intensity - Highlight intensity (0-1, default: 0.3) + */ + resetHighlightColor(intensity = 0.3) { + const highlightColor = this.extension.themeDetector.getAutoHighlightColor(intensity); + this.extension.debugLog(`Resetting highlight color to auto: ${highlightColor}`); + this.setCSSVariable("menu-highlight-color", highlightColor); + } + +} + +module.exports = CSSManager; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/deskletStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/deskletStyler.js new file mode 100644 index 00000000..bf753ef2 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/deskletStyler.js @@ -0,0 +1,244 @@ +const Main = imports.ui.main; +const Mainloop = imports.mainloop; +const DeskletManager = imports.ui.deskletManager; +const StylerBase = require("./stylerBase"); +const { TIMING } = require("./constants"); + +/** + * Desklet Styler applies CSS transparency/blur/glow effects to desktop widgets (desklets) + * Uses monkey patching to intercept DeskletManager creation and unload calls + */ +class DeskletStyler extends StylerBase { + /** + * Initialize Desklet Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "DeskletStyler"); + + // Track styled desklets with their original styles + this.activeDesklets = new Map(); + + // Store original methods for monkey patch restoration + this.originalCreateDesklets = null; + this.originalUnloadDesklet = null; + + // Store bound patched methods for idempotent restore + this._boundPatchedCreate = null; + this._boundPatchedUnload = null; + } + + /** + * Enable desklet styling via monkey patching DeskletManager + */ + enable() { + super.enable(); + + // Guard: skip if desklet styling is disabled in settings + if (!this.extension.enableDeskletStyling) return; + + // Save original methods before patching + this.originalCreateDesklets = DeskletManager._createDesklets; + this.originalUnloadDesklet = DeskletManager._unloadDesklet; + + // Patch _createDesklets using arrow closure (preserves 'this' reference cleanly) + this._boundPatchedCreate = (extension, deskletDef) => { + const desklet = this.originalCreateDesklets.call(DeskletManager, extension, deskletDef); + if (desklet) { + // Defer styling briefly to let the desklet actor fully initialize + Mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this._styleDesklet(desklet); + return false; // Remove timeout (one-shot) + }); + } + return desklet; + }; + DeskletManager._createDesklets = this._boundPatchedCreate; + + // Patch _unloadDesklet to restore styles before desklet is destroyed + this._boundPatchedUnload = (deskletDef, deleteConfig) => { + // Find and clean up the desklet before unloading + if (deskletDef && deskletDef.desklet && deskletDef.desklet.content && this.activeDesklets.has(deskletDef.desklet)) { + const originalData = this.activeDesklets.get(deskletDef.desklet); + try { + deskletDef.desklet.content.set_style(originalData.style); + } catch (e) { + this.debugLog("Error restoring desklet style on unload:", e); + } + this.activeDesklets.delete(deskletDef.desklet); + } + return this.originalUnloadDesklet.call(DeskletManager, deskletDef, deleteConfig); + }; + DeskletManager._unloadDesklet = this._boundPatchedUnload; + + // Style desklets that are already loaded + try { + // Access module-level definitions array directly — it holds live desklet instance references. + // getDefinitions() rebuilds from GSettings and returns fresh objects where def.desklet is null. + const definitions = DeskletManager.definitions || []; + definitions.forEach(def => { + if (def.desklet) { + this._styleDesklet(def.desklet); + } + }); + this.debugLog(`DeskletStyler enabled, styled ${this.activeDesklets.size} existing desklets`); + } catch (e) { + this.debugLog("Error styling existing desklets:", e); + } + } + + /** + * Disable desklet styling and restore all original states + */ + disable() { + this.debugLog("DeskletStyler: Starting disable cleanup"); + + // Restore _createDesklets idempotently (check we still own the patch) + if (this.originalCreateDesklets) { + if (DeskletManager._createDesklets === this._boundPatchedCreate) { + DeskletManager._createDesklets = this.originalCreateDesklets; + } + this.originalCreateDesklets = null; + this._boundPatchedCreate = null; + } + + // Restore _unloadDesklet idempotently + if (this.originalUnloadDesklet) { + if (DeskletManager._unloadDesklet === this._boundPatchedUnload) { + DeskletManager._unloadDesklet = this.originalUnloadDesklet; + } + this.originalUnloadDesklet = null; + this._boundPatchedUnload = null; + } + + // Restore all desklet styles + this._restoreAllDesklets(); + + this.debugLog("DeskletStyler: Disable cleanup completed"); + super.disable(); + } + + /** + * Refresh all currently styled desklets with updated CSS + */ + refresh() { + super.refresh(); + this.refreshAllDesklets(); + } + + /** + * Apply CSS styling to a single desklet + * Guards against invalid actors and double-styling + * @param {Object} desklet - Cinnamon desklet instance with an actor property + * @private + */ + _styleDesklet(desklet) { + if (!desklet || !desklet.actor || !desklet.content) return; + + // Skip if already tracked (already styled) + if (this.activeDesklets.has(desklet)) return; + + try { + // Save original inline style of content actor (theme styles via class, not inline) + const originalStyle = desklet.content.get_style() || ""; + + // Store in map before applying so destroy-cleanup can find it + this.activeDesklets.set(desklet, { style: originalStyle }); + + // Connect destroy signal for automatic map cleanup + this.addConnection(desklet.actor, "destroy", () => { + this.activeDesklets.delete(desklet); + }); + + // Build color/config from current extension settings + const deskletColor = this.extension.themeDetector.getEffectivePopupColor(); + const config = { + backgroundColor: `rgba(${deskletColor.r}, ${deskletColor.g}, ${deskletColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("desklet"), + blurRadius: this.getAdjustedBlurRadius("desklet"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager and apply + const css = this.extension.blurTemplateManager.generateDeskletCSS(config); + // Apply to content — carries .desklet class, sits above actor background + desklet.content.set_style(css); + + this.debugLog("Desklet styled via template generation"); + } catch (e) { + this.debugLog("Error styling desklet:", e); + // Remove from map on failure to keep state consistent + this.activeDesklets.delete(desklet); + } + } + + /** + * Restore all tracked desklets to their original inline styles + * @private + */ + _restoreAllDesklets() { + this.activeDesklets.forEach((originalData, desklet) => { + try { + if (desklet && desklet.content) { + desklet.content.set_style(originalData.style); + } + } catch (e) { + this.debugLog("Error restoring desklet style:", e); + } + }); + this.activeDesklets.clear(); + } + + /** + * Public alias for _restoreAllDesklets — restores all desklets to original styles + */ + restoreAllDesklets() { + this._restoreAllDesklets(); + } + + /** + * Re-apply current CSS to all tracked desklets (used after settings change) + * Removes each desklet from the map first so the guard in _styleDesklet passes + */ + refreshAllDesklets() { + this.debugLog("refreshing all desklets"); + + // Collect desklets to refresh (avoid mutating map while iterating) + const desklets = []; + this.activeDesklets.forEach((originalData, desklet) => { + desklets.push({ desklet, originalStyle: originalData.style }); + }); + + desklets.forEach(({ desklet, originalStyle }) => { + if (!desklet || !desklet.content) return; + + // Delete from map so _styleDesklet guard allows re-styling + // Preserve the original style value by re-setting it first + this.activeDesklets.delete(desklet); + + // Restore original before re-styling to keep originalStyle accurate + try { + desklet.content.set_style(originalStyle); + } catch (e) { + this.debugLog("Error pre-resetting desklet style during refresh:", e); + } + + // Re-apply updated styling (re-adds to activeDesklets) + this._styleDesklet(desklet); + + // Recover the true original style (set before the first styling) + const entry = this.activeDesklets.get(desklet); + if (entry) { + entry.style = originalStyle; + } + }); + } +} + +module.exports = DeskletStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/extension.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/extension.js new file mode 100644 index 00000000..b3e03d96 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/extension.js @@ -0,0 +1,1357 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const Settings = imports.ui.settings; +const StylerBase = require("./stylerBase"); +const Gettext = imports.gettext; +const GLib = imports.gi.GLib; +const { TIMING, STYLING, COLORS } = require("./constants"); +const { GlobalSignalsHandler } = require("./signalHandler"); + +// Import refactored modules +const PanelStyler = require("./panelStyler"); +const PopupStyler = require("./popupStyler"); +const NotificationStyler = require("./notificationStyler"); +const OSDStyler = require("./osdStyler"); +const NemoPopupStyler = require("./nemoPopupStyler"); +const TooltipStyler = require("./tooltipStyler"); +const AltTabStyler = require("./alttabStyler"); +const DeskletStyler = require("./deskletStyler"); +const SystemIndicator = require("./systemIndicator"); +const ThemeDetector = require("./themeDetector"); +const CSSManager = require("./cssManager"); +const BlurTemplateManager = require("./blurTemplateManager"); +const WallpaperMonitor = require("./wallpaperMonitor"); +const HoverStyleManager = require("./hoverStyleManager"); + +/** + * Main extension instance + * @type {CSSPanelsExtension} + */ +let cssPanelsExtension = null; + +/** + * Main extension class for CSS Panels transparency control + * Manages all transparency and blur effects for Cinnamon panels and UI elements + */ +class CSSPanelsExtension { + /** + * Constructor initializes all extension settings and default values + * @param {Object} metadata - Extension metadata from metadata.json + */ + constructor(metadata) { + this.metadata = metadata; + this._ = this.setupLocalization(metadata); + this.setupSettings(); + this.initializeComponents(); + + // Initialize panel monitoring variables + this._panelCheckTimeout = null; + this._debounceTimeout = null; + + // Initialize global signals handler for extension-level connections + this._signalsHandler = new GlobalSignalsHandler(); + + this.debugLog("CSSPanelsExtension initialized successfully"); + } + + /** + * Setup localization support + * @param {Object} metadata - Extension metadata + * @returns {Function} Translation function + */ + setupLocalization(metadata) { + Gettext.bindtextdomain(metadata.uuid, metadata.path + "/po"); + return function (str) { + return Gettext.dgettext(metadata.uuid, str) || str; + }; + } + + /** + * Initialize settings with proper bindings + */ + setupSettings() { + this.settings = new Settings.ExtensionSettings(this, this.metadata.uuid); + this.bindSettings(); + this.initializeDefaults(); + } + + /** + * Bind all settings to their respective callbacks + */ + bindSettings() { + // Basic transparency settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "panel-opacity", + "panelOpacity", + this.onPanelOpacityChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "menu-opacity", + "menuOpacity", + this.onMenuOpacityChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "border-radius", + "borderRadius", + this.onBorderRadiusChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "auto-detect-radius", + "autoDetectRadius", + this.onAutoDetectRadiusChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "apply-panel-radius", + "applyPanelRadius", + this.onPanelRadiusChanged.bind(this) + ); + + // Color override settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "override-panel-color", + "overridePanelColor", + this.onOverridePanelColorChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "choose-override-panel-color", + "chooseOverridePanelColor", + this.onChooseOverridePanelColorChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "override-popup-color", + "overridePopupColor", + this.onOverridePopupColorChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "choose-override-popup-color", + "chooseOverridePopupColor", + this.onChooseOverridePopupColorChanged.bind(this) + ); + + // Notification and OSD settings (NEW) + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-notification-styling", + "enableNotificationStyling", + this.onNotificationStylingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-osd-styling", + "enableOSDStyling", + this.onOSDStylingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-tooltip-styling", + "enableTooltipStyling", + this.onTooltipStylingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-alttab-styling", + "enableAltTabStyling", + this.onAltTabStylingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-desktop-context-styling", + "enableDesktopContextStyling", + this.onDesktopContextStylingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-desklet-styling", + "enableDeskletStyling", + this.onDeskletStylingChanged.bind(this) + ); + + // Blur effect settings + this.bindBlurSettings(); + + // System settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "hide-tray-icon", + "hideTrayIcon", + this.onHideTrayIconChanged.bind(this) + ); + this.settings.bindProperty(Settings.BindingDirection.IN, "debug-logging", "debugLogging", (value) => { + global.log(`[CSSPanels] Debug logging changed to: ${value}`); + this.onDebugLoggingChanged(); + }); + + // Phase 2.5B - Accent color detection settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "auto-apply-accent-on-theme-change", + "autoApplyAccentOnThemeChange", + this.onAutoApplyAccentChanged.bind(this) + ); + + this.settings.bindProperty( + Settings.BindingDirection.IN, + "accent-shadow-color", + "accentShadowColor", + null // Reserved for future use (Phase 2.5C+) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "shadow-spread", + "shadowSpread", + this.onBlurSettingsChanged.bind(this) // Phase 2.5D - Trigger UI refresh on change + ); + + // Glow effect settings (inset/outset/none) + this.settings.bindProperty( + Settings.BindingDirection.IN, + "glow-mode", + "glowMode", + this.onGlowSettingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "glow-blur", + "glowBlur", + this.onGlowSettingChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "glow-intensity", + "glowIntensity", + this.onGlowSettingChanged.bind(this) + ); + + // Phase 2.5C - Wallpaper detection settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "enable-wallpaper-detection", + "enableWallpaperDetection", + this.onWallpaperDetectionChanged.bind(this) + ); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "full-auto-mode", + "fullAutoMode", + this.onFullAutoModeChanged.bind(this) + ); + // Wallpaper extraction mode settings + this.settings.bindProperty( + Settings.BindingDirection.IN, + "wallpaper-color-strategy", "wallpaperColorStrategy", null); + this.settings.bindProperty( + Settings.BindingDirection.IN, + "dark-light-override", "darkLightOverride", null); + } + + /** + * Bind all blur-related settings + */ + bindBlurSettings() { + const blurSettings = [ + "blur-radius", + "blur-saturate", + "blur-contrast", + "blur-brightness", + "blur-background", + "blur-border-color", + "blur-transition", + "blur-opacity", + "blur-template", + ]; + + blurSettings.forEach((setting) => { + const property = setting.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase()); + const callback = + setting === "blur-opacity" + ? this.onBlurOpacityChanged.bind(this) + : setting === "blur-template" + ? this.onBlurTemplateChanged.bind(this) + : this.onBlurSettingsChanged.bind(this); + this.settings.bindProperty(Settings.BindingDirection.IN, setting, property, callback); + }); + } + + /** + * Initialize default values for all settings + */ + initializeDefaults() { + // Basic transparency defaults + if (this.panelOpacity === undefined) this.panelOpacity = STYLING.DEFAULT_PANEL_OPACITY; + if (this.menuOpacity === undefined) this.menuOpacity = STYLING.DEFAULT_MENU_OPACITY; + if (this.borderRadius === undefined) this.borderRadius = STYLING.DEFAULT_BORDER_RADIUS; + if (this.autoDetectRadius === undefined) this.autoDetectRadius = true; + if (this.applyPanelRadius === undefined) this.applyPanelRadius = true; + + // Color override defaults + if (this.overridePanelColor === undefined) this.overridePanelColor = false; + if (this.chooseOverridePanelColor === undefined) this.chooseOverridePanelColor = COLORS.DEFAULT_PANEL_COLOR; + if (this.overridePopupColor === undefined) this.overridePopupColor = false; + if (this.chooseOverridePopupColor === undefined) this.chooseOverridePopupColor = COLORS.DEFAULT_POPUP_COLOR; + + // NEW: Notification and OSD defaults + if (this.enableNotificationStyling === undefined) this.enableNotificationStyling = false; + if (this.enableOSDStyling === undefined) this.enableOSDStyling = false; + if (this.enableTooltipStyling === undefined) this.enableTooltipStyling = true; + if (this.enableAltTabStyling === undefined) this.enableAltTabStyling = false; + if (this.enableDesktopContextStyling === undefined) this.enableDesktopContextStyling = false; + if (this.enableDeskletStyling === undefined) this.enableDeskletStyling = false; + + // System defaults + if (this.hideTrayIcon === undefined) this.hideTrayIcon = true; + if (this.debugLogging === undefined) this.debugLogging = false; + + // Blur defaults + this.initializeBlurDefaults(); + } + + /** + * Initialize blur effect default values + */ + initializeBlurDefaults() { + if (this.blurRadius === undefined) this.blurRadius = STYLING.DEFAULT_BLUR_RADIUS; + if (this.blurSaturate === undefined) this.blurSaturate = COLORS.DEFAULT_BLUR_SATURATE; + if (this.blurContrast === undefined) this.blurContrast = COLORS.DEFAULT_BLUR_CONTRAST; + if (this.blurBrightness === undefined) this.blurBrightness = COLORS.DEFAULT_BLUR_BRIGHTNESS; + if (this.blurBackground === undefined) this.blurBackground = COLORS.DEFAULT_BLUR_BACKGROUND; + if (this.blurBorderColor === undefined) this.blurBorderColor = COLORS.DEFAULT_BLUR_BORDER_COLOR; + if (this.blurBorderWidth === undefined) this.blurBorderWidth = COLORS.DEFAULT_BLUR_BORDER_WIDTH; + if (this.blurTransition === undefined) this.blurTransition = TIMING.TRANSITION_CSS_DEFAULT; + if (this.blurOpacity === undefined) this.blurOpacity = COLORS.DEFAULT_BLUR_OPACITY; + if (this.blurTemplate === undefined) this.blurTemplate = COLORS.DEFAULT_BLUR_TEMPLATE; + } + + /** + * Initialize all component modules + */ + initializeComponents() { + this.cssManager = new CSSManager(this); + this.themeDetector = new ThemeDetector(this); + this.blurTemplateManager = new BlurTemplateManager(this); + this.wallpaperMonitor = new WallpaperMonitor(this); // Phase 2.5C + this.panelStyler = new PanelStyler(this); + this.popupStyler = new PopupStyler(this); + this.notificationStyler = new NotificationStyler(this); + this.osdStyler = new OSDStyler(this); + this.nemoPopupStyler = new NemoPopupStyler(this); + this.tooltipStyler = new TooltipStyler(this); + this.altTabStyler = new AltTabStyler(this); + this.deskletStyler = new DeskletStyler(this); + this.systemIndicator = new SystemIndicator(this); + this.hoverStyleManager = new HoverStyleManager(this); + this.panelMonitoringTimeout = null; + this.panelMonitoringConnection = null; + this.isEnabled = false; + } + + /** + * Debug logging function - only logs when debug logging is enabled + * @param {string} message - The message to log + * @param {any} data - Optional data to log + */ + debugLog(message, data = null) { + // Allow logging during disable/cleanup phase + const isCleanupMessage = + message.includes("Disabling") || + message.includes("Cleaning") || + message.includes("Restored") || + message.includes("disabled") || + message.includes("cleanup"); + + if (!this.isEnabled && !isCleanupMessage) return; // Suppress logs when disabled, except cleanup messages + if (this.debugLogging) { + const timestamp = new Date().toISOString().slice(11, 19); + if (data) { + global.log(`[CSSPanels] [${timestamp}] ${message}`, data); + } else { + global.log(`[CSSPanels] [${timestamp}] ${message}`); + } + } + } + /** + * Schedule refresh for all panels with a short delay + * Prevents multiple rapid refresh calls + */ + scheduleRefreshPanels() { + if (this._scheduleRefreshTimeout) { + imports.mainloop.source_remove(this._scheduleRefreshTimeout); + } + this._scheduleRefreshTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this.checkForNewPanels(); + this._scheduleRefreshTimeout = null; + return false; + }); + } + + /** + * Setup periodic panel monitoring + */ + setupPanelMonitoring() { + try { + this.debugLog("Setting up panel monitoring..."); + + // Use global.settings signal if available for tracking added/removed panels + if (global.settings && typeof global.settings.connect === "function") { + this._signalsHandler.add([ + global.settings, + "changed::panels-enabled", + () => { + this.debugLog("Panels-enabled setting changed - checking for new panels"); + // Implement debouncing to prevent frequent calls + if (this._debounceTimeout) { + imports.mainloop.source_remove(this._debounceTimeout); + } + this._debounceTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_PANEL_MONITORING, () => { + this.checkForNewPanels(); + this._debounceTimeout = null; + return false; + }); + }, + ]); + this.debugLog("Using global.settings panels-enabled signal for monitoring"); + return; // No need for polling if we have the signal + } + + // Fallback to longer polling interval (10 seconds) + this._panelCheckTimeout = imports.mainloop.timeout_add(TIMING.POLL_PANELS_LONG, () => { + this.checkForNewPanels(); + return true; // Continue the timeout + }); + + this.debugLog("Panel monitoring setup completed with polling"); + } catch (e) { + this.debugLog("Error setting up panel monitoring:", e); + } + } + + /** + * Check for new panels and apply styles if found + */ + checkForNewPanels() { + try { + let currentPanels = this.panelStyler.getAllPanels(); + let knownPanels = Object.keys(this.panelStyler.originalPanelStyles); + + // Check for any new panels + let newPanelsFound = false; + currentPanels.forEach((panelInfo) => { + if (!knownPanels.includes(panelInfo.id)) { + this.debugLog(`New panel detected: ${panelInfo.id}`); + newPanelsFound = true; + } + }); + + // If new panels are found, reapply styles + if (newPanelsFound) { + this.debugLog("Applying styles to new panels..."); + this._newPanelApplyTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_MEDIUM, () => { + this.panelStyler.applyPanelStyles(); + this._newPanelApplyTimeout = null; + return false; + }); + } + } catch (e) { + this.debugLog("Error checking for new panels:", e); + } + } + + /** + * Cleanup panel monitoring resources + */ + cleanupPanelMonitoring() { + try { + this.debugLog("Cleaning up panel monitoring..."); + + if (this._panelCheckTimeout) { + imports.mainloop.source_remove(this._panelCheckTimeout); + this._panelCheckTimeout = null; + this.debugLog("Removed panel check timeout"); + } + + // Cleanup debounce timeout if exists + if (this._debounceTimeout) { + imports.mainloop.source_remove(this._debounceTimeout); + this._debounceTimeout = null; + } + + // Signal handler cleanup is automatic via destroy() + this.debugLog("Panel monitoring cleanup completed"); + } catch (e) { + this.debugLog("Error cleaning up panel monitoring:", e); + } + } + /** + * Enable the extension and apply all styling + */ + enable() { + this.isEnabled = true; // Set flag early to prevent premature callback execution + + // Log extension startup info + const enabledFeatures = []; + if (this.enableTooltipStyling) enabledFeatures.push("Tooltip"); + if (this.enableAltTabStyling) enabledFeatures.push("Alt-Tab"); + if (this.enableNotificationStyling) enabledFeatures.push("Notification"); + if (this.enableOSDStyling) enabledFeatures.push("OSD"); + if (this.enableDesktopContextStyling) enabledFeatures.push("Desktop Context"); + if (this.enableDeskletStyling) enabledFeatures.push("Desklet"); + + global.log( + `[CSSPanels] Extension started - Theme: ${ + this.themeDetector.currentTheme || "Unknown" + }, Enabled features: Panel, Popup${enabledFeatures.length > 0 ? ", " + enabledFeatures.join(", ") : ""}` + ); + + this.debugLog("Enabling extension..."); + try { + this.cssManager.initialize(); + this.themeDetector.setup(); + + // NEW UNIFIED FLOW: Detect all theme properties and apply based on switches + const detectedThemeData = this.themeDetector.redetectAllThemeData(); + this.applyDetectedThemeData(detectedThemeData); + + this.cssManager.updateAllVariables(); // Update CSS variables after theme detection + this.panelStyler.safeEnable(); + this.popupStyler.safeEnable(); + this.hoverStyleManager.enable(); // Attach hover hooks after panel applets are in tree + + // Enable tooltip styling if enabled + if (this.enableTooltipStyling) { + this.tooltipStyler.safeEnable(); + } + + // Enable alttab styling if enabled + if (this.enableAltTabStyling) { + this.altTabStyler.safeEnable(); + } + + // Enable notification and OSD styling if enabled + if (this.enableNotificationStyling) { + this.notificationStyler.safeEnable(); + } + + if (this.enableOSDStyling) { + this.osdStyler.safeEnable(); + } + + if (this.enableDesktopContextStyling) { + this.nemoPopupStyler.safeEnable(); + } + + if (this.enableDeskletStyling) { + this.deskletStyler.safeEnable(); + } + + // Enable wallpaper monitoring if enabled (Phase 2.5C) + if (this.enableWallpaperDetection) { + this.wallpaperMonitor.enable(); + } + + // Create system indicator if enabled + if (!this.hideTrayIcon) { + this.systemIndicator.create(); + } + + // Setup panel monitoring + this.setupPanelMonitoring(); + + // Note: Theme properties already detected and applied above via redetectAllThemeData() + // No need for additional accent detection here + + this.forceSettingsUpdate(); + this.debugLog("Extension enabled successfully"); + } catch (error) { + this.debugLog("Error during enable:", error); + global.logError("[CSSPanels] Error in enable: " + error.message); + } + + // Return callbacks for external access + // Return callbacks for external access + return { + resetBlurToDefaults: () => { + global.logWarning("[CSSPanels] External resetBlurToDefaults called"); + this._resetBlurToDefaults(); + }, + applyDetectedAccent: () => { + global.logWarning("[CSSPanels] External applyDetectedAccent called"); + const detectedThemeData = this.themeDetector.redetectAllThemeData(); + this.applyDetectedThemeData(detectedThemeData); + // Button always populates pickers with accent shadow regardless of auto-apply toggle. + // redetectAllThemeData() skips accent when auto-apply is OFF, so detect explicitly. + let accentVariants = detectedThemeData.accentColor.variants; + if (!accentVariants) { + const accentColor = this.themeDetector.detectThemeAccentColor(); + if (accentColor) { + accentVariants = this.themeDetector.generateAccentSystem( + accentColor, + detectedThemeData.isDarkMode + ); + } + } + if (accentVariants) { + // Apply accent to blur effects (border, tint, shadow settings). + // applyDetectedThemeData() skipped this step because shouldApply is false; + // button always applies regardless of auto-apply toggle. + this.applyAccentSystemToBlurEffects(accentVariants); + this.settings.setValue("choose-override-panel-color", accentVariants.shadow); + this.settings.setValue("choose-override-popup-color", accentVariants.shadow); + this.debugLog(` ✓ override pickers set to accent shadow: ${accentVariants.shadow}`); + } + // Disable wallpaper detection (theme accent takes priority) + if (this.enableWallpaperDetection) { + this.settings.setValue("enable-wallpaper-detection", false); + } + // Enable panel override so accent shadow is immediately visible + if (!this.overridePanelColor) { + this.settings.setValue("override-panel-color", true); + } + // Programmatic settings.setValue() does not trigger IN-bound callbacks; + // explicitly refresh all visual styles to reflect the newly written values. + if (accentVariants) { + this.cssManager.updateAllVariables(); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + this.refreshAllActiveStyles(); + } + }, + extractWallpaperColors: () => { + global.logWarning("[CSSPanels] External extractWallpaperColors called"); + this.extractWallpaperColors(); + }, + }; + } + + /** + * Disable the extension and restore original appearance + */ + disable() { + this.isEnabled = false; // Set flag immediately to prevent settings callbacks + + this.debugLog("Disabling extension... Starting cleanup"); + try { + // Disable all stylers in reverse order to avoid dependencies + // Each styler gets its own try/catch to ensure cleanup continues on failure + this.debugLog("Disabling all stylers..."); + + // Unload hover stylesheet first (removes from St theme before stylers run) + if (this.hoverStyleManager) { + try { + this.hoverStyleManager.disable(); + } catch (e) { + this.debugLog(`Error disabling hoverStyleManager: ${e.message}`); + } + } + + const stylers = [ + ['deskletStyler', this.deskletStyler], + ['altTabStyler', this.altTabStyler], + ['tooltipStyler', this.tooltipStyler], + ['osdStyler', this.osdStyler], + ['notificationStyler', this.notificationStyler], + ['nemoPopupStyler', this.nemoPopupStyler], + ['popupStyler', this.popupStyler], + ['panelStyler', this.panelStyler], + ]; + for (const [name, styler] of stylers) { + try { + styler.disable(); + } catch (e) { + this.debugLog(`Error disabling ${name}: ${e.message}`); + global.logError(`[CSSPanels] Error disabling ${name}: ${e.message}`); + } + } + + // Disable wallpaper monitoring (Phase 2.5C) + if (this.wallpaperMonitor) { + try { + this.wallpaperMonitor.disable(); + } catch (e) { + this.debugLog(`Error disabling wallpaperMonitor: ${e.message}`); + } + } + + this.debugLog("All stylers disabled"); + + // Cleanup monitoring and connections AFTER stylers + this.debugLog("Cleaning up monitoring and connections..."); + this.cleanupPanelMonitoring(); + this.clearAllTimeouts(); + + // Cleanup system components + this.debugLog("Cleaning up system components..."); + this.systemIndicator.destroy(); + this.themeDetector.cleanup(); + this.cssManager.cleanup(); + + // Cleanup blur template cache to free memory + this.blurTemplateManager.cleanup(); + + // Cleanup extension-level signal handler + const signalCount = this._signalsHandler.getSignalCount(); + this._signalsHandler.destroy(); + this.debugLog(`Cleaned up ${signalCount} extension signals`); + + // Finalize settings: unregisters from settingsManager, removes all bindings and signals + if (this.settings) { + this.settings.finalize(); + this.settings = null; + } + + this.debugLog("Extension disabled successfully - all resources cleaned"); + } catch (error) { + this.debugLog("Error during disable:", error); + global.logError("[CSSPanels] Error in disable: " + error.message); + // Don't call forceCleanupAllResources again - already in progress + } + } + + /** + * Force update all settings to UI + */ + forceSettingsUpdate() { + this.onPanelOpacityChanged(); + this.onMenuOpacityChanged(); + this.onBorderRadiusChanged(); + this.onBlurSettingsChanged(); + this.cssManager.updateAllVariables(); + } + + /** + * Clear all known timeouts and intervals + */ + clearAllTimeouts() { + try { + // Clear all tracked timeouts + const timeouts = [ + '_scheduleRefreshTimeout', + '_newPanelApplyTimeout', + '_osdCleanupTimeout', + '_tooltipCleanupTimeout', + ]; + for (const key of timeouts) { + if (this[key]) { + imports.mainloop.source_remove(this[key]); + this[key] = null; + } + } + this.debugLog("Clearing timeouts..."); + } catch (e) { + this.debugLog("Error clearing timeouts:", e); + } + } + + /** + * Reset blur settings to selected template defaults + */ + _resetBlurToDefaults() { + this.debugLog("Applying selected blur template"); + // Implementation moved to BlurTemplateManager for better organization + this.blurTemplateManager.applyTemplate(this.blurTemplate); + // Refresh OSD styles with new template settings + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + /** + * Refresh all active styled elements with current settings + * Centralized method to update all components when settings change + */ + /** + * Refresh all active styled elements (popups, tooltips, OSD, etc.) + * IMPORTANT: This is called AFTER cssManager and panelStyler have already been updated + * Do NOT refresh panel or CSS variables again to avoid duplicate work + */ + refreshAllActiveStyles() { + this.debugLog("Refreshing all active styled elements (excluding panel - already updated)"); + + // NOTE: Do NOT call cssManager.updateAllVariables() - already done by caller + // NOTE: Do NOT call panelStyler.applyPanelStyles() - already done by caller + + // Refresh hover/active color override stylesheet with new colors + this.hoverStyleManager.refresh(); + + // Refresh other UI elements + this.popupStyler.refreshActiveMenus(); + if (this.enableTooltipStyling) { + this.tooltipStyler.refreshActiveTooltips(); + } + if (this.enableAltTabStyling) { + this.altTabStyler.refreshActiveSwitchers(); + } + if (this.enableOSDStyling) { + this.osdStyler.refreshAllOSDs(); + } + if (this.enableNotificationStyling) { + // this.notificationStyler.refreshActiveNotifications(); + } + if (this.enableDesktopContextStyling) { + this.nemoPopupStyler.refresh(); + } + if (this.enableDeskletStyling && this.deskletStyler) { + this.deskletStyler.refreshAllDesklets(); + } + } + + // === SETTINGS CALLBACKS === + onPanelOpacityChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Panel opacity changed to: ${this.panelOpacity}`); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + } + + onMenuOpacityChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Menu opacity changed to: ${this.menuOpacity}`); + this.cssManager.updateAllVariables(); + // Refresh OSD elements with new border radius + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onBorderRadiusChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Border radius changed to: ${this.borderRadius}px`); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + + // Refresh OSD elements with new border radius + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onAutoDetectRadiusChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Auto-detect radius changed to: ${this.autoDetectRadius}`); + this.themeDetector.invalidateCache(); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + + // Refresh OSD elements when auto-detect changes + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onPanelRadiusChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Apply panel radius changed to: ${this.applyPanelRadius}`); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + + // Refresh OSD elements when panel radius setting changes + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onOverridePanelColorChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Override panel color changed to: ${this.overridePanelColor}`); + this.themeDetector.invalidateCache(); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + this.popupStyler.refreshActiveMenus(); + this.hoverStyleManager.refresh(); + if (this.enableTooltipStyling) { + this.tooltipStyler.refreshActiveTooltips(); + } + + // Refresh OSD elements with new panel color + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onChooseOverridePanelColorChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Choose override panel color changed to: ${this.chooseOverridePanelColor}`); + this.themeDetector.invalidateCache(); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + this.popupStyler.refreshActiveMenus(); + this.hoverStyleManager.refresh(); + if (this.enableTooltipStyling) { + this.tooltipStyler.refreshActiveTooltips(); + } + + // Refresh OSD elements with new panel color value + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onOverridePopupColorChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Override popup color changed to: ${this.overridePopupColor}`); + this.themeDetector.invalidateCache(); + this.cssManager.updateAllVariables(); // Update CSS first + this.refreshAllActiveStyles(); + // Ensure OSD gets popup color overrides + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onChooseOverridePopupColorChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Choose override popup color changed to: ${this.chooseOverridePopupColor}`); + this.themeDetector.invalidateCache(); + this.cssManager.updateAllVariables(); // Update CSS first + this.refreshAllActiveStyles(); + // Ensure OSD gets new popup color value + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onNotificationStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Notification styling changed to: ${this.enableNotificationStyling}`); + if (this.enableNotificationStyling) { + this.notificationStyler.safeEnable(); + } else { + this.notificationStyler.disable(); + } + } + + onOSDStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`OSD styling changed to: ${this.enableOSDStyling}`); + if (this.enableOSDStyling) { + this.osdStyler.safeEnable(); + } else { + // Force immediate cleanup of all styled OSDs before disabling + this.osdStyler.restoreAllOSDs(); + this.osdStyler.disable(); + // Additional cleanup with timeout to ensure complete restoration + if (this._osdCleanupTimeout) { + imports.mainloop.source_remove(this._osdCleanupTimeout); + } + this._osdCleanupTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_MEDIUM, () => { + this.osdStyler.restoreAllOSDs(); + this._osdCleanupTimeout = null; + return false; + }); + } + } + + onTooltipStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Tooltip styling changed to: ${this.enableTooltipStyling}`); + if (this.enableTooltipStyling) { + this.tooltipStyler.safeEnable(); + } else { + // Force immediate cleanup of all styled tooltips before disabling + this.tooltipStyler.cleanupActiveTooltips(); + this.tooltipStyler.disable(); + // Additional cleanup with timeout to ensure complete restoration + if (this._tooltipCleanupTimeout) { + imports.mainloop.source_remove(this._tooltipCleanupTimeout); + } + this._tooltipCleanupTimeout = imports.mainloop.timeout_add(TIMING.DEBOUNCE_MEDIUM, () => { + this.tooltipStyler.cleanupActiveTooltips(); + this._tooltipCleanupTimeout = null; + return false; + }); + } + } + + onAltTabStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`AltTab styling changed to: ${this.enableAltTabStyling}`); + if (this.enableAltTabStyling) { + this.altTabStyler.safeEnable(); + } else { + this.altTabStyler.disable(); + } + } + + /** + * Handle wallpaper detection setting change (Phase 2.5C) + * Automatically enables panel/popup color overrides when detection is turned on, + * and disables full-auto-mode when detection is turned off. + */ + onWallpaperDetectionChanged() { + if (!this.isEnabled) return; + this.debugLog(`Wallpaper detection changed to: ${this.enableWallpaperDetection}`); + + if (this.enableWallpaperDetection) { + // Auto-enable panel color override so extracted colors apply to panel + if (!this.overridePanelColor) { + this.settings.setValue("override-panel-color", true); + } + this.wallpaperMonitor.enable(); + } else { + // Turn off full-auto-mode when detection is disabled (dependency hides it) + if (this.fullAutoMode) { + this.settings.setValue("full-auto-mode", false); + } + this.wallpaperMonitor.disable(); + } + } + + /** + * Handle full auto mode setting change (Phase 2.5C) + * When enabled, wallpaper changes will also update blur/accent color settings. + * When disabled, only panel and popup colors are updated on wallpaper change. + */ + onFullAutoModeChanged() { + if (!this.isEnabled) return; + this.debugLog(`Full auto mode changed to: ${this.fullAutoMode}`); + } + + /** + * Manual wallpaper color extraction callback (Phase 2.5C) + * Called when user clicks "Extract colors from wallpaper" button + */ + extractWallpaperColors() { + this.debugLog("🔘 Manual wallpaper extraction requested"); + + if (!this.wallpaperMonitor) { + this.debugLog("❌ WallpaperMonitor not initialized"); + Main.notifyError("CSS Panels", "Wallpaper monitor not available"); + return; + } + + const success = this.wallpaperMonitor.manualExtract(this.fullAutoMode); + + if (!success) { + Main.notifyError("CSS Panels", "No wallpaper detected or extraction in progress"); + } else { + this.debugLog("✅ Manual extraction triggered successfully"); + // Phase 3 will add actual extraction notification + } + } + + onBlurSettingsChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog("Blur settings changed"); + this.cssManager.updateAllVariables(); // Update CSS first + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + this.refreshAllActiveStyles(); + } + + /** + * Handle glow setting changes + * Refreshes panels to apply new glow configuration + */ + onGlowSettingChanged() { + if (!this.isEnabled) return; + this.debugLog(`Glow settings changed - mode: ${this.glowMode}`); + + // Clear cache to force CSS regeneration + if (this.blurTemplateManager) { + this.blurTemplateManager.clearCache(); + this.debugLog("Template cache cleared after glow change"); + } + + if (this.panelStyler && !this.panelStyler._enableFailed) { + this.panelStyler.refresh(); + } + } + + onBlurOpacityChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Blur opacity changed to: ${this.blurOpacity}`); + this.panelStyler.applyPanelStyles(); + this.scheduleRefreshPanels(); + } + + onBlurTemplateChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Blur template changed to: ${this.blurTemplate}`); + // Template is used in reset function + // Refresh OSD styles when template changes + if (this.enableOSDStyling && this.osdStyler) { + this.osdStyler.refreshAllOSDs(); + } + } + + onHideTrayIconChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Hide tray icon changed to: ${this.hideTrayIcon}`); + if (this.hideTrayIcon) { + this.systemIndicator.destroy(); + } else { + this.systemIndicator.create(); + } + } + + onDebugLoggingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + // this.debugLog(`Debug logging changed to: ${this.debugLogging}`); + // debugLog checks this value automatically + } + + onDesktopContextStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Desktop context styling changed to: ${this.enableDesktopContextStyling}`); + if (this.enableDesktopContextStyling) { + this.nemoPopupStyler.safeEnable(); + } else { + this.nemoPopupStyler.disable(); + } + } + + onDeskletStylingChanged() { + if (!this.isEnabled) return; // Prevent execution when disabled + this.debugLog(`Desklet styling changed to: ${this.enableDeskletStyling}`); + if (this.enableDeskletStyling) { + this.deskletStyler.safeEnable(); + } else { + this.deskletStyler.restoreAllDesklets(); + this.deskletStyler.disable(); + } + } + + // === PHASE 2.5B - ACCENT COLOR CALLBACKS === + + /** + * Callback when auto-apply-accent-on-theme-change toggle changes + * When enabled: automatically detect and apply accent colors on theme changes + * When disabled: user must manually click button to apply accent colors + */ + onAutoApplyAccentChanged() { + if (!this.isEnabled) return; + this.debugLog( + `Auto-apply accent on theme change ${ + this.autoApplyAccentOnThemeChange ? "ENABLED" : "DISABLED" + }` + ); + + if (this.autoApplyAccentOnThemeChange) { + // Apply accent colors immediately when enabled using unified flow + this.debugLog("Applying accent colors immediately via unified detection flow"); + const detectedThemeData = this.themeDetector.redetectAllThemeData(); + this.applyDetectedThemeData(detectedThemeData); + } else { + this.debugLog("Auto-apply disabled, colors preserved until manual apply"); + // Do NOT restore default colors - keep current colors + // User can manually apply or keep their custom colors + } + } + + /** + * Apply detected theme data from themeDetector.redetectAllThemeData() + * This is the MAIN entry point called from themeDetector's theme-set callback + * + * @param {Object} detectedData - Structured object from redetectAllThemeData() + * { + * borderRadius: {detected: number, shouldApply: boolean}, + * accentColor: {detected: string, shouldApply: boolean, variants: object}, + * panelColor: {detected: string, shouldApply: boolean}, + * popupColor: {detected: string, shouldApply: boolean}, + * isDarkMode: boolean + * } + */ + applyDetectedThemeData(detectedData) { + this.debugLog("=".repeat(60)); + this.debugLog("► APPLYING DETECTED THEME DATA..."); + + let appliedCount = 0; + + // 1. Apply border-radius if auto-detect enabled + if (detectedData.borderRadius.shouldApply) { + this.settings.setValue("border-radius", detectedData.borderRadius.detected); + this.debugLog(` ✓ border-radius: ${detectedData.borderRadius.detected}px (applied)`); + appliedCount++; + } else { + this.debugLog(` ⊗ border-radius: skipped (auto-detect disabled)`); + } + + // 2. Apply accent colors if auto-apply enabled + if (detectedData.accentColor.shouldApply && detectedData.accentColor.variants) { + this.applyAccentSystemToBlurEffects(detectedData.accentColor.variants); + this.debugLog(` ✓ accent colors: ${detectedData.accentColor.detected} (applied with variants)`); + appliedCount++; + } else { + this.debugLog(` ⊗ accent colors: skipped (auto-apply disabled)`); + } + + // 3. PANEL BASE COLOR DECISION + // Panel base color always comes from detected theme color. + // Accent variants (border, tint, shadow) are applied separately via applyAccentSystemToBlurEffects. + // accent.shadow is a box-shadow color (deep dark, low alpha) — not suitable as panel background. + const panelBaseColor = detectedData.panelColor.detected; + this.themeDetector.currentPanelBaseColor = panelBaseColor; // Update stored base + + if (detectedData.accentColor.shouldApply) { + this.debugLog(` ✓ panel-color: ${panelBaseColor} (from theme - accent applied separately)`); + } else { + this.debugLog(` ✓ panel-color: ${panelBaseColor} (from theme - auto-apply OFF)`); + } + + // Write accent shadow to picker when accent is available; else write theme base color. + // Guard: if user override is ON and auto-apply is OFF, preserve the user's chosen color. + // Note: picker alpha is ignored by cssManager (panel-bg-rgb uses r,g,b only). + if (detectedData.accentColor.shouldApply || !this.overridePanelColor) { + const pickerPanelColor = (detectedData.accentColor.shouldApply && detectedData.accentColor.variants) + ? detectedData.accentColor.variants.shadow + : panelBaseColor; + this.settings.setValue("choose-override-panel-color", pickerPanelColor); + this.debugLog(` ✓ panel picker: ${pickerPanelColor} (${detectedData.accentColor.shouldApply ? "accent shadow" : "theme base"})`); + } else { + this.debugLog(` ⊗ panel picker: preserved (user override active, auto-apply OFF)`); + } + appliedCount++; + + // 4. POPUP COLOR: always update picker with detected theme color. + // Override switch is never auto-enabled — user controls it explicitly. + // Picker stays in sync so the correct color is ready whenever user enables override. + const pickerPopupColor = (detectedData.accentColor.shouldApply && detectedData.accentColor.variants) + ? detectedData.accentColor.variants.shadow + : this.themeDetector.getCurrentPanelColor(); + this.settings.setValue("choose-override-popup-color", pickerPopupColor); + this.debugLog(` ✓ popup picker: ${pickerPopupColor} (${detectedData.accentColor.shouldApply ? "accent shadow" : "inherited from panel"})`); + appliedCount++; + + // 5. Coordinated refresh ONCE at the end + this.debugLog("► Refreshing all UI elements (coordinated single pass)..."); + this.cssManager.updateAllVariables(); + this.panelStyler.applyPanelStyles(); + this.refreshAllActiveStyles(); // Popups, tooltips, OSD, etc. + + this.debugLog( + `✓ Theme application complete: ${appliedCount}/4 properties applied (mode: ${ + detectedData.isDarkMode ? "DARK" : "LIGHT" + })` + ); + this.debugLog("=".repeat(60)); + } + + /** + * Detect accent color from theme and generate complete accent system + * This is a DATA SOURCE function - it only populates color picker values in settings. + * Similar to blur template apply - uses settings.setValue() to update UI. + * + * Flow: + * 1. Detect base accent from theme CSS (switch:checked or theme_selected_bg_color) + * 2. Generate accent variants (border, tint, shadow) + * 3. Populate color pickers: blur-border-color, blur-background, accent-shadow-color + * 4. User can manually adjust these values or let auto-apply mode handle it + */ + detectAndApplyAccentColors() { + this.debugLog("Detecting and applying theme accent colors..."); + + // Detect dark/light mode FIRST + const isDarkMode = this.themeDetector.isDarkModePreferred(); + this.debugLog(`Theme mode: ${isDarkMode ? "DARK" : "LIGHT"}`); + + // Detect base accent from theme + const accentColor = this.themeDetector.detectThemeAccentColor(); + + if (!accentColor) { + this.debugLog("No accent color found in theme, using defaults"); + // Use default Nord frost colors + const defaultAccent = { r: 136, g: 192, b: 208 }; + const accentSystem = this.themeDetector.generateAccentSystem(defaultAccent, isDarkMode); + this.applyAccentSystemToBlurEffects(accentSystem); + return; + } + + // Generate complete accent system (accent, border, tint, shadow) with explicit isDarkMode + const accentSystem = this.themeDetector.generateAccentSystem(accentColor, isDarkMode); + + if (accentSystem) { + // Apply to blur effects + this.applyAccentSystemToBlurEffects(accentSystem); + this.debugLog("Accent colors detected and applied successfully"); + } + } + + /** + * Apply accent system colors to blur effect settings + * IMPORTANT: This function ONLY sets values in settings (like blur template apply). + * It does NOT directly style elements - settings callbacks handle that. + * + * Mapping: + * - accent → detected-accent-color (preview/reference field) + * - border → blur-border-color (actual border color used in styling) + * - tint → blur-background (actual background tint used in styling) + * - shadow → accent-shadow-color (reserved for Phase 2.5C+) + * + * @param {Object} accentSystem - {accent, border, tint, shadow} in CSS rgba format + */ + applyAccentSystemToBlurEffects(accentSystem) { + this.debugLog("Applying accent system to blur effects (settings only)"); + + // Update detected-accent-color for preview (no actual effect) + //this.settings.setValue("detected-accent-color", accentSystem.accent); + + // Apply border color (accent-border-color → blur-border-color) + this.settings.setValue("blur-border-color", accentSystem.border); + + // Apply tint color (accent-tint-color → blur-background) + this.settings.setValue("blur-background", accentSystem.tint); + + // Store shadow color for future use + this.settings.setValue("accent-shadow-color", accentSystem.shadow); + + //this.debugLog(` → detected-accent-color: ${accentSystem.accent} (preview)`); + this.debugLog(` → blur-border-color: ${accentSystem.border}`); + this.debugLog(` → blur-background: ${accentSystem.tint}`); + this.debugLog(` → accent-shadow-color: ${accentSystem.shadow}`); + + // NOTE: Panel base color decision is handled by applyDetectedThemeData() + // Do NOT apply shadow color here - parent method decides based on switches + // NOTE: Do NOT refresh here - parent method (applyDetectedThemeData) handles coordinated refresh + } +} + +// === EXTENSION LIFECYCLE FUNCTIONS === + +/** + * Extension initialization function - called when extension is loaded + * @param {Object} metadata - Extension metadata from metadata.json + */ +function init(metadata) { + try { + cssPanelsExtension = new CSSPanelsExtension(metadata); + global.log("[CSSPanels] Extension initialized"); + } catch (error) { + global.logError("[CSSPanels] Error in init: " + error.message); + } +} + +/** + * Extension enable function - called when extension becomes active + */ +function enable() { + try { + if (cssPanelsExtension) { + return cssPanelsExtension.enable(); + } else { + global.logError("[CSSPanels] Cannot enable: extension not initialized"); + } + } catch (error) { + global.logError("[CSSPanels] Error in enable: " + error.message); + } +} + +/** + * Extension disable function - called when extension is deactivated + */ +function disable() { + try { + if (cssPanelsExtension) { + cssPanelsExtension.disable(); + cssPanelsExtension = null; + } + } catch (error) { + global.logError("[CSSPanels] Error in disable: " + error.message); + } +} diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/hoverStyleManager.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/hoverStyleManager.js new file mode 100644 index 00000000..4c3a4648 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/hoverStyleManager.js @@ -0,0 +1,628 @@ +const Main = imports.ui.main; +const GLib = imports.gi.GLib; +const { HOVER } = require("./constants"); +const { ThemeUtils } = require("./themeUtils"); + +/** + * Manages hover color overrides for Cinnamon panel and popup elements using + * GObject property notification ('notify::hover') instead of enter/leave events. + * + * CSS :hover loaded via St.Theme.load_stylesheet() loses against theme rules in Cinnamon + * 6.6.7 (same priority pool, theme wins on equal specificity). Instead, we watch the + * 'notify::hover' property on each actor: actor.hover stays true even when the pointer + * moves into child actors, eliminating false leave/enter transitions on container widgets. + * Inline background-color overrides with !important are applied/removed on hover state change. + * + * Actor hierarchy handled: + * - Standard applets: direct applet-box child of panel zone, track_hover=true, _applet backlink + * - Menu applet (panel-button): uses 'panel-button' CSS class, _delegate backlink (not _applet) + * - Window list / grouped-window-list: outer actor has track_hover=false; real hover + * targets (window-list-item-box / grouped-window-list-item-box) are two levels deep. + * actor-added watcher picks up items as windows open. + * - XApp status area: outer actor has _applet but icons are added async via D-Bus monitor. + * actor-added watcher on manager_container picks up XAppStatusIcon.actor children. + * - Legacy systray: foreign X windows, no usable notify::hover — skipped intentionally. + */ +class HoverStyleManager { + /** + * @param {Object} extension - Main extension instance (CSSPanelsExtension) + */ + constructor(extension) { + this.extension = extension; + this._connections = []; + this._externalActors = []; + } + + /** + * Enable hover styling: attach event hooks to all panel applets. + * Must be called after panelStyler.safeEnable() so applet actors are in the tree. + */ + enable() { + this.extension.debugLog("HoverStyleManager: enabling"); + try { + this._attachPanelHooks(); + for (const actor of this._externalActors) { + this._hookActor(actor); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: error in enable: " + e.message + "\n" + e.stack); + } + } + + /** + * Disable hover styling: disconnect all event handlers and restore original inline styles. + */ + disable() { + this.extension.debugLog("HoverStyleManager: disabling, removing " + this._connections.length + " connections"); + for (const conn of this._connections) { + try { + if (conn.actor && conn.addedId) conn.actor.disconnect(conn.addedId); + if (conn.actor && conn.removedId) conn.actor.disconnect(conn.removedId); + if (conn.actor && conn.hoverId) conn.actor.disconnect(conn.hoverId); + if (conn.actor && conn.pressId) conn.actor.disconnect(conn.pressId); + if (conn.actor && conn.releaseId) conn.actor.disconnect(conn.releaseId); + if (conn.actor && conn.pseudoId) conn.actor.disconnect(conn.pseudoId); + if (!conn.isContainerWatch && conn.actor && conn.baseStyle !== undefined) { + conn.actor.set_style(conn.baseStyle || null); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: error disconnecting: " + e.message); + } + } + this._connections = []; + } + + /** + * Refresh hover hooks when panel color or settings change. + */ + refresh() { + this.extension.debugLog("HoverStyleManager: refreshing"); + this.disable(); + this.enable(); + } + + /** + * Register an external actor for hover styling. + * Persists across refresh() cycles — actor is re-hooked automatically on each enable(). + * + * @param {St.Widget} actor - Actor to register and hook immediately + */ + hookExternalActor(actor) { + if (!actor) return; + if (!this._externalActors.includes(actor)) { + this._externalActors.push(actor); + } + this._hookActor(actor); + } + + /** + * Unregister an external actor and restore its base style. + * Call this before destroying the actor. + * + * @param {St.Widget} actor - Actor to unregister and unhook + */ + unhookExternalActor(actor) { + if (!actor) return; + this._externalActors = this._externalActors.filter(a => a !== actor); + this._unhookActor(actor); + } + + /** + * Attach notify::hover listeners to all panel applet boxes. + * + * Routing per zone child: + * - Window-list containers: recurse to find item-box actors + actor-added watcher + * - XApp status containers: actor-added watcher on manager_container for async icon load + * - Standard applets (applet-box or _applet): hook outer actor directly + * - Panel-button actors (_delegate): hook directly (menu@cinnamon.org) + */ + _attachPanelHooks() { + const panels = this._getAllPanels(); + let hookCount = 0; + + for (const panel of panels) { + const zones = [panel._leftBox, panel._centerBox, panel._rightBox].filter(Boolean); + + // Watch for applets added after enable() (e.g. menu@cinnamon.org loads late) + for (const zone of zones) { + const addedId = zone.connect('actor-added', (container, newActor) => { + this._processZoneChild(newActor); + }); + this._connections.push({ actor: zone, addedId, removedId: null, hoverId: null, baseStyle: null, isContainerWatch: true }); + } + + for (const zone of zones) { + const children = zone.get_children ? zone.get_children() : []; + for (const child of children) { + hookCount += this._processZoneChild(child); + } + } + } + + this.extension.debugLog("HoverStyleManager: hooked " + hookCount + " panel applet actors"); + } + + /** + * Process a single direct child of a panel zone. + * Routes to the appropriate hook strategy based on actor type. + * + * @param {St.Widget} child - Direct child of a panel zone box + * @returns {number} Number of actors hooked + */ + _processZoneChild(child) { + if (!child) return 0; + + if (this._isWindowListContainer(child)) { + return this._hookWindowListContainer(child); + } + + if (this._isXAppStatusContainer(child)) { + return this._hookXAppStatusContainer(child); + } + + if (this._isAppletActor(child)) { + this._hookActor(child); + return 1; + } + + return 0; + } + + /** + * Check if actor is a window-list or grouped-window-list container. + * These replace the applet-box class name and set track_hover=false on the outer actor. + * + * @param {St.Widget} actor + * @returns {boolean} + */ + _isWindowListContainer(actor) { + if (!actor) return false; + return actor.has_style_class_name && ( + actor.has_style_class_name('grouped-window-list-box') || + actor.has_style_class_name('window-list-box') + ); + } + + /** + * Check if actor is the xapp-status container applet. + * CinnamonXAppStatusApplet removes 'applet-box' from its outer actor and adds + * XAppStatusIcon children asynchronously via D-Bus monitor after construction. + * + * @param {St.Widget} actor + * @returns {boolean} + */ + _isXAppStatusContainer(actor) { + if (!actor || !actor.get_style_class_name) return false; + const cls = actor.get_style_class_name() || ''; + return cls.includes('xapp-status-cinnamon-org-applet'); + } + + /** + * Check if an actor is a hookable panel applet actor. + * Accepts: applet-box class, _applet backlink, or _delegate backlink (menu applet uses panel-button class). + * + * @param {St.Widget} actor - Actor to test + * @returns {boolean} True if actor is a hookable applet actor + */ + _isAppletActor(actor) { + if (!actor) return false; + if (actor._applet) return true; + if (actor._delegate) return true; + return actor.has_style_class_name && actor.has_style_class_name('applet-box'); + } + + /** + * Hook all currently-present XAppStatusIcon children and watch for async additions. + * XApp icons are added dynamically via D-Bus after applet construction, so we must + * watch actor-added on the manager_container to catch them. + * + * @param {St.Widget} outerActor - The xapp-status outer applet actor + * @returns {number} Number of icon actors hooked immediately + */ + _hookXAppStatusContainer(outerActor) { + let count = 0; + + const managerContainer = this._findManagerContainer(outerActor); + if (!managerContainer) { + this.extension.debugLog("HoverStyleManager: xapp-status: no manager_container found"); + return 0; + } + + const hookIfXAppIcon = (actor) => { + if (actor && actor.has_style_class_name && + actor.has_style_class_name('applet-box') && + actor.get_track_hover && actor.get_track_hover()) { + this._hookActor(actor); + count++; + } + }; + + const children = managerContainer.get_children ? managerContainer.get_children() : []; + for (const child of children) { + hookIfXAppIcon(child); + } + + const addedId = managerContainer.connect('actor-added', (container, newActor) => { + hookIfXAppIcon(newActor); + }); + + const removedId = managerContainer.connect('actor-removed', (container, removedActor) => { + this._unhookActor(removedActor); + }); + + this._connections.push({ actor: managerContainer, addedId, removedId, hoverId: null, baseStyle: null, isContainerWatch: true }); + + return count; + } + + /** + * Find the manager_container child of an xapp-status outer actor. + * The outer actor contains exactly one St.BoxLayout manager_container. + * + * @param {St.Widget} outerActor + * @returns {St.Widget|null} + */ + _findManagerContainer(outerActor) { + if (!outerActor || !outerActor.get_children) return null; + const children = outerActor.get_children(); + for (const child of children) { + const type = child.get_theme_node ? child.constructor.name : null; + if (child.get_children) return child; + } + return null; + } + + /** + * Hook individual item children of a window-list or grouped-window-list container. + * Item actors are two levels deep (container -> workspace/manager -> item-box). + * Watches actor-added on the container to hook items as windows open/close. + * + * @param {St.Widget} containerActor - The window-list-box or grouped-window-list-box actor + * @returns {number} Number of item actors hooked + */ + _hookWindowListContainer(containerActor) { + let count = 0; + + const recurseForItems = (actor, depth) => { + if (depth > 3 || !actor) return; + if (this._isWindowListItem(actor)) { + this._hookActor(actor); + count++; + return; + } + const children = actor.get_children ? actor.get_children() : []; + for (const child of children) { + recurseForItems(child, depth + 1); + } + }; + + const children = containerActor.get_children ? containerActor.get_children() : []; + for (const child of children) { + recurseForItems(child, 0); + } + + const addedId = containerActor.connect('actor-added', (container, newActor) => { + // Recurse to catch items at any nesting level, same as initial traversal + recurseForItems(newActor, 0); + }); + + const removedId = containerActor.connect('actor-removed', (container, removedActor) => { + this._unhookActor(removedActor); + }); + + this._connections.push({ actor: containerActor, addedId, removedId, hoverId: null, baseStyle: null, isContainerWatch: true }); + + return count; + } + + /** + * Check if actor is an individual window list item button. + * + * @param {St.Widget} actor + * @returns {boolean} + */ + _isWindowListItem(actor) { + if (!actor) return false; + return actor.has_style_class_name && ( + actor.has_style_class_name('grouped-window-list-item-box') || + actor.has_style_class_name('window-list-item-box') + ); + } + + /** + * Disconnect hover hooks from a specific actor and restore its base style. + * Used when a window closes and its button actor is removed from the tree. + * + * @param {St.Widget} actor - Actor to unhook + */ + _unhookActor(actor) { + const idx = this._connections.findIndex(c => c.actor === actor && !c.isContainerWatch); + if (idx === -1) return; + const conn = this._connections[idx]; + try { + if (conn.hoverId) actor.disconnect(conn.hoverId); + if (conn.pressId) actor.disconnect(conn.pressId); + if (conn.releaseId) actor.disconnect(conn.releaseId); + if (conn.baseStyle !== undefined) actor.set_style(conn.baseStyle || null); + } catch (e) { + this.extension.debugLog("HoverStyleManager: error unhooking actor: " + e.message); + } + this._connections.splice(idx, 1); + } + + /** + * Hook popup menu items when a popup menu opens. + * Called externally by popupStyler after menu actors are created. + * Also recurses into popup-sub-menu containers for dropdown items. + * + * @param {St.Widget} menuActor - The popup menu's main actor + */ + hookPopupMenu(menuActor) { + if (!menuActor) return; + this._hookMenuItemsInActor(menuActor, 0, menuActor); + } + + /** + * Reset the active (checked) visual state of the actor that opened a popup menu. + * Called by PopupStyler when a menu closes so the applet button returns to its base style. + * Deferred to idle to let Cinnamon finish removing the 'checked' pseudo-class first. + * + * @param {St.Widget} actor - The sourceActor (applet button) that triggered the menu + */ + resetActorActiveState(actor) { + if (!actor) return; + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + try { + const conn = this._connections.find(c => c.actor === actor && !c.isContainerWatch); + if (conn && !actor.hover) { + actor.set_style(conn.baseStyle || null); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: resetActorActiveState error: " + e.message); + } + return GLib.SOURCE_REMOVE; + }); + } + + /** + * Disconnect hover hooks from all popup-menu-item actors within a menu. + * Called by PopupStyler when a menu closes. + * Also cleans up any ScrollView container watchers registered for this menu. + * + * @param {St.Widget} menuActor - The popup menu's main actor + */ + unhookPopupMenu(menuActor) { + if (!menuActor) return; + this._unhookMenuItemsInActor(menuActor, 0); + const toRemove = this._connections.filter(c => c.isContainerWatch && c.menuActor === menuActor); + for (const conn of toRemove) { + try { + if (conn.addedId) conn.actor.disconnect(conn.addedId); + } catch (e) { + this.extension.debugLog("HoverStyleManager: error disconnecting ScrollView watcher: " + e.message); + } + } + this._connections = this._connections.filter(c => !(c.isContainerWatch && c.menuActor === menuActor)); + } + + /** + * Recursively find and hook popup-menu-item actors within a container. + * Descends into popup-sub-menu containers to hook dropdown items as well. + * + * @param {St.Widget} actor - Container actor to search + * @param {number} depth - Current recursion depth + * @param {St.Widget} [menuActor] - Root menu actor (used to tag container watchers for cleanup) + */ + _hookMenuItemsInActor(actor, depth, menuActor) { + if (depth > 8 || !actor) return; + if (actor.has_style_class_name && actor.has_style_class_name('popup-menu-item')) { + this._hookActor(actor, true); + const children = actor.get_children ? actor.get_children() : []; + for (const child of children) { + this._hookMenuItemsInActor(child, depth + 1, menuActor); + } + return; + } + // St.ScrollView (popup-sub-menu) wraps content via get_child() bin, not get_children() + if (actor.has_style_class_name && actor.has_style_class_name('popup-sub-menu')) { + this._traverseScrollViewContent(actor, depth, menuActor); + return; + } + const children = actor.get_children ? actor.get_children() : []; + for (const child of children) { + this._hookMenuItemsInActor(child, depth + 1, menuActor); + } + } + + /** + * Traverse St.ScrollView content to reach popup-menu-item actors. + * ScrollView exposes content via get_child() (bin) → get_child() (box), not get_children(). + * Registers an actor-added watcher tagged with menuActor for cleanup on menu close. + * + * @param {St.ScrollView} scrollView - The popup-sub-menu ScrollView actor + * @param {number} depth - Current recursion depth + * @param {St.Widget} [menuActor] - Root menu actor (used to tag watcher for cleanup) + */ + _traverseScrollViewContent(scrollView, depth, menuActor) { + try { + const bin = scrollView.get_child ? scrollView.get_child() : null; + if (!bin) return; + const box = bin.get_child ? bin.get_child() : null; + const container = box || bin; + const children = container.get_children ? container.get_children() : []; + for (const child of children) { + this._hookMenuItemsInActor(child, depth + 1, menuActor); + } + // Watch for items added after initial traversal; tagged with menuActor for cleanup + const addedId = container.connect('actor-added', (c, newActor) => { + this._hookMenuItemsInActor(newActor, depth + 1, menuActor); + }); + this._connections.push({ actor: container, addedId, removedId: null, hoverId: null, baseStyle: null, isContainerWatch: true, menuActor: menuActor || null }); + } catch (e) { + this.extension.debugLog("HoverStyleManager: ScrollView traversal error: " + e.message); + } + } + + /** + * Recursively find and unhook popup-menu-item actors within a container. + * + * @param {St.Widget} actor - Container actor to search + * @param {number} depth - Current recursion depth + */ + _unhookMenuItemsInActor(actor, depth) { + if (depth > 8 || !actor) return; + if (actor.has_style_class_name && actor.has_style_class_name('popup-menu-item')) { + this._unhookActor(actor); + } + const children = actor.get_children ? actor.get_children() : []; + for (const child of children) { + this._unhookMenuItemsInActor(child, depth + 1); + } + } + + /** + * Attach hover/active listeners to a single actor for hover color override. + * Saves current inline style as baseline so it is restored when hover ends. + * Watches 'notify::style-pseudo-class' to detect when applet popup closes + * ('checked' pseudo-class removed by Cinnamon) and restore base style. + * No-ops if this actor is already hooked (prevents duplicate signals on refresh). + * + * @param {St.Widget} actor - Actor to hook + * @param {boolean} [isMenuItem=false] - True if actor is a popup menu item + */ + _hookActor(actor, isMenuItem = false) { + // Guard: skip if already hooked to prevent duplicate signal connections on refresh + if (this._connections.some(c => c.actor === actor && !c.isContainerWatch)) { + return; + } + + const baseStyle = actor.get_style ? (actor.get_style() || null) : null; + + /** Returns true if the applet's popup is currently open (Cinnamon sets 'checked'). */ + const isChecked = () => !!(actor.has_style_pseudo_class && actor.has_style_pseudo_class('checked')); + + const hoverHandler = () => { + try { + if (actor.hover) { + actor.set_style(this._mergeHoverStyle(baseStyle || '', this._getHoverColor(isMenuItem))); + } else if (!isChecked()) { + actor.set_style(baseStyle); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: hover handler error: " + e.message); + } + }; + + const pressHandler = () => { + try { + actor.set_style(this._mergeHoverStyle(baseStyle || '', this._getActiveColor(isMenuItem))); + } catch (e) { + this.extension.debugLog("HoverStyleManager: press handler error: " + e.message); + } + }; + + const releaseHandler = () => { + try { + if (actor.hover || isChecked()) { + actor.set_style(this._mergeHoverStyle(baseStyle || '', this._getHoverColor(isMenuItem))); + } else { + actor.set_style(baseStyle); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: release handler error: " + e.message); + } + }; + + // Fires when Cinnamon adds/removes 'checked' pseudo-class (applet popup open/close). + // Deferred to idle to ensure Cinnamon has finished updating pseudo-class state. + const pseudoHandler = () => { + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + try { + if (!actor.hover && !isChecked()) { + actor.set_style(baseStyle); + } + } catch (e) { + this.extension.debugLog("HoverStyleManager: pseudo handler error: " + e.message); + } + return GLib.SOURCE_REMOVE; + }); + }; + + const hoverId = actor.connect('notify::hover', hoverHandler); + const pressId = actor.connect('button-press-event', pressHandler); + const releaseId = actor.connect('button-release-event', releaseHandler); + const pseudoId = actor.connect('notify::style-pseudo-class', pseudoHandler); + + this._connections.push({ actor, hoverId, pressId, releaseId, pseudoId, baseStyle, isContainerWatch: false }); + } + + /** + * Inject background-color into an existing inline style string, + * replacing any existing background-color declaration. + * + * @param {string} existingStyle - Current inline style string + * @param {string} hoverColor - CSS color value to apply + * @returns {string} New inline style string with hover color applied + */ + _mergeHoverStyle(existingStyle, hoverColor) { + const cleaned = existingStyle.replace(/background-color\s*:[^;]+;?/g, '').trim(); + const sep = cleaned.length > 0 && !cleaned.endsWith(';') ? '; ' : (cleaned.length > 0 ? ' ' : ''); + return cleaned + sep + "background-color: " + hoverColor + " !important;"; + } + + /** + * Get the hover color derived from the current panel base color. + * For menu items the base color alpha is ignored so the highlight is computed + * from the RGB components only, producing a visible contrast on dark backgrounds. + * + * @param {boolean} isMenuItem - Whether the color is for a popup menu item + * @returns {string} CSS rgba() color string + */ + _getHoverColor(isMenuItem) { + const bgColor = isMenuItem + ? this.extension.themeDetector.getEffectivePopupColor() + : this.extension.themeDetector.getPanelBaseColor(); + // For menu items strip the alpha so highlight is computed on opaque RGB only. + // Panel base colors often have low alpha (e.g. rgba(2,18,33,0.3)) which causes + // the auto-highlight algorithm to produce a nearly transparent result. + const r = bgColor.r; + const g = bgColor.g; + const b = bgColor.b; + const highlightRgb = ThemeUtils.getAutoHighlightColor( + [r, g, b], + isMenuItem ? HOVER.HOVER_INTENSITY * 1.5 : HOVER.HOVER_INTENSITY + ); + return ThemeUtils.rgbaToCss(highlightRgb[0], highlightRgb[1], highlightRgb[2], HOVER.HOVER_ALPHA); + } + + /** + * Get the active (click) color, more intense than hover color. + * + * @param {boolean} isMenuItem - Whether the color is for a popup menu item + * @returns {string} CSS rgba() color string + */ + _getActiveColor(isMenuItem) { + const bgColor = isMenuItem + ? this.extension.themeDetector.getEffectivePopupColor() + : this.extension.themeDetector.getPanelBaseColor(); + const highlightRgb = ThemeUtils.getAutoHighlightColor( + [bgColor.r, bgColor.g, bgColor.b], + HOVER.ACTIVE_INTENSITY + ); + return ThemeUtils.rgbaToCss(highlightRgb[0], highlightRgb[1], highlightRgb[2], HOVER.HOVER_ALPHA); + } + + /** + * Get all active Cinnamon panels. + * + * @returns {Array} Array of panel objects + */ + _getAllPanels() { + const panels = []; + if (Main.panel && Main.panel.actor) panels.push(Main.panel); + if (Main.panel2 && Main.panel2.actor) panels.push(Main.panel2); + return panels; + } +} + +module.exports = HoverStyleManager; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/icon.png b/csspanels@dr.drummie/files/csspanels@dr.drummie/icon.png new file mode 100755 index 00000000..e7695d92 Binary files /dev/null and b/csspanels@dr.drummie/files/csspanels@dr.drummie/icon.png differ diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/metadata.json b/csspanels@dr.drummie/files/csspanels@dr.drummie/metadata.json new file mode 100644 index 00000000..976a539f --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "csspanels@dr.drummie", + "name": "CSS Panels", + "description": "Dynamic control of panels and popups colors and visual effects", + "version": "2.0.9", + "author": "drdrummie", + "url": "https://github.com/drdrummie/cinnamon-spices-extensions/tree/master/csspanels@dr.drummie", + "cinnamon-version": ["6.4", "6.6"], + "multiversion": true, + "max-instances": 1 +} diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/nemoPopupStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/nemoPopupStyler.js new file mode 100644 index 00000000..577d8f74 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/nemoPopupStyler.js @@ -0,0 +1,146 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const GLib = imports.gi.GLib; +const StylerBase = require("./stylerBase"); +const { TRAVERSAL, CSS_CLASSES, SIGNALS } = require("./constants"); + +/** + * Nemo Popup Styler handles popup menu transparency and blur effects for Nemo desktop + * Integrates with existing popup styler for desktop context menus + */ +class NemoPopupStyler extends StylerBase { + /** + * Initialize Nemo Popup Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "NemoPopupStyler"); + this.isEnabled = false; + } + + /** + * Enable Nemo popup styling + */ + enable() { + super.enable(); + if (!this.extension.enableDesktopContextStyling || this.isEnabled) return; + + try { + this.debugLog("Enabling Nemo popup styling..."); + + // Setup desktop right-click detection + this.setupDesktopRightClickDetection(); + + this.isEnabled = true; + this.debugLog("Nemo popup styling enabled"); + } catch (e) { + this.debugLog("Failed to enable Nemo popup styling:", e); + } + } + + /** + * Disable Nemo popup styling + */ + disable() { + if (!this.isEnabled) { + this.debugLog("NemoPopupStyler: Already disabled"); + return; + } + + try { + this.isEnabled = false; // Set flag early + this.debugLog("NemoPopupStyler: Disable cleanup completed"); + } catch (e) { + this.debugLog("Error disabling Nemo popup styling:", e); + } + super.disable(); // Automatic signal cleanup via GlobalSignalsHandler + } + + /** + * Refresh Nemo popup styles on settings change + */ + refresh() { + super.refresh(); + if (this.isEnabled) { + // Re-setup detection - GlobalSignalsHandler will clean up old connections automatically + this.setupDesktopRightClickDetection(); + this.debugLog("Nemo popup styling refreshed"); + } + } + + /** + * Setup desktop right-click detection + */ + setupDesktopRightClickDetection() { + // Use GlobalSignalsHandler for automatic cleanup + this.addConnection(global.stage, SIGNALS.BUTTON_PRESS_EVENT, (actor, event) => { + // Check if it's a right-click (button 3) + if (event.get_button() === 3) { + this.handleDesktopRightClick(actor, event); + } + }); + + this.debugLog("Desktop right-click detection setup"); + } + + /** + * Handle desktop right-click event + * @param {Clutter.Actor} actor - The actor that received the event + * @param {Clutter.Event} event - The button press event + */ + handleDesktopRightClick(actor, event) { + // Check if the click is on desktop area + if (this.isDesktopArea(actor)) { + this.debugLog("Desktop right-click detected - popup menu should be styled by popupStyler"); + + // The popupStyler monkey patch should handle the styling automatically + // No additional action needed here as popupStyler intercepts all popup menus + } + } + + /** + * Check if the actor is in desktop area + * @param {Clutter.Actor} actor - The actor to check + * @returns {boolean} True if actor is in desktop area + */ + isDesktopArea(actor) { + if (!actor) return false; + + // Check if actor is the desktop window or its children + let current = actor; + let depth = 0; + const MAX_DEPTH = TRAVERSAL.MAX_DEPTH_DESKTOP; + + while (current && depth < MAX_DEPTH) { + this.extension.cssManager.logActorDetails(current, depth); + + if (current === global.stage) { + this.debugLog("Current actor is global.stage:", current); + // Click on stage - likely desktop + return true; + } + + // Check for desktop-related style classes + if (current.get_style_class_name) { + let styleClasses = current.get_style_class_name(); + this.debugLog("Checking style classes for current actor:", current, "classes:", styleClasses); + if ( + styleClasses && + (styleClasses.includes(CSS_CLASSES.DESKTOP) || + styleClasses.includes(CSS_CLASSES.NEMO_DESKTOP) || + styleClasses.includes(CSS_CLASSES.NAUTILUS_DESKTOP) || + styleClasses.includes(CSS_CLASSES.CAJA_DESKTOP)) + ) { + return true; + } + } + + current = current.get_parent(); + depth++; + } + + return false; + } +} + +module.exports = NemoPopupStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/notificationStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/notificationStyler.js new file mode 100644 index 00000000..77382df2 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/notificationStyler.js @@ -0,0 +1,721 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const StylerBase = require("./stylerBase"); +const { TIMING, TRAVERSAL, SIZE, STYLING, COLORS, DEFAULT_COLORS } = require("./constants"); + +/** + * Notification Styler handles popup notification transparency and blur effects + * Intercepts notifications that appear as floating banners on screen + */ +class NotificationStyler extends StylerBase { + constructor(extension) { + super(extension, "NotificationStyler"); + this.originalNotificationStyles = new Map(); + this.activeNotifications = new Set(); + + // Monkey patch targets for popup notifications + this.originalShowNotification = null; + this.originalHideNotification = null; + this.originalUpdateShowingNotification = null; + + // Debug tracking + this.debugMode = true; + this.notificationTracker = new Map(); + } + + /** + * Enable notification styling by monkey patching MessageTray + */ + enable() { + super.enable(); + if (!this.extension.enableNotificationStyling) { + this.debugLog("Notification styling disabled in settings"); + return; + } + + try { + this.applyMessageTrayPatches(); + this.monitorExistingNotifications(); + this.debugLog("Notification styler enabled - targeting popup notifications"); + } catch (e) { + this.debugLog("Error enabling notification styler:", e); + this.setupFallbackMonitoring(); + } + } + + /** + * Apply monkey patches to MessageTray for intercepting popup notifications + */ + applyMessageTrayPatches() { + // Patch the main notification display method + if (Main.messageTray && Main.messageTray._showNotification) { + this.originalShowNotification = Main.messageTray._showNotification; + this._boundPatchedShowNotification = this._patchedShowNotification.bind(this); + Main.messageTray._showNotification = this._boundPatchedShowNotification; + this.debugLog("Patched MessageTray._showNotification"); + } + + // Patch notification banner creation if available + if (MessageTray.NotificationBanner && MessageTray.NotificationBanner.prototype._init) { + this.originalBannerInit = MessageTray.NotificationBanner.prototype._init; + this._boundPatchedBannerInit = this._patchedBannerInit.bind(this); + MessageTray.NotificationBanner.prototype._init = this._boundPatchedBannerInit; + this.debugLog("Patched NotificationBanner._init"); + } + + // Alternative: Patch notification display in different Cinnamon versions + if (Main.messageTray && Main.messageTray.showNotification) { + this.originalShowNotificationAlt = Main.messageTray.showNotification; + this._boundPatchedShowNotificationAlt = this._patchedShowNotificationAlt.bind(this); + Main.messageTray.showNotification = this._boundPatchedShowNotificationAlt; + this.debugLog("Patched MessageTray.showNotification (alternative)"); + } + } + + /** + * Handle notification received signal (primary method) + * @param {Object} messageTray - Message tray instance + * @param {Object} notification - Notification object + */ + _patchedShowNotification() { + // Call original method first + const result = this.originalShowNotification.apply(Main.messageTray, arguments); + + // Style the notification that was just shown + this.styleCurrentNotification(); + + return result; + } + + /** + * Patched NotificationBanner._init - intercepts banner creation + */ + _patchedBannerInit(notification) { + // Call original constructor + const result = this.originalBannerInit.apply(this, arguments); + + // Style this banner + this.styleNotificationBanner(this); + + return result; + } + + /** + * Alternative patched showNotification for different Cinnamon versions + */ + _patchedShowNotificationAlt(notification) { + const result = this.originalShowNotificationAlt.apply(Main.messageTray, arguments); + + // Style with slight delay to ensure DOM is ready + imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this.searchForExistingNotifications(); + return false; + }); + + return result; + } + + /** + * Style the currently displayed notification + */ + styleCurrentNotification() { + try { + // Check for notification banner (most common case) + if (Main.messageTray._banner && Main.messageTray._banner.actor) { + this.styleNotificationElement(Main.messageTray._banner.actor, "notification-banner"); + } + + // Check for notification container + if (Main.messageTray._notificationContainer) { + this.styleNotificationElement(Main.messageTray._notificationContainer, "notification-container"); + } + + // Check for notification widget + if (Main.messageTray._notificationWidget) { + this.styleNotificationElement(Main.messageTray._notificationWidget, "notification-widget"); + } + + // Fallback: search for notification elements by class + this.findAndStylePopupNotifications(); + } catch (e) { + this.debugLog("Error styling current notification:", e); + } + } + + /** + * Style a notification banner specifically + * @param {Object} banner - NotificationBanner instance + */ + styleNotificationBanner(banner) { + if (!banner || !banner.actor) return; + + this.debugLog("Styling notification banner"); + this.styleNotificationElement(banner.actor, "banner"); + + // Also style any child containers + if (banner.bodyLabel && banner.bodyLabel.get_parent()) { + this.styleNotificationElement(banner.bodyLabel.get_parent(), "banner-body"); + } + + if (banner.titleLabel && banner.titleLabel.get_parent()) { + this.styleNotificationElement(banner.titleLabel.get_parent(), "banner-title"); + } + } + + /** + * Find popup notifications by searching for CSS classes instead of position + */ + findAndStylePopupNotifications() { + this.debugLog("Searching for popup notifications by CSS"); + + let totalFound = 0; + + // Search only global.stage with CSS filtering - works for all positions + if (global.stage) { + this.debugLog("Searching for notification CSS classes..."); + totalFound = this.searchForNotificationsByClass(global.stage, 0); + } + + this.debugLog(`CSS-based search found ${totalFound} notifications`); + } + + /** + * Search for notifications by CSS class names - position agnostic + * @param {Clutter.Actor} actor - Actor to search + * @param {number} depth - Current search depth + */ + searchForNotificationsByClass(actor, depth = 0) { + if (!actor || depth > 6) return 0; // Limited but sufficient depth + + let foundCount = 0; + + try { + const styleClass = (actor.get_style_class_name && actor.get_style_class_name()) || ""; + const name = (actor.get_name && actor.get_name()) || ""; + + // Look for notification-specific CSS classes + const notificationClasses = [ + "notification", + "banner", + "multi-line-notification", + "notification-banner", + "popup-message", + ]; + + const hasNotificationClass = notificationClasses.some( + (cls) => styleClass.includes(cls) || name.includes(cls) + ); + + if (hasNotificationClass && this.isValidNotificationSize(actor)) { + foundCount++; + this.styleNotificationElement(actor, "css-found-notification"); + this.debugLog(`Found notification by CSS: ${styleClass}`); + return foundCount; // Don't search children + } + + // Search children + if (actor.get_children) { + actor.get_children().forEach((child) => { + foundCount += this.searchForNotificationsByClass(child, depth + 1); + }); + } + } catch (e) { + // Silent fail + } + + return foundCount; + } + + /** + * Check if element has valid notification dimensions + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if valid size + */ + isValidNotificationSize(actor) { + if (!actor) return false; + + try { + const width = actor.get_width ? actor.get_width() : 0; + const height = actor.get_height ? actor.get_height() : 0; + + // Reasonable notification dimensions - not too small/large + return ( + width > SIZE.NOTIFICATION_MIN_WIDTH_BASIC && + height > SIZE.NOTIFICATION_MIN_HEIGHT_BASIC && + width < SIZE.NOTIFICATION_MAX_WIDTH_BASIC && + height < SIZE.NOTIFICATION_MAX_HEIGHT_BASIC + ); + } catch (e) { + return false; + } + } + + /** + * Recursively search for notification actors + * @param {Clutter.Actor} actor - Actor to search + * @param {number} depth - Current search depth + */ + searchForNotificationActors(actor, depth = 0) { + if (!actor || depth > TRAVERSAL.MAX_DEPTH_NOTIFICATION) return; // Limit search depth + + try { + // Check if this looks like a notification + if (this.isPopupNotification(actor)) { + this.styleNotificationElement(actor, "found-popup-notification"); + return; // Don't search children of notifications + } + + // Search children + if (actor.get_children) { + actor.get_children().forEach((child) => { + this.searchForNotificationActors(child, depth + 1); + }); + } + } catch (e) { + // Silent fail for individual actors + } + } + + /** + * Check if an actor is a popup notification + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if this appears to be a popup notification + */ + isPopupNotification(actor) { + if (!actor) return false; + + // Skip wrapper elements - we want to style their content instead + if (this.isNotificationWrapper(actor)) { + return false; + } + + // Prefer content elements that have wrapper parents + if (this.hasNotificationWrapperParent(actor)) { + return true; + } + + // Skip system tray and notification applet elements + if (this.isSystemElement(actor)) { + return false; + } + + try { + const styleClass = (actor.get_style_class_name && actor.get_style_class_name()) || ""; + const name = (actor.get_name && actor.get_name()) || ""; + + // Skip notification applet elements (in panel) + if ( + styleClass.includes("notification-applet") || + styleClass.includes("applet-box") || + name.includes("applet") + ) { + return false; + } + + // Check for notification-specific classes and names + const notificationIndicators = [ + "notification", + "message-tray", + "banner", + "popup-message", + "osd-notification", + ]; + + const hasNotificationClass = notificationIndicators.some( + (indicator) => styleClass.includes(indicator) || name.includes(indicator) + ); + + if (hasNotificationClass) return true; + + // Check position and size characteristics of popup notifications + if (actor.get_width && actor.get_height && actor.get_x && actor.get_y) { + const width = actor.get_width(); + const height = actor.get_height(); + const x = actor.get_x(); + const y = actor.get_y(); + + // Typical notification dimensions and positioning + const isNotificationSize = + width > SIZE.NOTIFICATION_MIN_WIDTH && + width < SIZE.NOTIFICATION_MAX_WIDTH && + height > SIZE.NOTIFICATION_MIN_HEIGHT && + height < SIZE.NOTIFICATION_MAX_HEIGHT; + + // Only consider elements positioned as floating notifications + // Must be positioned away from panel (not at 0,0) and in notification area + const isFloatingPosition = x > 0 && y > 0; + const isTopRight = + x > global.screen_width - SIZE.NOTIFICATION_MAX_WIDTH && + y > STYLING.NOTIFICATION_POSITION_TOP_OFFSET && + y < STYLING.NOTIFICATION_POSITION_TOP_RIGHT_MAX_Y; + const isTopCenter = + x > global.screen_width / 4 && + x < (3 * global.screen_width) / 4 && + y < STYLING.NOTIFICATION_POSITION_TOP_CENTER_MAX_Y; + + return isNotificationSize && (isTopRight || isTopCenter); + } + + return false; + } catch (e) { + return false; + } + } + + /** + * Check if actor is a system element that should not be styled + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if this is a system element + */ + isSystemElement(actor) { + if (!actor) return false; + + try { + // Check if element is child of panel + let parent = actor.get_parent(); + while (parent) { + const parentClass = (parent.get_style_class_name && parent.get_style_class_name()) || ""; + if ( + parentClass.includes("panel") || + parentClass.includes("panelRight") || + parentClass.includes("panelLeft") + ) { + return true; + } + parent = parent.get_parent(); + } + } catch (e) { + // Silent fail + } + return false; + } + + /** + * Check if actor is a notification wrapper element + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if this is a notification wrapper + */ + isNotificationWrapper(actor) { + if (!actor) return false; + + try { + const styleClass = (actor.get_style_class_name && actor.get_style_class_name()) || ""; + + // Look for wrapper classes that control notification layout + const wrapperIndicators = ["notification-applet-padding", "notification-container", "notification-wrapper"]; + + return wrapperIndicators.some((indicator) => styleClass.includes(indicator)); + } catch (e) { + return false; + } + } + + /** + * Check if wrapper element has visible notification content + * @param {Clutter.Actor} actor - Wrapper actor to check + * @returns {boolean} True if wrapper contains notification content + */ + hasVisibleNotificationContent(actor) { + if (!actor || !actor.get_children) return false; + + try { + const children = actor.get_children(); + return children.some((child) => { + const styleClass = (child.get_style_class_name && child.get_style_class_name()) || ""; + return styleClass.includes("notification") || styleClass.includes("multi-line"); + }); + } catch (e) { + return false; + } + } + + /** + * Check if actor has a notification wrapper as parent + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if parent is a notification wrapper + */ + hasNotificationWrapperParent(actor) { + if (!actor) return false; + + const parent = actor.get_parent ? actor.get_parent() : null; + return parent && this.isNotificationWrapper(parent); + } + + /** + * Apply styling to a notification element + * @param {Clutter.Actor} element - Notification element to style + * @param {string} type - Type for logging + */ + styleNotificationElement(element, type) { + if (!element || this.originalNotificationStyles.has(element)) { + return; // Already styled or invalid + } + + try { + this.debugLog(`Styling popup notification: ${type}`); + + // Save original styling + const originalData = { + style: element.get_style() || "", + styleClasses: element.get_style_class_name() || "", + opacity: element.get_opacity(), + }; + + this.originalNotificationStyles.set(element, originalData); + this.activeNotifications.add(element); + + // Get template and colors + const template = this.extension.blurTemplateManager.getTemplate( + this.extension.settings.getValue("blur-template") + ); + + if (!template) { + this.debugLog("No blur template available"); + return; + } + + // Apply enhanced notification styling using template generation + // Get effective popup color and apply notification-specific lightening for visibility + let notificationColor = this.extension.themeDetector.getEffectivePopupColor(); + notificationColor = { + r: Math.min(notificationColor.r + DEFAULT_COLORS.NOTIFICATION_LIGHTEN_AMOUNT, 255), + g: Math.min(notificationColor.g + DEFAULT_COLORS.NOTIFICATION_LIGHTEN_AMOUNT, 255), + b: Math.min(notificationColor.b + DEFAULT_COLORS.NOTIFICATION_LIGHTEN_AMOUNT, 255), + }; + + // Build configuration object for template generation + const config = { + backgroundColor: `rgba(${notificationColor.r}, ${notificationColor.g}, ${notificationColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("notification"), + blurRadius: this.getAdjustedBlurRadius("notification"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor || STYLING.FALLBACK_BORDER_COLOR, + borderWidth: this.extension.blurBorderWidth || COLORS.DEFAULT_BLUR_BORDER_WIDTH, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager + const notificationCSS = this.extension.blurTemplateManager.generateNotificationCSS(config); + + // Add notification-specific enhancements + const enhancedCSS = + notificationCSS + + ` + box-shadow: 0 ${STYLING.NOTIFICATION_SHADOW_OUTER_OFFSET}px ${STYLING.NOTIFICATION_SHADOW_OUTER_BLUR}px rgba(0, 0, 0, ${STYLING.NOTIFICATION_SHADOW_OUTER_OPACITY}), 0 ${STYLING.NOTIFICATION_SHADOW_INNER_OFFSET}px ${STYLING.NOTIFICATION_SHADOW_INNER_BLUR}px rgba(0, 0, 0, ${STYLING.NOTIFICATION_SHADOW_INNER_OPACITY}), inset 0 ${STYLING.NOTIFICATION_SHADOW_HIGHLIGHT_OFFSET}px 0 rgba(255, 255, 255, ${STYLING.NOTIFICATION_SHADOW_HIGHLIGHT_OPACITY}); + transition: all ${STYLING.NOTIFICATION_TRANSITION_DURATION}s ${STYLING.NOTIFICATION_TRANSITION_CUBIC_BEZIER}; + overflow: hidden; + `; + + // Apply inline CSS directly + element.set_style(enhancedCSS); + + this.trackNotificationDimensions(element, type, "after-styling"); + + // Monitor for notification removal + this.monitorNotificationRemoval(element); + + this.debugLog(`Successfully styled popup notification: ${type}`); + } catch (e) { + this.debugLog(`Error styling notification ${type}: ${e.message || e}`); + if (e.stack) { + this.debugLog(e.stack); + } + } + } + + /** + * Track notification dimensions and properties for debugging + * @param {Clutter.Actor} element - Element to track + * @param {string} type - Type identifier + * @param {string} phase - Tracking phase + */ + trackNotificationDimensions(element, type, phase) { + if (!this.debugMode || !element) return; + + try { + const elementId = element.toString(); + const timestamp = Date.now(); + + // Simplified logging - only track start of tracking phase + this.debugLog(`Tracking notification: ${type} (${phase})`); + + // Keep minimal tracking data for lifecycle logging + const trackingData = { + elementId, + type, + phase, + timestamp, + }; + + if (!this.notificationTracker.has(elementId)) { + this.notificationTracker.set(elementId, []); + } + this.notificationTracker.get(elementId).push(trackingData); + } catch (e) { + this.debugLog(`Error tracking notification dimensions: ${e}`); + } + } + + /** + * Monitor when a notification is removed to clean up + * @param {Clutter.Actor} element - Element to monitor + */ + monitorNotificationRemoval(element) { + if (!element.connect) return; + + // Use GlobalSignalsHandler for automatic cleanup + this.addConnection(element, "destroy", () => { + this.originalNotificationStyles.delete(element); + this.activeNotifications.delete(element); + this.debugLog("Cleaned up destroyed notification"); + this.logNotificationLifecycle(element); + }); + + // Also monitor parent removal + const parent = element.get_parent(); + if (parent && parent.connect) { + this.addConnection(parent, "destroy", () => { + this.originalNotificationStyles.delete(element); + this.activeNotifications.delete(element); + }); + } + } + + /** + * Log complete lifecycle of a notification for analysis + * @param {Clutter.Actor} element - Element that was destroyed + */ + logNotificationLifecycle(element) { + if (!this.debugMode) return; + + const elementId = element.toString(); + const lifecycleData = this.notificationTracker.get(elementId); + + if (lifecycleData && lifecycleData.length > 0) { + this.debugLog(`Notification lifecycle complete: ${lifecycleData.length} phases tracked`); + + this.notificationTracker.delete(elementId); + } + } + + /** + * Setup fallback monitoring using stage events + */ + setupFallbackMonitoring() { + this.debugLog("Setting up fallback notification monitoring"); + + if (global.stage) { + // Use GlobalSignalsHandler for automatic cleanup + this.addConnection(global.stage, "actor-added", (stage, actor) => { + if (this.isPopupNotification(actor)) { + this.debugLog("Detected notification via stage monitoring"); + // Delay styling to allow full initialization + imports.mainloop.timeout_add(TIMING.DEBOUNCE_MEDIUM, () => { + this.styleNotificationElement(actor, "stage-detected"); + return false; + }); + } + }); + } + } + + /** + * Monitor existing notifications that might already be displayed + */ + monitorExistingNotifications() { + // Style any currently visible notifications + imports.mainloop.timeout_add(TIMING.DEBOUNCE_MEDIUM, () => { + this.findAndStylePopupNotifications(); + return false; + }); + } + + /** + * Disable notification styling and restore originals + */ + disable() { + this.debugMode = false; // Disable debug logging immediately + this.debugLog("NotificationStyler: Starting disable cleanup"); + + try { + this.restoreAllNotifications(); + this.restoreMonkeyPatches(); + this.notificationTracker.clear(); // Clear tracking data + this.debugLog("NotificationStyler: Disable cleanup completed"); + } catch (error) { + this.debugLog("NotificationStyler: Error during disable:", error); + } + super.disable(); // Automatic signal cleanup via GlobalSignalsHandler + } + + /** + * Restore all monkey patches + */ + restoreMonkeyPatches() { + if (this.originalShowNotification && Main.messageTray) { + if (Main.messageTray._showNotification === this._boundPatchedShowNotification) { + Main.messageTray._showNotification = this.originalShowNotification; + } + this.originalShowNotification = null; + this._boundPatchedShowNotification = null; + } + + if (this.originalBannerInit && MessageTray.NotificationBanner) { + if (MessageTray.NotificationBanner.prototype._init === this._boundPatchedBannerInit) { + MessageTray.NotificationBanner.prototype._init = this.originalBannerInit; + } + this.originalBannerInit = null; + this._boundPatchedBannerInit = null; + } + + if (this.originalShowNotificationAlt && Main.messageTray) { + if (Main.messageTray.showNotification === this._boundPatchedShowNotificationAlt) { + Main.messageTray.showNotification = this.originalShowNotificationAlt; + } + this.originalShowNotificationAlt = null; + this._boundPatchedShowNotificationAlt = null; + } + } + + /** + * Restore all styled notifications + */ + restoreAllNotifications() { + this.originalNotificationStyles.forEach((originalData, element) => { + try { + this.restoreNotificationElement(element, originalData); + } catch (e) { + this.debugLog("Error restoring notification:", e); + } + }); + + this.originalNotificationStyles.clear(); + this.activeNotifications.clear(); + } + + /** + * Restore a single notification element + * @param {Clutter.Actor} element - Element to restore + * @param {Object} originalData - Original styling data + */ + restoreNotificationElement(element, originalData) { + if (!element) return; + + element.set_style(originalData.style); + element.set_style_class_name(originalData.styleClasses); + element.set_opacity(originalData.opacity); + + // Remove our custom classes + const classesToRemove = ["transparency-notification-blur", "transparency-fallback-blur", "profile-custom"]; + + classesToRemove.forEach((cls) => { + element.remove_style_class_name(cls); + }); + } +} + +module.exports = NotificationStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/osdStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/osdStyler.js new file mode 100644 index 00000000..068126e6 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/osdStyler.js @@ -0,0 +1,489 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const StylerBase = require("./stylerBase"); +const { TIMING, SIZE, STYLING, CSS_CLASSES, SIGNALS, ACTIONS } = require("./constants"); + +/** + * OSD Styler handles On-Screen Display transparency and blur effects (NEW) + * Applies glass morphism effects to volume, brightness, and other OSD elements + */ +class OSDStyler extends StylerBase { + /** + * Initialize OSD Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "OSDStyler"); + this.originalOSDStyles = new Map(); + this.osdConnections = []; + this.monitoredElements = new Set(); + this.originalShow = null; + this.styledOSDs = new Set(); // Cache styled OSDs to avoid re-styling + } + + /** + * Disable OSD styling and restore original OSD show method + */ + disable() { + if (this.originalShow) { + const OSDWindow = this._findOSDWindow(); + if (OSDWindow && OSDWindow.show === this._patchedShowBound) { + OSDWindow.show = this.originalShow; + } + this.originalShow = null; + this._patchedShowBound = null; + } + this.restoreAllOSDs(); + super.disable(); + } + + /** + * Find OSD Window object from multiple possible import paths + * @returns {Object|null} OSD Window object or null if not found + * @private + */ + _findOSDWindow() { + // Array of possible OSD Window paths in order of preference + const osdPaths = [ + () => imports.ui.osdWindow.OSDWindow, + () => imports.ui.osdWindow, + () => Main.osdWindowManager, + () => Main.osdWindow, + ]; + + for (const pathFn of osdPaths) { + try { + const osdWindow = pathFn(); + if (osdWindow && typeof osdWindow.show === "function") { + this.debugLog(`Found OSD Window via: ${pathFn.toString()}`); + return osdWindow; + } + } catch (e) { + // Silent fail - try next path + } + } + + this.debugLog("OSDWindow not found in any known path"); + return null; + } + + /** + * Apply monkeypatch to OSD manager for OSD styling + */ + applyMonkeyPatch() { + try { + const OSDWindow = this._findOSDWindow(); + + if (OSDWindow) { + this.originalShow = OSDWindow.show; + // Store bound function reference to enable idempotent restore + this._patchedShowBound = this._patchedShow.bind(this); + OSDWindow.show = this._patchedShowBound; + this.debugLog("OSD monkeypatch applied successfully"); + } else { + this.debugLog("OSDWindow not found, using monitoring fallback"); + this.setupOSDMonitoring(); + } + } catch (e) { + this.debugLog("Failed to apply OSD monkeypatch:", e.message); + if (e.stack) { + this.debugLog("Error stack:", e.stack); + } + // Fallback to monitoring + this.setupOSDMonitoring(); + } + } + + /** + * Patched OSD show with custom styling + * @param {Object} monitorIndex - Monitor index + * @param {string} icon - OSD icon + * @param {string} label - OSD label + * @param {number} level - OSD level + */ + _patchedShow(monitorIndex, icon, label, level) { + // CRITICAL: When monkey-patched, 'this' in the ORIGINAL method context is the OSDWindow object + // We need to preserve that context when calling originalShow + // But 'this' in _patchedShow is bound to OSDStyler (via .bind(this) in applyMonkeyPatch) + + // Store reference to OSDStyler (bound 'this') + const styler = this; + + // Get the actual OSDWindow object - it's passed as the context when show() is called + // We need to find it via the known paths + const OSDWindow = styler._findOSDWindow(); + + // Call original show with proper OSDWindow context + const result = styler.originalShow.call(OSDWindow, monitorIndex, icon, label, level); + + // Apply styles to the OSDWindow object (not styler!) + styler.applyOSDStyles(OSDWindow); + + return result; + } + + /** + * Apply styles to OSD + * @param {Object} osd - OSD object + */ + applyOSDStyles(osd) { + if (!osd || !osd.actor) return; + + const actor = osd.actor; + + // Get effective popup color and apply OSD-specific darkening for better contrast + let osdColor = this.extension.themeDetector.getEffectivePopupColor(); + osdColor = { + r: Math.max(osdColor.r - STYLING.COLOR_DARKEN_AMOUNT, 0), + g: Math.max(osdColor.g - STYLING.COLOR_DARKEN_AMOUNT, 0), + b: Math.max(osdColor.b - STYLING.COLOR_DARKEN_AMOUNT, 0), + }; + + // Build configuration object for template generation + const config = { + backgroundColor: `rgba(${osdColor.r}, ${osdColor.g}, ${osdColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("osd"), + blurRadius: this.getAdjustedBlurRadius("osd"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: Math.max(this.extension.blurBorderWidth, 2), + transition: "all 0.2s ease", + }; + + // Generate and apply CSS via template manager + const osdCSS = this.extension.blurTemplateManager.generateOSDCSS(config); + actor.set_style(osdCSS); + } + + /** + * Enable OSD styling + */ + enable() { + super.enable(); + if (!this.extension.enableOSDStyling) { + this.debugLog("OSD styling disabled in settings"); + return; + } + + try { + this.applyMonkeyPatch(); + this.setupOSDMonitoring(); + // Initial search done in setupOSDMonitoring - avoid duplicate call + this.debugLog("OSD styler enabled"); + } catch (e) { + this.debugLog("Error enabling OSD styler:", e); + // Add detailed error logging for debugging + if (e.message) { + this.debugLog("Error message:", e.message); + } + if (e.stack) { + this.debugLog("Error stack:", e.stack); + } + } + } + + /** + * Disable OSD styling + */ + disable() { + this.debugLog("OSDStyler: Starting disable cleanup"); + try { + // Restore monkey-patched OSD show method + if (this.originalShow) { + const OSDWindow = this._findOSDWindow(); + if (OSDWindow) { + OSDWindow.show = this.originalShow; + this.debugLog("OSD monkey patch restored"); + } + this.originalShow = null; + } + + this.restoreAllOSDs(); + this.styledOSDs.clear(); + this.originalOSDStyles.clear(); + this.monitoredElements.clear(); + this.debugLog("OSDStyler: Disable cleanup completed"); + } catch (e) { + this.debugLog("Error disabling OSD styler:", e); + } + super.disable(); // Automatic signal cleanup via GlobalSignalsHandler + } + + /** + * Setup CSS-based monitoring for OSD elements + */ + setupOSDMonitoring() { + this.debugLog("Setting up CSS-based OSD monitoring"); + + // Monitor global stage for new OSD elements - use GlobalSignalsHandler + if (global.stage) { + this.addConnection(global.stage, SIGNALS.ACTOR_ADDED, (stage, actor) => { + if (this.isOSDElementByCSS(actor) && !this.styledOSDs.has(actor)) { + this.debugLog("Detected new OSD via CSS monitoring"); + imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this.styleOSDElement(actor, "css-found-osd"); + return false; + }); + } + }); + } + + // Initial search for existing OSDs + this.findAndStyleOSDsByCSS(); + } + + /** + * Find and style OSD elements using CSS classes (one-time search) + */ + findAndStyleOSDsByCSS() { + this.debugLog("Searching for OSD CSS classes..."); + + // Search in main UI locations for OSD elements + const searchLocations = [global.stage, Main.layoutManager.uiGroup, Main.layoutManager.modalDialogGroup]; + + let totalFound = 0; + searchLocations.forEach((location) => { + if (location) { + const found = this.searchForOSDActorsByCSS(location, 0); + totalFound += found; + } + }); + + this.debugLog(`CSS-based OSD search found ${totalFound} OSDs`); + // No periodic repeat - elements are styled once + } + + /** + * Search for OSD actors using CSS classes + * @param {Clutter.Actor} actor - Actor to search in + * @param {number} depth - Current search depth + * @returns {number} Number of OSDs found + */ + searchForOSDActorsByCSS(actor, depth = 0) { + if (!actor || depth > 6) return 0; // Limit search depth + + let foundCount = 0; + + try { + // Check if this looks like an OSD using CSS classes + if (this.isOSDElementByCSS(actor)) { + foundCount++; + this.styleOSDElement(actor, "css-found-osd"); + this.debugLog(`Found OSD by CSS: ${actor.get_style_class_name()}`); + return foundCount; // Don't search children of OSDs + } + + // Search children + if (actor.get_children) { + actor.get_children().forEach((child) => { + foundCount += this.searchForOSDActorsByCSS(child, depth + 1); + }); + } + } catch (e) { + // Silent fail for individual actors + } + + return foundCount; + } + + /** + * Check if actor is an OSD element using CSS classes + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if OSD element + */ + isOSDElementByCSS(actor) { + if (!actor) return false; + + try { + const styleClass = (actor.get_style_class_name && actor.get_style_class_name()) || ""; + + // Use regex for faster CSS class matching + const wrapperRegex = /(media-keys-osd|info-osd|osd-window|osd-container|osd|on-screen-display)/; + const contentRegex = /(volume-osd|brightness-osd|popup-slider|sound-slider|level-bar)/; + + const hasWrapperClass = wrapperRegex.test(styleClass); + const hasContentClass = contentRegex.test(styleClass); + + if (hasWrapperClass || hasContentClass) { + // Validate dimensions to avoid false positives + const width = actor.get_width ? actor.get_width() : 0; + const height = actor.get_height ? actor.get_height() : 0; + + // Reasonable OSD dimensions (relaxed height for wrapper elements) + return ( + width >= SIZE.OSD_MIN_WIDTH && + width <= SIZE.OSD_MAX_WIDTH && + height >= SIZE.OSD_MIN_HEIGHT && + height <= SIZE.OSD_MAX_HEIGHT + ); + } + + return false; + } catch (e) { + return false; + } + } + + /** + * Setup general system key monitoring for OSD triggers + */ + setupKeyMonitoring() { + try { + // Monitor for media keys that trigger OSDs - use GlobalSignalsHandler + if (global.display) { + this.lastKeyTrigger = 0; // Debounce timestamp + this.addConnection( + global.display, + SIGNALS.ACCELERATOR_ACTIVATED, + (display, action, deviceId, timestamp) => { + // Check if this is a volume or brightness key + if (action && (action.includes(ACTIONS.VOLUME) || action.includes(ACTIONS.BRIGHTNESS))) { + const now = Date.now(); + if (now - this.lastKeyTrigger > TIMING.KEY_TRIGGER_THROTTLE) { + // Debounce + this.lastKeyTrigger = now; + this.debugLog(`Media key detected: ${action}`); + // Trigger CSS-based search only if needed, without periodic repeat + this.findAndStyleOSDsByCSS(); + } + } + } + ); + } + } catch (e) { + this.debugLog("Could not setup key monitoring:", e); + } + } + + /** + * Apply styling to an OSD element + * @param {Clutter.Actor|Object} element - OSD element to style + * @param {string} type - Type of OSD element (for logging) + */ + styleOSDElement(element, type = "osd") { + let actor = element.actor || element; + + if (!actor || this.originalOSDStyles.has(actor) || this.styledOSDs.has(actor)) { + return; // Already styled or invalid + } + + try { + this.debugLog(`Styling OSD element: ${type}`); + + // Save original styling + let originalData = { + style: actor.get_style(), + backgroundColor: actor.get_background_color ? actor.get_background_color() : null, + styleClasses: actor.get_style_class_name(), + opacity: actor.get_opacity(), + }; + + this.originalOSDStyles.set(actor, originalData); + this.monitoredElements.add(actor); + + // Get effective popup color and apply OSD-specific darkening for better contrast + let osdColor = this.extension.themeDetector.getEffectivePopupColor(); + osdColor = { + r: Math.max(osdColor.r - STYLING.COLOR_DARKEN_AMOUNT, 0), + g: Math.max(osdColor.g - STYLING.COLOR_DARKEN_AMOUNT, 0), + b: Math.max(osdColor.b - STYLING.COLOR_DARKEN_AMOUNT, 0), + }; + + // Build configuration object for template generation + const config = { + backgroundColor: `rgba(${osdColor.r}, ${osdColor.g}, ${osdColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("osd"), + blurRadius: this.getAdjustedBlurRadius("osd"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: Math.max(this.extension.blurBorderWidth, 2), + transition: "all 0.2s ease", + }; + + // Generate CSS via template manager + const osdCSS = this.extension.blurTemplateManager.generateOSDCSS(config); + actor.set_style(osdCSS); + + this.debugLog("Applying OSD styles via template generation"); + + // Mark as styled to avoid re-styling + this.styledOSDs.add(actor); + + this.debugLog(`Successfully styled ${type} OSD`); + } catch (e) { + this.debugLog(`Error styling OSD element ${type}:`, e); + } + } + + /** + * Restore all styled OSDs to their original appearance + */ + restoreAllOSDs() { + this.debugLog("Restoring all OSD elements to default Cinnamon styling"); + + this.originalOSDStyles.forEach((originalData, element) => { + try { + this.restoreOSDElement(element, originalData); + } catch (e) { + this.debugLog("Error restoring OSD:", e); + } + }); + + this.originalOSDStyles.clear(); + this.monitoredElements.clear(); + this.styledOSDs.clear(); + } + + /** + * Restore original styling to an OSD element + * @param {Clutter.Actor} element - Element to restore + * @param {Object} originalData - Original styling data + */ + restoreOSDElement(element, originalData) { + if (!element) return; + + // Remove our custom styling completely + element.set_style(""); + + // Reset opacity to default + element.set_opacity(255); + + // Remove our style classes + element.remove_style_class_name(CSS_CLASSES.OSD_BLUR); + element.remove_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + element.remove_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + + // Clear any cached styling reference + this.styledOSDs.delete(element); + } + + /** + * Refresh OSD styling by invalidating cache and forcing re-styling on next display + * This ensures new settings are applied when OSDs are next shown + */ + refreshAllOSDs() { + if (!this.extension.enableOSDStyling) { + this.debugLog("OSD styling not enabled, skipping refresh"); + return; + } + + try { + this.debugLog("Refreshing OSD styling - invalidating cache for next display"); + + // Simplified refresh process + this.styledOSDs.clear(); + this.originalOSDStyles.clear(); + this.findAndStyleOSDsByCSS(); + } catch (e) { + this.debugLog("Error refreshing OSD elements:", e); + } + } +} + +module.exports = OSDStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/panelStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/panelStyler.js new file mode 100644 index 00000000..e2934414 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/panelStyler.js @@ -0,0 +1,434 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const StylerBase = require("./stylerBase"); +const { TIMING, DEFAULT_COLORS } = require("./constants"); + +/** + * Panel Styler handles panel transparency and blur effects + * Manages all panel appearance with blur effects and opacity + */ +class PanelStyler extends StylerBase { + /** + * Initialize Panel Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "PanelStyler"); + this.originalPanelStyles = {}; // Store original panel styles for restoration + + // Performance optimization - cache panel references + this.panelCache = null; + this.lastPanelCheck = 0; + this.panelCacheTimeout = TIMING.CACHE_PANEL_CHECK; + + // Track override state to prevent unnecessary theme reloads + this._lastOverrideState = null; + + // Map to save original sub-box inline styles for restoration + this._savedSubBoxStyles = new Map(); + } + + /** + * Enable panel styling + */ + enable() { + super.enable(); + this.saveOriginalStyles(); + this.applyPanelStyles(); + } + + /** + * Disable panel styling + */ + disable() { + this.restoreOriginalStyles(); + super.disable(); + } + + /** + * Refresh panel styling when settings change + */ + refresh() { + super.refresh(); + this.applyPanelStyles(); + } + + /** + * Get all available panels in the system with caching + * @returns {Array} Array of all panel actors with their IDs + */ + getAllPanels() { + const now = Date.now(); + + // Return cached result if still valid + if (this.panelCache && now - this.lastPanelCheck < this.panelCacheTimeout) { + return this.panelCache; + } + + let panels = []; + + // We keep the existing logic for the main panels (1 and 2) + if (Main.panel && Main.panel.actor) { + panels.push({ + id: "main", + actor: Main.panel.actor, + panel: Main.panel, + }); + this.debugLog("Found main panel"); + } + + if (Main.panel2 && Main.panel2.actor) { + panels.push({ + id: "panel2", + actor: Main.panel2.actor, + panel: Main.panel2, + }); + this.debugLog("Found panel2"); + } + + // Search for additional panels in Main.panelManager or other Main properties + try { + // Check Main.panelManager if it exists (newer versions of Cinnamon) + if (Main.panelManager && Main.panelManager.panels) { + for (let panelId in Main.panelManager.panels) { + let panel = Main.panelManager.panels[panelId]; + if (panel && panel.actor) { + // Check if it has already been added + let exists = panels.some((p) => p.actor === panel.actor); + if (!exists) { + panels.push({ + id: `managed_${panelId}`, + actor: panel.actor, + panel: panel, + }); + this.debugLog(`Found managed panel: ${panelId}`); + } + } + } + } + + // Alternative approach - check all Main objects that start with 'panel' + for (let key in Main) { + if (key.startsWith("panel") && key !== "panel" && key !== "panel2" && Main[key] && Main[key].actor) { + // Check if it has already been added + let exists = panels.some((p) => p.actor === Main[key].actor); + if (!exists) { + panels.push({ + id: key, + actor: Main[key].actor, + panel: Main[key], + }); + this.debugLog(`Found additional panel: ${key}`); + } + } + } + } catch (e) { + this.debugLog("Error finding additional panels:", e); + } + + this.debugLog(`Total panels found: ${panels.length}`); + + // Cache the result + this.panelCache = panels; + this.lastPanelCheck = now; + + return panels; + } + + /** + * Invalidate panel cache when panels change + */ + invalidatePanelCache() { + this.panelCache = null; + this.lastPanelCheck = 0; + + // Also clear saved original styles on theme change (but NOT during disable) + // This ensures we get fresh original styles from the new theme + if (this.isEnabled) { + this.originalPanelStyles = {}; + this.debugLog("Panel cache AND original styles invalidated (theme change)"); + } else { + this.debugLog("Panel cache invalidated"); + } + } + /** + * Save original panel styles for restoration + */ + saveOriginalStyles() { + try { + this.debugLog("Saving original styles for all panels"); + + // Get all panels and save their original styles + let allPanels = this.getAllPanels(); + + allPanels.forEach((panelInfo) => { + if (panelInfo.actor) { + // GUARD CHECK: Only save if not already saved + // This prevents overwriting true originals with our modified styles + if (!this.originalPanelStyles[panelInfo.id]) { + this.originalPanelStyles[panelInfo.id] = { + style: panelInfo.actor.get_style(), + backgroundColor: panelInfo.actor.get_background_color(), + styleClasses: panelInfo.actor.get_style_class_name(), + }; + this.debugLog(`Saved original styles for panel: ${panelInfo.id}`); + } else { + this.debugLog(`Panel ${panelInfo.id} already has saved styles - skipping to preserve original`); + } + } + }); + + this.debugLog("All original styles saved"); + } catch (e) { + this.debugLog("Error saving original styles:", e); + } + } + + /** + * Restore panels to their original styling + * @param {boolean} skipThemeRefresh - Skip _changeTheme() call when re-applying immediately after (default: false) + */ + restoreOriginalStyles(skipThemeRefresh = false) { + try { + // Scan for all panels for cleanup + let allPanels = this.getAllPanels(); + + // Restore all panels from saved styles + for (let panelId in this.originalPanelStyles) { + let original = this.originalPanelStyles[panelId]; + + // Try to find the panel again + let panelActor = this.findPanelActorById(panelId); + + if (panelActor && original) { + panelActor.set_style(original.style || ""); + if (original.backgroundColor) { + panelActor.set_background_color(original.backgroundColor); + } else { + panelActor.set_background_color(null); + } + + if (original.styleClasses) { + panelActor.set_style_class_name(original.styleClasses); + } + + // Remove our style classes + this.removeStyleClasses(panelActor); + + this.debugLog(`Restored original styles for panel: ${panelId}`); + } + } + + // Additional cleanup for any panels that were added after initial save + allPanels.forEach((panelInfo) => { + if (panelInfo.actor && !this.originalPanelStyles[panelInfo.id]) { + // Panel added after initial save - clean our styles + this.removeStyleClasses(panelInfo.actor); + panelInfo.actor.set_style(""); + this.debugLog(`Cleaned styles from additional panel: ${panelInfo.id}`); + } + }); + + // Restore panel sub-box backgrounds + if (this._savedSubBoxStyles && this._savedSubBoxStyles.size > 0) { + for (const [box, style] of this._savedSubBoxStyles) { + try { + box.set_style(style); + } catch (e) { + this.debugLog("PanelStyler: error restoring sub-box style: " + e.message); + } + } + this._savedSubBoxStyles.clear(); + } + + // Force theme refresh only on actual disable (not during re-apply cycles) + try { + if (!skipThemeRefresh && Main.themeManager && Main.themeManager._changeTheme) { + Main.themeManager._changeTheme(); + } + } catch (e) { + // Ignore errors during theme refresh + } + + this.debugLog("All original styles restored"); + } catch (e) { + this.debugLog("Error restoring original styles:", e); + } + } + + /** + * Find panel actor by saved ID + * @param {string} panelId - Saved panel ID + * @returns {Clutter.Actor|null} Panel actor or null + */ + findPanelActorById(panelId) { + // Try to find the panel based on the ID + if (panelId === "main" && Main.panel && Main.panel.actor) { + return Main.panel.actor; + } + if (panelId === "panel2" && Main.panel2 && Main.panel2.actor) { + return Main.panel2.actor; + } + + // For other panels, try rescanning + let allPanels = this.getAllPanels(); + let found = allPanels.find((p) => p.id === panelId); + return found ? found.actor : null; + } + + /** + * Remove our style classes from an actor + * @param {Clutter.Actor} actor - Actor to clean up + */ + removeStyleClasses(actor) { + const classesToRemove = [ + "transparency-panel-blur", + "blur-double", + "blur-triple", + "blur-enhanced", + "transparency-glass-effect", + "transparency-fallback-blur", + ]; + classesToRemove.forEach((className) => { + actor.remove_style_class_name(className); + }); + } + + /** + * Apply transparency and blur effects to all panels + */ + applyPanelStyles() { + try { + this.debugLog("Applying panel styles to all panels"); + + // CRITICAL FIX: Restore original styles FIRST before applying new ones + // This ensures we start from clean theme state, not our previous modifications + if (Object.keys(this.originalPanelStyles).length > 0) { + this.debugLog("Restoring to clean theme state before applying new styles"); + this.restoreOriginalStyles(true); // skip _changeTheme(): re-applying immediately after + + // Clear saved originals so we can save fresh ones + this.originalPanelStyles = {}; + this._savedSubBoxStyles.clear(); + } + + // Now save the CLEAN original styles from theme + this.saveOriginalStyles(); + + // Update CSS variables with current settings + this.extension.cssManager.updateAllVariables(); + + // Prepare panel color once before applying to all panels + // Use BLACK BOX pattern: getCurrentPanelColor() handles override logic + let panelColor; + if (this.extension.overridePanelColor) { + // Use safe parser via ThemeDetector for user-provided override color + panelColor = this.extension.themeDetector._safeParseColor( + this.extension.chooseOverridePanelColor, + DEFAULT_COLORS.MINT_Y_DARK_FALLBACK, + "panel override (panelStyler)" + ); + this.debugLog("Using override panel color:", this.extension.chooseOverridePanelColor); + } else { + // getCurrentPanelColor() returns a CSS string — normalize to {r,g,b} object + const panelColorRaw = this.extension.themeDetector.getCurrentPanelColor(); + panelColor = this.extension.themeDetector._safeParseColor( + panelColorRaw, + DEFAULT_COLORS.MINT_Y_DARK_FALLBACK, + "panel color (detected)" + ); + this.debugLog( + "Using detected panel color:", + `rgb(${panelColor.r}, ${panelColor.g}, ${panelColor.b})` + ); + } + + // Get all panels and apply styles + let allPanels = this.getAllPanels(); + allPanels.forEach((panelInfo, index) => { + if (panelInfo.actor) { + this.debugLog(`Applying styles to panel ${index + 1}/${allPanels.length}: ${panelInfo.id}`); + this.applyPanelStyleToActor(panelInfo.actor, panelColor); + this._clearSubBoxBackgrounds(panelInfo.panel); + } else { + this.debugLog(`Warning: Panel ${panelInfo.id} has no actor`); + } + }); + + this.debugLog(`Panel styling applied successfully to ${allPanels.length} panels`); + } catch (e) { + this.debugLog("Error applying panel styles:", e); + } + } + + /** + * Apply styling to a specific panel actor - SINGLE ACTOR approach + * Applies CSS directly to panel.actor without creating additional layers + * + * @param {Clutter.Actor} actor - The panel actor to style + * @param {Object} panelColor - The panel color to use (RGB object) + */ + applyPanelStyleToActor(actor, panelColor) { + if (!actor) return; + + let effectiveBorderRadius = this.extension.cssManager.getEffectiveBorderRadius(); + let radius = this.extension.applyPanelRadius ? effectiveBorderRadius : 0; + + this.debugLog("Applying DIRECT styling to panel.actor (single-actor approach)"); + + // Build configuration object for template generation + const config = { + backgroundColor: `rgba(${panelColor.r}, ${panelColor.g}, ${panelColor.b}, ${this.extension.panelOpacity})`, + borderRadius: radius, + blurRadius: this.extension.blurRadius, + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS and apply DIRECTLY to panel.actor + const css = this.extension.blurTemplateManager.generatePanelCSS(config); + actor.set_style(css); + + this.debugLog(`Direct panel styling applied with opacity ${this.extension.panelOpacity}`); + this.debugLog(`Panel color: rgb(${panelColor.r}, ${panelColor.g}, ${panelColor.b})`); + this.debugLog( + `Border: ${this.extension.blurBorderWidth}px, Radius: ${radius}px, Blur: ${this.extension.blurRadius}px` + ); + + // Log cache stats periodically (every 10th call) + if (Math.random() < 0.1) { + this.extension.blurTemplateManager.logCacheStats(); + } + } + + /** + * Clear background styles on panel sub-boxes to prevent theme bleed-through. + * Saves originals for restoration on disable. + * @param {Object} panel - Cinnamon panel object with _leftBox/_centerBox/_rightBox + */ + _clearSubBoxBackgrounds(panel) { + if (!panel) return; + const subBoxes = [panel._leftBox, panel._centerBox, panel._rightBox].filter(Boolean); + for (const box of subBoxes) { + if (!this._savedSubBoxStyles.has(box)) { + this._savedSubBoxStyles.set(box, box.get_style() || null); + } + const existingStyle = box.get_style() || ''; + const cleaned = existingStyle + .replace(/background-color\s*:[^;]+;?/g, '') + .replace(/background-gradient-direction\s*:[^;]+;?/g, '') + .replace(/background-gradient-start\s*:[^;]+;?/g, '') + .replace(/background-gradient-end\s*:[^;]+;?/g, '') + .replace(/background\s*:[^;]+;?/g, '') + .trim(); + const sep = cleaned.length > 0 && !cleaned.endsWith(';') ? '; ' : (cleaned.length > 0 ? ' ' : ''); + box.set_style(cleaned + sep + 'background: transparent !important; background-gradient-direction: none !important;'); + } + } +} + +module.exports = PanelStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/po/csspanels@dr.drummie.pot b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/csspanels@dr.drummie.pot new file mode 100644 index 00000000..2779a3f5 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/csspanels@dr.drummie.pot @@ -0,0 +1,338 @@ +# Translation template for csspanels@dr.drummie. +# Copyright (C) 2026 drdrummie +# This file is distributed under the same license as the csspanels@dr.drummie package. +# +msgid "" +msgstr "" +"Project-Id-Version: CSS Panels 2.0.7\n" +"Report-Msgid-Bugs-To: https://github.com/drdrummie/cinnamon-spices-extensions\n" +"POT-Creation-Date: 2026-04-19 12:41+0200\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=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Metadata strings from metadata.json + +msgid "CSS Panels" +msgstr "" + +msgid "Dynamic control of panels and popups colors and visual effects" +msgstr "" + +# Settings UI strings from settings-schema.json + +msgid "10 minutes" +msgstr "" + +msgid "10 seconds" +msgstr "" + +msgid "15 seconds" +msgstr "" + +msgid "1 minute" +msgstr "" + +msgid "2 minutes" +msgstr "" + +msgid "30 seconds" +msgstr "" + +msgid "5 minutes" +msgstr "" + +msgid "5 seconds" +msgstr "" + +msgid "Accent shadow/glow color" +msgstr "" + +msgid "Accent tint color, automatically populated from the active GTK theme (low-opacity variant of the accent color). Also used as a fallback glow color when no border color is set. You can adjust it manually - use semi-transparent colors for subtle color tinting." +msgstr "" + +msgid "Adjusts color vibrancy of the transparent background. Values above 1.0 make colors more vivid, below 1.0 create muted, desaturated tones. Applied as part of backdrop-filter — effective only on compositors that support it." +msgstr "" + +msgid "Adjust the transparency of all panels. Lower values create a more transparent panel." +msgstr "" + +msgid "Adjust the transparency of popup menus and some popup-based controls. Creates a semi-transparent appearance combined with tint and glow effects. Note: some Mint theme menus have a hardcoded background color that overrides transparency — this is a theme limitation, not an extension bug." +msgstr "" + +msgid "Advanced Settings" +msgstr "" + +msgid "Advanced Tools" +msgstr "" + +msgid "Appearance Settings" +msgstr "" + +msgid "Apply border radius to main panel" +msgstr "" + +msgid "Apply popup color override to the start menu (menu@cinnamon.org) sidebar. When disabled (default), sidebar uses the Cinnamon theme color (grey/white). When enabled, sidebar matches the popup override color. Has no effect if the original Cinnamon menu applet is not active." +msgstr "" + +msgid "Apply selected template" +msgstr "" + +msgid "Apply the selected effect template to all visual effect controls. This will update radius, saturation, contrast, brightness, background, border color, border width, transition, and opacity to match the chosen template." +msgstr "" + +msgid "Apply transparency and visual effect styles to Alt-Tab window switcher. Creates a consistent visual appearance for the application switcher." +msgstr "" + +msgid "Apply transparency and visual effect styles to desktop right-click context menus" +msgstr "" + +msgid "Apply transparency and visual effect styles to desktop widgets (desklets). Creates a consistent visual appearance for desklets matching your panel style." +msgstr "" + +msgid "Apply transparency and visual effect styles to On-Screen Display elements like volume sliders, brightness controls, and other overlay elements. Creates a consistent visual appearance." +msgstr "" + +msgid "Apply transparency and visual effect styles to system notifications (volume, brightness, etc.). This will make notifications match your panel's visual style." +msgstr "" + +msgid "Apply transparency and visual effect styles to tooltip elements that appear when hovering over panel items and other UI elements. Creates a consistent visual appearance." +msgstr "" + +msgid "Auto-apply accent colors on theme change" +msgstr "" + +msgid "Automatically detect and apply accent colors from your active GTK theme whenever you change themes. When disabled, you can still manually apply accent colors using the button below. This allows you to keep your custom colors while changing themes." +msgstr "" + +msgid "Automatically extract and apply colors from your current wallpaper to the panel. Enabling this also activates the panel color override — without it, extracted colors would be ignored. Popup menus automatically inherit the panel color; enable 'Override popup color' separately if you want independent popup customization. Use the 'Extract colors from wallpaper' button for a manual one-time extraction." +msgstr "" + +msgid "Background color/tint" +msgstr "" + +msgid "Basic Appearance Controls" +msgstr "" + +msgid "Blur radius" +msgstr "" + +msgid "Border color" +msgstr "" + +msgid "Border radius" +msgstr "" + +msgid "Border width (deprecated - hardcoded to 0)" +msgstr "" + +msgid "Brightness multiplier" +msgstr "" + +msgid "Choose override panel color" +msgstr "" + +msgid "Choose override popup color" +msgstr "" + +msgid "Color of the subtle border framing styled elements. Also used as the primary glow color for the Glow Effect system, and as a fallback for the background tint. Automatically populated from the active GTK theme accent or wallpaper extraction. Adjust opacity for softer or more defined borders." +msgstr "" + +msgid "Contrast multiplier" +msgstr "" + +msgid "Controls the brightness/visibility of the glow. Lower values (0.05-0.15) = subtle highlight, higher values (0.3-0.5) = prominent glossy effect. Demo example: 0.15 for balanced glossy look." +msgstr "" + +msgid "Controls the difference between light and dark areas in the visual effect. Higher values (above 1.0) enhance depth and sharpness, lower values soften the appearance. Applied as part of backdrop-filter — effective only on compositors that support it." +msgstr "" + +msgid "Controls the intended blur intensity. Higher values produce stronger diffusion (e.g. 30px+ for a foggy look), lower values give a sharper, more subtle appearance. Note: actual blur rendering depends on compositor support — on most Cinnamon setups this has no visible effect, but the setting is preserved for compatible environments." +msgstr "" + +msgid "Controls the spread of the shadow effect. Higher values (0.8-1.0) create a more pronounced shadow, while lower values (0.1-0.5) result in a softer, more diffused shadow." +msgstr "" + +msgid "Controls the spread/size of the glow effect. Higher values = more diffused glow. For panels, minimum glow size is 4px to maintain visual consistency." +msgstr "" + +msgid "Controls the transparency of the entire effect layer. Higher values (0.8-1.0) make the visual effect more prominent and solid, while lower values (0.1-0.5) create a lighter, more subtle appearance that blends with the background." +msgstr "" + +msgid "Dark/light mode override" +msgstr "" + +msgid "Debugging" +msgstr "" + +msgid "Detect accent colors from your current GTK theme and apply them to panel/popup color pickers and blur effects (border, background, shadow). Enables the panel color override so the detected accent is immediately applied to the panel. Popup menus inherit the panel color automatically. Disables wallpaper detection if active." +msgstr "" + +msgid "Detect and apply accent from current theme" +msgstr "" + +msgid "Effect layer opacity" +msgstr "" + +msgid "Effect template" +msgstr "" + +msgid "Enable debug logging" +msgstr "" + +msgid "Enable desktop context menu styling" +msgstr "" + +msgid "Enable detailed logging for troubleshooting extension issues. Check terminal output with 'journalctl -f' for detailed information." +msgstr "" + +msgid "Enable rounded corners on taskbar for modern appearance. May look odd at screen edges depending on your theme." +msgstr "" + +msgid "Enable wallpaper detection" +msgstr "" + +msgid "Extended UI Styling" +msgstr "" + +msgid "Extract colors from wallpaper" +msgstr "" + +msgid "Globally overrides dark/light mode detection for the entire extension — affects sidebar color fallback, accent color generation, and wallpaper extraction tone. 'Auto' follows the active GTK color scheme and theme name. 'Force dark' is recommended for mixed themes (e.g. Mint-Y-Aqua) where the panel is dark but the GTK theme has no -Dark suffix." +msgstr "" + +msgid "Glow blur size" +msgstr "" + +msgid "Glow Effect Controls" +msgstr "" + +msgid "Glow effect mode" +msgstr "" + +msgid "Glow intensity (opacity)" +msgstr "" + +msgid "Hide label" +msgstr "" + +msgid "Hide system tray indicator" +msgstr "" + +msgid "Hide the transparency control icon from the system tray. You can still access settings through Cinnamon Settings > Extensions." +msgstr "" + +msgid "Immediately extract colors from your current wallpaper and apply them to the panel and popup color pickers. If 'Wallpaper manages all shell colors' is also enabled, also updates border, tint, and shadow colors. Does not require wallpaper detection to be enabled." +msgstr "" + +msgid "Inset: Glow at edges/corners, darker center (classic). Outset: Glow at center, fade to edges (reverse). None: No glow effect." +msgstr "" + +msgid "Menu opacity" +msgstr "" + +msgid "Modifies the overall lightness of the effect layer. Increase above 1.0 for a brighter, illuminated look (ideal for light themes), decrease below 1.0 for darker, moodier tones. Applied as part of backdrop-filter — effective only on compositors that support it." +msgstr "" + +msgid "Override panel color" +msgstr "" + +msgid "Override popup color" +msgstr "" + +msgid "Panel opacity" +msgstr "" + +msgid "Rounded corners for panels and menus. Used as fallback when auto-detect fails or finds inconsistent values. Set to 0 for completely flat appearance." +msgstr "" + +msgid "Saturation multiplier" +msgstr "" + +msgid "Select an effect template to apply when using the 'Apply Selected Template' button. Each template defines preset values for all visual effect controls." +msgstr "" + +msgid "Select the background color to use for popup menus when 'Override popup color' is enabled. Supports transparency (alpha channel). Only active when 'Override popup color' is checked." +msgstr "" + +msgid "Select the background color to use for the panel when 'Override panel color' is enabled. Supports transparency (alpha channel). This color is saved independently of theme changes." +msgstr "" + +msgid "Sets the speed of visual effect transitions when settings change. Shorter durations (0.1-0.5s) create snappy, responsive transitions for quick adjustments, while longer ones (1-2s) provide smooth, elegant fades for a polished feel." +msgstr "" + +msgid "Shadow and glow color for box-shadow effects on all elements (panels, popups, notifications). Deep dark for dark themes or soft light for light themes. Automatically populated when accent colors are detected from the active GTK theme." +msgstr "" + +msgid "Shadow spread" +msgstr "" + +msgid "Show percentage" +msgstr "" + +msgid "Show percentage and time remaining" +msgstr "" + +msgid "Show time remaining" +msgstr "" + +msgid "Standard: panel color is a weighted average of the entire image tone (stable, smooth). Contrast: panel uses the darkest/lightest pixels from the image — stronger tonal expression, more sensitive to extreme pixels in the wallpaper." +msgstr "" + +msgid "Style Alt-Tab switcher elements" +msgstr "" + +msgid "Style desklet elements" +msgstr "" + +msgid "Style OSD (On-Screen Display) elements" +msgstr "" + +msgid "Style start menu sidebar" +msgstr "" + +msgid "Style system notifications" +msgstr "" + +msgid "Style tooltip elements" +msgstr "" + +msgid "System Tray Indicator" +msgstr "" + +msgid "Theme Integration" +msgstr "" + +msgid "Theme Settings" +msgstr "" + +msgid "This setting is deprecated and hardcoded to 0. Border effects are now handled by the Glow Effect system." +msgstr "" + +msgid "Transition duration" +msgstr "" + +msgid "Visual Effect Controls" +msgstr "" + +msgid "Visual Effects" +msgstr "" + +msgid "Wallpaper color extraction mode" +msgstr "" + +msgid "Wallpaper manages all shell colors (experimental)" +msgstr "" + +msgid "When enabled, every wallpaper change also updates blur and accent color settings (border color, background tint, shadow color). Panel and popup colors are always extracted regardless of this setting. Requires wallpaper detection to be active." +msgstr "" + +msgid "When OFF: Panel color is auto-detected from the active GTK theme (adapts automatically on theme change). When ON: Panel uses the color picker below. This does NOT affect popup color - see 'Override popup color' setting." +msgstr "" + +msgid "When OFF: Popup menus and some popup-based controls inherit the current panel color (auto-detected theme color, or the panel override color if that is enabled). When ON: Uses the color picker below, independent of panel color." +msgstr "" diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/po/en.po b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/en.po new file mode 100644 index 00000000..8c53262f --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/en.po @@ -0,0 +1,607 @@ +# Translation for csspanels@dr.drummie +# Copyright (C) 2026 THE csspanels@dr.drummie'S COPYRIGHT HOLDER +# This file is distributed under the same license as the csspanels@dr.drummie package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: csspanels@dr.drummie 2.0.7\n" +"Report-Msgid-Bugs-To: https://github.com/drdrummie/cinnamon-spices-extensions\n" +"POT-Creation-Date: 2026-04-19 12:41+0200\n" +"PO-Revision-Date: 2026-04-19 12:41+0200\n" +"Last-Translator: CSSPanels Extension Team \n" +"Language-Team: en \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Metadata strings from metadata.json +msgid "CSS Panels" +msgstr "CSS Panels" + +msgid "Dynamic control of panels and popups colors and visual effects" +msgstr "Dynamic control of panels and popups colors and visual effects" + +# Settings UI strings from settings-schema.json +msgid "10 minutes" +msgstr "10 minutes" + +msgid "10 seconds" +msgstr "10 seconds" + +msgid "15 seconds" +msgstr "15 seconds" + +msgid "1 minute" +msgstr "1 minute" + +msgid "2 minutes" +msgstr "2 minutes" + +msgid "30 seconds" +msgstr "30 seconds" + +msgid "5 minutes" +msgstr "5 minutes" + +msgid "5 seconds" +msgstr "5 seconds" + +msgid "Accent shadow/glow color" +msgstr "Accent shadow/glow color" + +msgid "" +"Accent tint color, automatically populated from the active GTK theme (low-" +"opacity variant of the accent color). Also used as a fallback glow color " +"when no border color is set. You can adjust it manually - use semi-" +"transparent colors for subtle color tinting." +msgstr "" +"Accent tint color, automatically populated from the active GTK theme (low-" +"opacity variant of the accent color). Also used as a fallback glow color " +"when no border color is set. You can adjust it manually - use semi-" +"transparent colors for subtle color tinting." + +msgid "" +"Adjusts color vibrancy of the transparent background. Values above 1.0 make " +"colors more vivid, below 1.0 create muted, desaturated tones. Applied as " +"part of backdrop-filter — effective only on compositors that support it." +msgstr "" +"Adjusts color vibrancy of the transparent background. Values above 1.0 make " +"colors more vivid, below 1.0 create muted, desaturated tones. Applied as " +"part of backdrop-filter — effective only on compositors that support it." + +#, fuzzy +msgid "" +"Adjust the transparency of all panels. Lower values create a more " +"transparent panel." +msgstr "" +"Adjust the transparency of the main panel (taskbar). Lower values create a " +"more transparent panel. Click the system tray icon to cycle through quick " +"presets." + +msgid "" +"Adjust the transparency of popup menus and some popup-based controls. " +"Creates a semi-transparent appearance combined with tint and glow effects. " +"Note: some Mint theme menus have a hardcoded background color that overrides " +"transparency — this is a theme limitation, not an extension bug." +msgstr "" + +msgid "Advanced Settings" +msgstr "Advanced Settings" + +msgid "Advanced Tools" +msgstr "Advanced Tools" + +msgid "Appearance Settings" +msgstr "Appearance Settings" + +msgid "Apply border radius to main panel" +msgstr "Apply border radius to main panel" + +msgid "" +"Apply popup color override to the start menu (menu@cinnamon.org) sidebar. " +"When disabled (default), sidebar uses the Cinnamon theme color (grey/white). " +"When enabled, sidebar matches the popup override color. Has no effect if the " +"original Cinnamon menu applet is not active." +msgstr "" +"Apply popup color override to the start menu (menu@cinnamon.org) sidebar. " +"When disabled (default), sidebar uses the Cinnamon theme color (grey/white). " +"When enabled, sidebar matches the popup override color. Has no effect if the " +"original Cinnamon menu applet is not active." + +msgid "Apply selected template" +msgstr "Apply selected template" + +msgid "" +"Apply the selected effect template to all visual effect controls. This will " +"update radius, saturation, contrast, brightness, background, border color, " +"border width, transition, and opacity to match the chosen template." +msgstr "" +"Apply the selected effect template to all visual effect controls. This will " +"update radius, saturation, contrast, brightness, background, border color, " +"border width, transition, and opacity to match the chosen template." + +msgid "" +"Apply transparency and visual effect styles to Alt-Tab window switcher. " +"Creates a consistent visual appearance for the application switcher." +msgstr "" +"Apply transparency and visual effect styles to Alt-Tab window switcher. " +"Creates a consistent visual appearance for the application switcher." + +msgid "" +"Apply transparency and visual effect styles to desktop right-click context " +"menus" +msgstr "" +"Apply transparency and visual effect styles to desktop right-click context " +"menus" + +#, fuzzy +msgid "" +"Apply transparency and visual effect styles to desktop widgets (desklets). " +"Creates a consistent visual appearance for desklets matching your panel " +"style." +msgstr "" +"Apply transparency and visual effect styles to Alt-Tab window switcher. " +"Creates a consistent visual appearance for the application switcher." + +msgid "" +"Apply transparency and visual effect styles to On-Screen Display elements " +"like volume sliders, brightness controls, and other overlay elements. " +"Creates a consistent visual appearance." +msgstr "" +"Apply transparency and visual effect styles to On-Screen Display elements " +"like volume sliders, brightness controls, and other overlay elements. " +"Creates a consistent visual appearance." + +msgid "" +"Apply transparency and visual effect styles to system notifications (volume, " +"brightness, etc.). This will make notifications match your panel's visual " +"style." +msgstr "" +"Apply transparency and visual effect styles to system notifications (volume, " +"brightness, etc.). This will make notifications match your panel's visual " +"style." + +msgid "" +"Apply transparency and visual effect styles to tooltip elements that appear " +"when hovering over panel items and other UI elements. Creates a consistent " +"visual appearance." +msgstr "" +"Apply transparency and visual effect styles to tooltip elements that appear " +"when hovering over panel items and other UI elements. Creates a consistent " +"visual appearance." + +msgid "Auto-apply accent colors on theme change" +msgstr "Auto-apply accent colors on theme change" + +msgid "" +"Automatically detect and apply accent colors from your active GTK theme " +"whenever you change themes. When disabled, you can still manually apply " +"accent colors using the button below. This allows you to keep your custom " +"colors while changing themes." +msgstr "" +"Automatically detect and apply accent colors from your active GTK theme " +"whenever you change themes. When disabled, you can still manually apply " +"accent colors using the button below. This allows you to keep your custom " +"colors while changing themes." + +msgid "" +"Automatically extract and apply colors from your current wallpaper to the " +"panel. Enabling this also activates the panel color override — without it, " +"extracted colors would be ignored. Popup menus automatically inherit the " +"panel color; enable 'Override popup color' separately if you want " +"independent popup customization. Use the 'Extract colors from wallpaper' " +"button for a manual one-time extraction." +msgstr "" +"Automatically extract and apply colors from your current wallpaper to the " +"panel. Enabling this also activates the panel color override — without it, " +"extracted colors would be ignored. Popup menus automatically inherit the " +"panel color; enable 'Override popup color' separately if you want " +"independent popup customization. Use the 'Extract colors from wallpaper' " +"button for a manual one-time extraction." + +msgid "Background color/tint" +msgstr "Background color/tint" + +msgid "Basic Appearance Controls" +msgstr "Basic Appearance Controls" + +msgid "Blur radius" +msgstr "Blur radius" + +msgid "Border color" +msgstr "Border color" + +msgid "Border radius" +msgstr "Border radius" + +msgid "Border width (deprecated - hardcoded to 0)" +msgstr "Border width (deprecated - hardcoded to 0)" + +msgid "Brightness multiplier" +msgstr "Brightness multiplier" + +msgid "Choose override panel color" +msgstr "Choose override panel color" + +msgid "Choose override popup color" +msgstr "Choose override popup color" + +msgid "" +"Color of the subtle border framing styled elements. Also used as the primary " +"glow color for the Glow Effect system, and as a fallback for the background " +"tint. Automatically populated from the active GTK theme accent or wallpaper " +"extraction. Adjust opacity for softer or more defined borders." +msgstr "" +"Color of the subtle border framing styled elements. Also used as the primary " +"glow color for the Glow Effect system, and as a fallback for the background " +"tint. Automatically populated from the active GTK theme accent or wallpaper " +"extraction. Adjust opacity for softer or more defined borders." + +msgid "Contrast multiplier" +msgstr "Contrast multiplier" + +msgid "" +"Controls the brightness/visibility of the glow. Lower values (0.05-0.15) = " +"subtle highlight, higher values (0.3-0.5) = prominent glossy effect. Demo " +"example: 0.15 for balanced glossy look." +msgstr "" +"Controls the brightness/visibility of the glow. Lower values (0.05-0.15) = " +"subtle highlight, higher values (0.3-0.5) = prominent glossy effect. Demo " +"example: 0.15 for balanced glossy look." + +msgid "" +"Controls the difference between light and dark areas in the visual effect. " +"Higher values (above 1.0) enhance depth and sharpness, lower values soften " +"the appearance. Applied as part of backdrop-filter — effective only on " +"compositors that support it." +msgstr "" +"Controls the difference between light and dark areas in the visual effect. " +"Higher values (above 1.0) enhance depth and sharpness, lower values soften " +"the appearance. Applied as part of backdrop-filter — effective only on " +"compositors that support it." + +msgid "" +"Controls the intended blur intensity. Higher values produce stronger " +"diffusion (e.g. 30px+ for a foggy look), lower values give a sharper, more " +"subtle appearance. Note: actual blur rendering depends on compositor support " +"— on most Cinnamon setups this has no visible effect, but the setting is " +"preserved for compatible environments." +msgstr "" +"Controls the intended blur intensity. Higher values produce stronger " +"diffusion (e.g. 30px+ for a foggy look), lower values give a sharper, more " +"subtle appearance. Note: actual blur rendering depends on compositor support " +"— on most Cinnamon setups this has no visible effect, but the setting is " +"preserved for compatible environments." + +msgid "" +"Controls the spread of the shadow effect. Higher values (0.8-1.0) create a " +"more pronounced shadow, while lower values (0.1-0.5) result in a softer, " +"more diffused shadow." +msgstr "" +"Controls the spread of the shadow effect. Higher values (0.8-1.0) create a " +"more pronounced shadow, while lower values (0.1-0.5) result in a softer, " +"more diffused shadow." + +msgid "" +"Controls the spread/size of the glow effect. Higher values = more diffused " +"glow. For panels, minimum glow size is 4px to maintain visual consistency." +msgstr "" +"Controls the spread/size of the glow effect. Higher values = more diffused " +"glow. For panels, minimum glow size is 4px to maintain visual consistency." + +msgid "" +"Controls the transparency of the entire effect layer. Higher values " +"(0.8-1.0) make the visual effect more prominent and solid, while lower " +"values (0.1-0.5) create a lighter, more subtle appearance that blends with " +"the background." +msgstr "" +"Controls the transparency of the entire effect layer. Higher values " +"(0.8-1.0) make the visual effect more prominent and solid, while lower " +"values (0.1-0.5) create a lighter, more subtle appearance that blends with " +"the background." + +msgid "Dark/light mode override" +msgstr "Dark/light mode override" + +msgid "Debugging" +msgstr "Debugging" + +msgid "" +"Detect accent colors from your current GTK theme and apply them to panel/" +"popup color pickers and blur effects (border, background, shadow). Enables " +"the panel color override so the detected accent is immediately applied to " +"the panel. Popup menus inherit the panel color automatically. Disables " +"wallpaper detection if active." +msgstr "" +"Detect accent colors from your current GTK theme and apply them to panel/" +"popup color pickers and blur effects (border, background, shadow). Enables " +"the panel color override so the detected accent is immediately applied to " +"the panel. Popup menus inherit the panel color automatically. Disables " +"wallpaper detection if active." + +msgid "Detect and apply accent from current theme" +msgstr "Detect and apply accent from current theme" + +msgid "Effect layer opacity" +msgstr "Effect layer opacity" + +msgid "Effect template" +msgstr "Effect template" + +msgid "Enable debug logging" +msgstr "Enable debug logging" + +msgid "Enable desktop context menu styling" +msgstr "Enable desktop context menu styling" + +msgid "" +"Enable detailed logging for troubleshooting extension issues. Check terminal " +"output with 'journalctl -f' for detailed information." +msgstr "" +"Enable detailed logging for troubleshooting extension issues. Check terminal " +"output with 'journalctl -f' for detailed information." + +msgid "" +"Enable rounded corners on taskbar for modern appearance. May look odd at " +"screen edges depending on your theme." +msgstr "" +"Enable rounded corners on taskbar for modern appearance. May look odd at " +"screen edges depending on your theme." + +msgid "Enable wallpaper detection" +msgstr "Enable wallpaper detection" + +msgid "Extended UI Styling" +msgstr "Extended UI Styling" + +msgid "Extract colors from wallpaper" +msgstr "Extract colors from wallpaper" + +msgid "" +"Globally overrides dark/light mode detection for the entire extension — " +"affects sidebar color fallback, accent color generation, and wallpaper " +"extraction tone. 'Auto' follows the active GTK color scheme and theme name. " +"'Force dark' is recommended for mixed themes (e.g. Mint-Y-Aqua) where the " +"panel is dark but the GTK theme has no -Dark suffix." +msgstr "" +"Globally overrides dark/light mode detection for the entire extension — " +"affects sidebar color fallback, accent color generation, and wallpaper " +"extraction tone. 'Auto' follows the active GTK color scheme and theme name. " +"'Force dark' is recommended for mixed themes (e.g. Mint-Y-Aqua) where the " +"panel is dark but the GTK theme has no -Dark suffix." + +msgid "Glow blur size" +msgstr "Glow blur size" + +msgid "Glow Effect Controls" +msgstr "Glow Effect Controls" + +msgid "Glow effect mode" +msgstr "Glow effect mode" + +msgid "Glow intensity (opacity)" +msgstr "Glow intensity (opacity)" + +msgid "Hide label" +msgstr "Hide label" + +msgid "Hide system tray indicator" +msgstr "Hide system tray indicator" + +msgid "" +"Hide the transparency control icon from the system tray. You can still " +"access settings through Cinnamon Settings > Extensions." +msgstr "" +"Hide the transparency control icon from the system tray. You can still " +"access settings through Cinnamon Settings > Extensions." + +msgid "" +"Immediately extract colors from your current wallpaper and apply them to the " +"panel and popup color pickers. If 'Wallpaper manages all shell colors' is " +"also enabled, also updates border, tint, and shadow colors. Does not require " +"wallpaper detection to be enabled." +msgstr "" +"Immediately extract colors from your current wallpaper and apply them to the " +"panel and popup color pickers. If 'Wallpaper manages all shell colors' is " +"also enabled, also updates border, tint, and shadow colors. Does not require " +"wallpaper detection to be enabled." + +msgid "" +"Inset: Glow at edges/corners, darker center (classic). Outset: Glow at " +"center, fade to edges (reverse). None: No glow effect." +msgstr "" +"Inset: Glow at edges/corners, darker center (classic). Outset: Glow at " +"center, fade to edges (reverse). None: No glow effect." + +msgid "Menu opacity" +msgstr "Menu opacity" + +msgid "" +"Modifies the overall lightness of the effect layer. Increase above 1.0 for a " +"brighter, illuminated look (ideal for light themes), decrease below 1.0 for " +"darker, moodier tones. Applied as part of backdrop-filter — effective only " +"on compositors that support it." +msgstr "" +"Modifies the overall lightness of the effect layer. Increase above 1.0 for a " +"brighter, illuminated look (ideal for light themes), decrease below 1.0 for " +"darker, moodier tones. Applied as part of backdrop-filter — effective only " +"on compositors that support it." + +msgid "Override panel color" +msgstr "Override panel color" + +msgid "Override popup color" +msgstr "Override popup color" + +msgid "Panel opacity" +msgstr "Panel opacity" + +msgid "" +"Rounded corners for panels and menus. Used as fallback when auto-detect " +"fails or finds inconsistent values. Set to 0 for completely flat appearance." +msgstr "" +"Rounded corners for panels and menus. Used as fallback when auto-detect " +"fails or finds inconsistent values. Set to 0 for completely flat appearance." + +msgid "Saturation multiplier" +msgstr "Saturation multiplier" + +msgid "" +"Select an effect template to apply when using the 'Apply Selected Template' " +"button. Each template defines preset values for all visual effect controls." +msgstr "" +"Select an effect template to apply when using the 'Apply Selected Template' " +"button. Each template defines preset values for all visual effect controls." + +msgid "" +"Select the background color to use for popup menus when 'Override popup " +"color' is enabled. Supports transparency (alpha channel). Only active when " +"'Override popup color' is checked." +msgstr "" +"Select the background color to use for popup menus when 'Override popup " +"color' is enabled. Supports transparency (alpha channel). Only active when " +"'Override popup color' is checked." + +msgid "" +"Select the background color to use for the panel when 'Override panel color' " +"is enabled. Supports transparency (alpha channel). This color is saved " +"independently of theme changes." +msgstr "" +"Select the background color to use for the panel when 'Override panel color' " +"is enabled. Supports transparency (alpha channel). This color is saved " +"independently of theme changes." + +msgid "" +"Sets the speed of visual effect transitions when settings change. Shorter " +"durations (0.1-0.5s) create snappy, responsive transitions for quick " +"adjustments, while longer ones (1-2s) provide smooth, elegant fades for a " +"polished feel." +msgstr "" +"Sets the speed of visual effect transitions when settings change. Shorter " +"durations (0.1-0.5s) create snappy, responsive transitions for quick " +"adjustments, while longer ones (1-2s) provide smooth, elegant fades for a " +"polished feel." + +msgid "" +"Shadow and glow color for box-shadow effects on all elements (panels, " +"popups, notifications). Deep dark for dark themes or soft light for light " +"themes. Automatically populated when accent colors are detected from the " +"active GTK theme." +msgstr "" +"Shadow and glow color for box-shadow effects on all elements (panels, " +"popups, notifications). Deep dark for dark themes or soft light for light " +"themes. Automatically populated when accent colors are detected from the " +"active GTK theme." + +msgid "Shadow spread" +msgstr "Shadow spread" + +msgid "Show percentage" +msgstr "Show percentage" + +msgid "Show percentage and time remaining" +msgstr "Show percentage and time remaining" + +msgid "Show time remaining" +msgstr "Show time remaining" + +msgid "" +"Standard: panel color is a weighted average of the entire image tone " +"(stable, smooth). Contrast: panel uses the darkest/lightest pixels from the " +"image — stronger tonal expression, more sensitive to extreme pixels in the " +"wallpaper." +msgstr "" +"Standard: panel color is a weighted average of the entire image tone " +"(stable, smooth). Contrast: panel uses the darkest/lightest pixels from the " +"image — stronger tonal expression, more sensitive to extreme pixels in the " +"wallpaper." + +msgid "Style Alt-Tab switcher elements" +msgstr "Style Alt-Tab switcher elements" + +#, fuzzy +msgid "Style desklet elements" +msgstr "Style tooltip elements" + +msgid "Style OSD (On-Screen Display) elements" +msgstr "Style OSD (On-Screen Display) elements" + +msgid "Style start menu sidebar" +msgstr "Style start menu sidebar" + +msgid "Style system notifications" +msgstr "Style system notifications" + +msgid "Style tooltip elements" +msgstr "Style tooltip elements" + +msgid "System Tray Indicator" +msgstr "System Tray Indicator" + +msgid "Theme Integration" +msgstr "Theme Integration" + +msgid "Theme Settings" +msgstr "Theme Settings" + +msgid "" +"This setting is deprecated and hardcoded to 0. Border effects are now " +"handled by the Glow Effect system." +msgstr "" +"This setting is deprecated and hardcoded to 0. Border effects are now " +"handled by the Glow Effect system." + +msgid "Transition duration" +msgstr "Transition duration" + +msgid "Visual Effect Controls" +msgstr "Visual Effect Controls" + +msgid "Visual Effects" +msgstr "Visual Effects" + +msgid "Wallpaper color extraction mode" +msgstr "Wallpaper color extraction mode" + +msgid "Wallpaper manages all shell colors (experimental)" +msgstr "Wallpaper manages all shell colors (experimental)" + +msgid "" +"When enabled, every wallpaper change also updates blur and accent color " +"settings (border color, background tint, shadow color). Panel and popup " +"colors are always extracted regardless of this setting. Requires wallpaper " +"detection to be active." +msgstr "" +"When enabled, every wallpaper change also updates blur and accent color " +"settings (border color, background tint, shadow color). Panel and popup " +"colors are always extracted regardless of this setting. Requires wallpaper " +"detection to be active." + +msgid "" +"When OFF: Panel color is auto-detected from the active GTK theme (adapts " +"automatically on theme change). When ON: Panel uses the color picker below. " +"This does NOT affect popup color - see 'Override popup color' setting." +msgstr "" +"When OFF: Panel color is auto-detected from the active GTK theme (adapts " +"automatically on theme change). When ON: Panel uses the color picker below. " +"This does NOT affect popup color - see 'Override popup color' setting." + +#, fuzzy +msgid "" +"When OFF: Popup menus and some popup-based controls inherit the current " +"panel color (auto-detected theme color, or the panel override color if that " +"is enabled). When ON: Uses the color picker below, independent of panel " +"color." +msgstr "" +"When OFF: Popup menus match the ACTUAL panel color (either original theme or " +"panel override picker). When ON: Popup menus use the color picker below, " +"independent of panel color." + +#~ msgid "" +#~ "Adjust the transparency of popup menus. Creates a semi-transparent " +#~ "appearance combined with tint and glow effects." +#~ msgstr "" +#~ "Adjust the transparency of popup menus. Creates a semi-transparent " +#~ "appearance combined with tint and glow effects." diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/po/hr.po b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/hr.po new file mode 100644 index 00000000..98603d11 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/po/hr.po @@ -0,0 +1,925 @@ +# Translation for csspanels@dr.drummie +# Copyright (C) 2026 THE csspanels@dr.drummie'S COPYRIGHT HOLDER +# This file is distributed under the same license as the csspanels@dr.drummie package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: csspanels@dr.drummie 2.0.7\n" +"Report-Msgid-Bugs-To: https://github.com/drdrummie/cinnamon-spices-extensions\n" +"POT-Creation-Date: 2026-04-19 12:41+0200\n" +"PO-Revision-Date: 2026-04-19 12:41+0200\n" +"Last-Translator: CSSPanels Extension Team \n" +"Language-Team: hr \n" +"Language: hr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Metadata strings from metadata.json +msgid "CSS Panels" +msgstr "CSS Panels" + +msgid "Dynamic control of panels and popups colors and visual effects" +msgstr "Dinamičko upravljanje bojama i vizualnim efektima ploča i iskačućih izbornika" + +# Settings UI strings from settings-schema.json +msgid "10 minutes" +msgstr "10 minuta" + +msgid "10 seconds" +msgstr "10 sekundi" + +msgid "15 seconds" +msgstr "15 sekundi" + +msgid "1 minute" +msgstr "1 minuta" + +msgid "2 minutes" +msgstr "2 minute" + +msgid "30 seconds" +msgstr "30 sekundi" + +msgid "5 minutes" +msgstr "5 minuta" + +msgid "5 seconds" +msgstr "5 sekundi" + +# Metadata strings from metadata.json +# Settings UI strings from settings-schema.json +msgid "Accent shadow/glow color" +msgstr "Boja sjene/sjaja naglaska" + +msgid "" +"Accent tint color, automatically populated from the active GTK theme (low-" +"opacity variant of the accent color). Also used as a fallback glow color " +"when no border color is set. You can adjust it manually - use semi-" +"transparent colors for subtle color tinting." +msgstr "" +"Tint boja naglaska, automatski popunjena iz aktivne GTK teme (varijanta " +"naglasne boje s niskom neprozirnosti). Koristi se i kao rezervna glow boja " +"kada nije postavljena boja obruba. Možete je ručno podešavati - koristite " +"poluprozirne boje za suptilno bojanje." + +msgid "" +"Adjusts color vibrancy of the transparent background. Values above 1.0 make " +"colors more vivid, below 1.0 create muted, desaturated tones. Applied as " +"part of backdrop-filter — effective only on compositors that support it." +msgstr "" +"Podešava živost boja prozirne pozadine. Vrijednosti iznad 1.0 čine boje " +"živahnijima, dok ispod 1.0 stvaraju prigušene, desaturirane tonove. " +"Primjenjuje se kao dio backdrop-filter — učinkovito samo na kompozitorima " +"koji to podržavaju." + +msgid "" +"Adjust the transparency of all panels. Lower values create a more " +"transparent panel." +msgstr "" +"Podešava prozirnost svih ploča. Niže vrijednosti stvaraju prozirnu ploču." + +msgid "" +"Adjust the transparency of popup menus and some popup-based controls. " +"Creates a semi-transparent appearance combined with tint and glow effects. " +"Note: some Mint theme menus have a hardcoded background color that overrides " +"transparency — this is a theme limitation, not an extension bug." +msgstr "" +"Podešava prozirnost skočnih izbornika i nekih kontrola temeljenih na " +"popup-u. Stvara poluproziran izgled kombiniran s tint i glow efektima. " +"Napomena: neki izbornici Mint tema imaju hardkodiranu boju pozadine koja " +"nadjačava prozirnost — ovo je ograničenje teme, ne greška ekstenzije." + +msgid "Advanced Settings" +msgstr "Napredne postavke" + +msgid "Advanced Tools" +msgstr "Napredni alati" + +msgid "Appearance Settings" +msgstr "Postavke izgleda" + +msgid "Apply border radius to main panel" +msgstr "Primijeni radijus obruba na glavnu ploču" + +msgid "" +"Apply popup color override to the start menu (menu@cinnamon.org) sidebar. " +"When disabled (default), sidebar uses the Cinnamon theme color (grey/white). " +"When enabled, sidebar matches the popup override color. Has no effect if the " +"original Cinnamon menu applet is not active." +msgstr "" +"Primijeni nadjačavanje boje skočnih izbornika na bočnu traku start izbornika " +"(menu@cinnamon.org). Kada je onemogućeno (zadano), bočna traka koristi boju " +"Cinnamon teme (siva/bijela). Kada je omogućeno, bočna traka odgovara boji " +"nadjačavanja skočnih izbornika. Nema učinka ako originalni Cinnamon applet " +"izbornika nije aktivan." + +msgid "Apply selected template" +msgstr "Primijeni odabrani predložak" + +msgid "" +"Apply the selected effect template to all visual effect controls. This will " +"update radius, saturation, contrast, brightness, background, border color, " +"border width, transition, and opacity to match the chosen template." +msgstr "" +"Primijeni odabrani predložak efekta na sve kontrole vizualnih efekata. Ovo " +"će ažurirati radijus, zasićenost, kontrast, svjetlinu, pozadinu, boju " +"obruba, širinu obruba, prijelaz i neprozirnost kako bi odgovarali odabranom " +"predlošku." + +msgid "" +"Apply transparency and visual effect styles to Alt-Tab window switcher. " +"Creates a consistent visual appearance for the application switcher." +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na Alt-Tab prebacivač " +"prozora. Stvara dosljedan vizualni izgled za prebacivač aplikacija." + +msgid "" +"Apply transparency and visual effect styles to desktop right-click context " +"menus" +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na kontekstne izbornike " +"radne površine (desni klik)" + +msgid "" +"Apply transparency and visual effect styles to desktop widgets (desklets). " +"Creates a consistent visual appearance for desklets matching your panel " +"style." +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na desklet widgete radne " +"površine. Stvara dosljedan vizualni izgled deskleta koji odgovara stilu " +"ploče." + +msgid "" +"Apply transparency and visual effect styles to On-Screen Display elements " +"like volume sliders, brightness controls, and other overlay elements. " +"Creates a consistent visual appearance." +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na elemente On-Screen " +"Display-a poput klizača glasnoće, kontrola svjetline i drugih preklopnih " +"elemenata. Stvara dosljedan vizualni izgled." + +msgid "" +"Apply transparency and visual effect styles to system notifications (volume, " +"brightness, etc.). This will make notifications match your panel's visual " +"style." +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na sistemske obavijesti " +"(glasnoća, svjetlina, itd.). Ovo će učiniti da obavijesti odgovaraju " +"vizualnom stilu vaše ploče." + +msgid "" +"Apply transparency and visual effect styles to tooltip elements that appear " +"when hovering over panel items and other UI elements. Creates a consistent " +"visual appearance." +msgstr "" +"Primijeni stilove prozirnosti i vizualnih efekata na elemente tooltip-a koji " +"se pojavljuju pri prelasku mišem preko stavki ploče i drugih UI elemenata. " +"Stvara dosljedan vizualni izgled." + +msgid "Auto-apply accent colors on theme change" +msgstr "Automatski primijeni naglasne boje pri promjeni teme" + +msgid "" +"Automatically detect and apply accent colors from your active GTK theme " +"whenever you change themes. When disabled, you can still manually apply " +"accent colors using the button below. This allows you to keep your custom " +"colors while changing themes." +msgstr "" +"Automatski otkriva i primjenjuje naglasne boje iz aktivne GTK teme svaki put " +"kad promijenite temu. Kada je onemogućeno, i dalje možete ručno primijeniti " +"naglasne boje gumbom ispod. Ovo vam omogućuje zadržavanje prilagođenih boja " +"pri mijenjanju tema." + +msgid "" +"Automatically extract and apply colors from your current wallpaper to the " +"panel. Enabling this also activates the panel color override — without it, " +"extracted colors would be ignored. Popup menus automatically inherit the " +"panel color; enable 'Override popup color' separately if you want " +"independent popup customization. Use the 'Extract colors from wallpaper' " +"button for a manual one-time extraction." +msgstr "" +"Automatski izvlači i primjenjuje boje s trenutne pozadine na ploču. " +"Omogućivanjem se aktivira nadjačavanje boje ploče — bez njega bi izvučene " +"boje bile zanemarene. Skočni izbornici automatski nasljeđuju boju ploče; " +"omogućite 'Nadjačaj boju iskačućeg izbornika' zasebno ako želite neovisno " +"prilagođavanje. Koristite gumb 'Izvuci boje s pozadine' za ručnu jednokratnu " +"ekstrakciju." + +msgid "Background color/tint" +msgstr "Boja pozadine/tint" + +msgid "Basic Appearance Controls" +msgstr "Osnovne kontrole izgleda" + +msgid "Blur radius" +msgstr "Radijus blura" + +msgid "Border color" +msgstr "Boja obruba" + +msgid "Border radius" +msgstr "Radijus obruba" + +msgid "Border width (deprecated - hardcoded to 0)" +msgstr "Širina obruba (zastarjelo - hardkodirano na 0)" + +msgid "Brightness multiplier" +msgstr "Množitelj svjetline" + +msgid "Choose override panel color" +msgstr "Odaberite boju za nadjačavanje boje panela" + +msgid "Choose override popup color" +msgstr "Odaberite boju za nadjačavanje boje iskačućeg izbornika" + +msgid "" +"Color of the subtle border framing styled elements. Also used as the primary " +"glow color for the Glow Effect system, and as a fallback for the background " +"tint. Automatically populated from the active GTK theme accent or wallpaper " +"extraction. Adjust opacity for softer or more defined borders." +msgstr "" +"Boja suptilnog obruba koji okružuje stilizirane elemente. Koristi se i kao " +"primarna glow boja za sustav Glow Effect, te kao zamjena za tint pozadine. " +"Automatski se popunjava iz naglaska aktivne GTK teme ili ekstrakcije " +"pozadine. Prilagodite neprozirnost za mekše ili definirane obrube." + +msgid "Contrast multiplier" +msgstr "Množitelj kontrasta" + +msgid "" +"Controls the brightness/visibility of the glow. Lower values (0.05-0.15) = " +"subtle highlight, higher values (0.3-0.5) = prominent glossy effect. Demo " +"example: 0.15 for balanced glossy look." +msgstr "" +"Kontrolira svjetlinu/vidljivost sjaja. Niže vrijednosti (0.05-0.15) = " +"suptilno isticanje, više vrijednosti (0.3-0.5) = istaknut sjajni efekt. " +"Primjer: 0.15 za uravnotežen sjajni izgled." + +msgid "" +"Controls the difference between light and dark areas in the visual effect. " +"Higher values (above 1.0) enhance depth and sharpness, lower values soften " +"the appearance. Applied as part of backdrop-filter — effective only on " +"compositors that support it." +msgstr "" +"Kontrolira razliku između svijetlih i tamnih područja u vizualnom efektu. " +"Više vrijednosti (iznad 1.0) poboljšavaju dubinu i oštrinu, niže vrijednosti " +"omekšavaju izgled. Primjenjuje se kao dio backdrop-filter — učinkovito samo " +"na kompozitorima koji to podržavaju." + +msgid "" +"Controls the intended blur intensity. Higher values produce stronger " +"diffusion (e.g. 30px+ for a foggy look), lower values give a sharper, more " +"subtle appearance. Note: actual blur rendering depends on compositor support " +"— on most Cinnamon setups this has no visible effect, but the setting is " +"preserved for compatible environments." +msgstr "" +"Kontrolira namjeravani intenzitet zamućenja. Više vrijednosti daju jaču " +"difuziju (npr. 30px+ za magličasti izgled), niže vrijednosti daju oštriji, " +"suptilniji izgled. Napomena: stvarno renderiranje zamućenja ovisi o podršci " +"kompozitora — na većini Cinnamon postava nema vidljivog efekta, ali postavka " +"se čuva za kompatibilna okruženja." + +msgid "" +"Controls the spread of the shadow effect. Higher values (0.8-1.0) create a " +"more pronounced shadow, while lower values (0.1-0.5) result in a softer, " +"more diffused shadow." +msgstr "" +"Kontrolira raspon efekta sjene. Više vrijednosti (0.8-1.0) stvaraju " +"izrazitiju sjenu, dok niže vrijednosti (0.1-0.5) rezultiraju mekšom, " +"difuznijom sjenom." + +msgid "" +"Controls the spread/size of the glow effect. Higher values = more diffused " +"glow. For panels, minimum glow size is 4px to maintain visual consistency." +msgstr "" +"Kontrolira raspon/veličinu glow efekta. Više vrijednosti = difuzniji sjaj. " +"Za ploče, minimalna veličina sjaja je 4px radi vizualne dosljednosti." + +msgid "" +"Controls the transparency of the entire effect layer. Higher values " +"(0.8-1.0) make the visual effect more prominent and solid, while lower " +"values (0.1-0.5) create a lighter, more subtle appearance that blends with " +"the background." +msgstr "" +"Kontrolira prozirnost cijelog sloja efekta. Više vrijednosti (0.8-1.0) čine " +"vizualni efekt istaknutijim i solidnijim, dok niže vrijednosti (0.1-0.5) " +"stvaraju lakši, suptilniji izgled koji se stapa s pozadinom." + +msgid "Dark/light mode override" +msgstr "Nadjačavanje tamnog/svijetlog načina" + +msgid "Debugging" +msgstr "Otklanjanje grešaka" + +msgid "" +"Detect accent colors from your current GTK theme and apply them to panel/" +"popup color pickers and blur effects (border, background, shadow). Enables " +"the panel color override so the detected accent is immediately applied to " +"the panel. Popup menus inherit the panel color automatically. Disables " +"wallpaper detection if active." +msgstr "" +"Otkriva naglasne boje iz trenutne GTK teme i primjenjuje ih na birače boja " +"ploče/izbornika i efekte zamućenja (obrub, pozadina, sjena). Aktivira " +"nadjačavanje boje ploče kako bi otkriveni naglasak bio odmah primijenjen. " +"Skočni izbornici automatski nasljeđuju boju ploče. Onemogućuje detekciju " +"pozadine ako je aktivna." + +msgid "Detect and apply accent from current theme" +msgstr "Otkrij i primijeni naglasak iz trenutne teme" + +msgid "Effect layer opacity" +msgstr "Neprozirnost sloja efekta" + +msgid "Effect template" +msgstr "Predložak efekta" + +msgid "Enable debug logging" +msgstr "Omogući debug zapisivanje" + +msgid "Enable desktop context menu styling" +msgstr "Omogući stiliziranje kontekstnih izbornika radne površine" + +msgid "" +"Enable detailed logging for troubleshooting extension issues. Check terminal " +"output with 'journalctl -f' for detailed information." +msgstr "" +"Omogući detaljno zapisivanje za otklanjanje problema s ekstenzijom. " +"Provjerite izlaz terminala s 'journalctl -f' za detaljne informacije." + +msgid "" +"Enable rounded corners on taskbar for modern appearance. May look odd at " +"screen edges depending on your theme." +msgstr "" +"Omogući zaobljene kutove na traci zadataka za moderan izgled. Može izgledati " +"čudno na rubovima ekrana ovisno o temi." + +msgid "Enable wallpaper detection" +msgstr "Omogući detekciju pozadine" + +msgid "Extended UI Styling" +msgstr "Prošireno stiliziranje UI-ja" + +msgid "Extract colors from wallpaper" +msgstr "Izvuci boje s pozadine" + +msgid "" +"Globally overrides dark/light mode detection for the entire extension — " +"affects sidebar color fallback, accent color generation, and wallpaper " +"extraction tone. 'Auto' follows the active GTK color scheme and theme name. " +"'Force dark' is recommended for mixed themes (e.g. Mint-Y-Aqua) where the " +"panel is dark but the GTK theme has no -Dark suffix." +msgstr "" +"Globalno nadjačava detekciju tamnog/svijetlog načina za cijelu ekstenziju — " +"utječe na zamjensku boju bočne trake, generiranje naglasnih boja i ton " +"ekstrakcije pozadine. 'Auto' prati aktivnu GTK shemu boja i naziv teme. " +"'Forsiraj tamno' preporučuje se za mješovite teme (npr. Mint-Y-Aqua) gdje je " +"ploča tamna, ali GTK tema nema sufiks -Dark." + +msgid "Glow blur size" +msgstr "Veličina sjaja" + +msgid "Glow Effect Controls" +msgstr "Kontrole efekta sjaja" + +msgid "Glow effect mode" +msgstr "Način efekta sjaja" + +msgid "Glow intensity (opacity)" +msgstr "Intenzitet sjaja (neprozirnost)" + +msgid "Hide label" +msgstr "Sakrij oznaku" + +msgid "Hide system tray indicator" +msgstr "Sakrij indikator sistemskog tray-a" + +msgid "" +"Hide the transparency control icon from the system tray. You can still " +"access settings through Cinnamon Settings > Extensions." +msgstr "" +"Sakrij ikonu kontrole prozirnosti iz sistemskog tray-a. Još uvijek možete " +"pristupiti postavkama putem Cinnamon Settings > Extensions." + +msgid "" +"Immediately extract colors from your current wallpaper and apply them to the " +"panel and popup color pickers. If 'Wallpaper manages all shell colors' is " +"also enabled, also updates border, tint, and shadow colors. Does not require " +"wallpaper detection to be enabled." +msgstr "" +"Odmah izvlači boje s trenutne pozadine i primjenjuje ih na birače boja ploče " +"i skočnih izbornika. Ako je i 'Pozadina upravlja svim bojama sučelja' " +"omogućeno, ažurira i boje obruba, tinta i sjene. Ne zahtijeva aktivnu " +"detekciju pozadine." + +msgid "" +"Inset: Glow at edges/corners, darker center (classic). Outset: Glow at " +"center, fade to edges (reverse). None: No glow effect." +msgstr "" +"Inset: Sjaj na rubovima/kutovima, tamniji centar (klasično). Outset: Sjaj u " +"centru, blijedi prema rubovima (obrnuto). Ništa: Bez efekta sjaja." + +msgid "Menu opacity" +msgstr "Neprozirnost izbornika" + +msgid "" +"Modifies the overall lightness of the effect layer. Increase above 1.0 for a " +"brighter, illuminated look (ideal for light themes), decrease below 1.0 for " +"darker, moodier tones. Applied as part of backdrop-filter — effective only " +"on compositors that support it." +msgstr "" +"Mijenja ukupnu svjetlinu sloja efekta. Povećajte iznad 1.0 za svjetliji, " +"osvijetljeniji izgled (idealno za svijetle teme), smanjite ispod 1.0 za " +"tamnije, raspoloženije tonove. Primjenjuje se kao dio backdrop-filter — " +"učinkovito samo na kompozitorima koji to podržavaju." + +msgid "Override panel color" +msgstr "Nadjačaj boju panela" + +msgid "Override popup color" +msgstr "Nadjačaj boju iskačućeg izbornika" + +msgid "Panel opacity" +msgstr "Neprozirnost ploče" + +msgid "" +"Rounded corners for panels and menus. Used as fallback when auto-detect " +"fails or finds inconsistent values. Set to 0 for completely flat appearance." +msgstr "" +"Zaobljeni kutovi za ploče i izbornike. Koristi se kao zamjena kada auto-" +"otkrivanje ne uspije ili pronađe nedosljedne vrijednosti. Postavite na 0 za " +"potpuno ravan izgled." + +msgid "Saturation multiplier" +msgstr "Množitelj zasićenosti" + +msgid "" +"Select an effect template to apply when using the 'Apply Selected Template' " +"button. Each template defines preset values for all visual effect controls." +msgstr "" +"Odaberite predložak efekta za primjenu kada koristite gumb 'Primijeni " +"odabrani predložak'. Svaki predložak definira unaprijed postavljene " +"vrijednosti za sve kontrole vizualnih efekata." + +msgid "" +"Select the background color to use for popup menus when 'Override popup " +"color' is enabled. Supports transparency (alpha channel). Only active when " +"'Override popup color' is checked." +msgstr "" +"Odaberite boju pozadine koja će se koristiti za skočne izbornike kada je " +"omogućeno 'Nadjačaj boju iskačućeg izbornika'. Podržava prozirnost (alfa " +"kanal). Aktivno samo kada je označeno 'Nadjačaj boju iskačućeg izbornika'." + +msgid "" +"Select the background color to use for the panel when 'Override panel color' " +"is enabled. Supports transparency (alpha channel). This color is saved " +"independently of theme changes." +msgstr "" +"Odaberite boju pozadine koja će se koristiti za ploču kada je omogućeno " +"'Nadjačaj boju panela'. Podržava prozirnost (alfa kanal). Ova boja se čuva " +"neovisno o promjenama teme." + +msgid "" +"Sets the speed of visual effect transitions when settings change. Shorter " +"durations (0.1-0.5s) create snappy, responsive transitions for quick " +"adjustments, while longer ones (1-2s) provide smooth, elegant fades for a " +"polished feel." +msgstr "" +"Postavlja brzinu prijelaza vizualnih efekata kada se postavke mijenjaju. " +"Kraća trajanja (0.1-0.5s) stvaraju brze, odzivne prijelaze za brza " +"podešavanja, dok duža (1-2s) pružaju glatke, elegantne fade-ove za uglađeni " +"osjećaj." + +msgid "" +"Shadow and glow color for box-shadow effects on all elements (panels, " +"popups, notifications). Deep dark for dark themes or soft light for light " +"themes. Automatically populated when accent colors are detected from the " +"active GTK theme." +msgstr "" +"Boja sjene i sjaja za box-shadow efekte na svim elementima (ploče, skočni " +"izbornici, obavijesti). Tamno za tamne teme ili svijetlo za svijetle teme. " +"Automatski se popunjava kada se otkriju naglasne boje iz aktivne GTK teme." + +msgid "Shadow spread" +msgstr "Raspon sjene" + +msgid "Show percentage" +msgstr "Prikaži postotak" + +msgid "Show percentage and time remaining" +msgstr "Prikaži postotak i preostalo vrijeme" + +msgid "Show time remaining" +msgstr "Prikaži preostalo vrijeme" + +msgid "" +"Standard: panel color is a weighted average of the entire image tone " +"(stable, smooth). Contrast: panel uses the darkest/lightest pixels from the " +"image — stronger tonal expression, more sensitive to extreme pixels in the " +"wallpaper." +msgstr "" +"Standard: boja ploče je ponderirana sredina ukupnog tona slike (stabilno, " +"glatko). Kontrast: ploča koristi najtamnije/najsvjetlije piksele iz slike — " +"jači tonski izraz, osjetljiviji na ekstremne piksele u pozadini." + +msgid "Style Alt-Tab switcher elements" +msgstr "Stiliziraj elemente Alt-Tab prebacivača" + +msgid "Style desklet elements" +msgstr "Stiliziraj desklet elemente" + +msgid "Style OSD (On-Screen Display) elements" +msgstr "Stiliziraj elemente OSD-a (On-Screen Display)" + +msgid "Style start menu sidebar" +msgstr "Stiliziraj bočnu traku start izbornika" + +msgid "Style system notifications" +msgstr "Stiliziraj sistemske obavijesti" + +msgid "Style tooltip elements" +msgstr "Stiliziraj elemente tooltip-a" + +msgid "System Tray Indicator" +msgstr "Indikator sistemskog tray-a" + +msgid "Theme Integration" +msgstr "Integracija teme" + +msgid "Theme Settings" +msgstr "Postavke teme" + +msgid "" +"This setting is deprecated and hardcoded to 0. Border effects are now " +"handled by the Glow Effect system." +msgstr "" +"Ova postavka je zastarjela i hardkodirana na 0. Efekti obruba sada se " +"upravljaju sustavom Glow Effect." + +msgid "Transition duration" +msgstr "Trajanje prijelaza" + +msgid "Visual Effect Controls" +msgstr "Kontrole vizualnih efekata" + +msgid "Visual Effects" +msgstr "Vizualni efekti" + +msgid "Wallpaper color extraction mode" +msgstr "Način ekstrakcije boja pozadine" + +msgid "Wallpaper manages all shell colors (experimental)" +msgstr "Pozadina upravlja svim bojama sučelja (eksperimentalno)" + +msgid "" +"When enabled, every wallpaper change also updates blur and accent color " +"settings (border color, background tint, shadow color). Panel and popup " +"colors are always extracted regardless of this setting. Requires wallpaper " +"detection to be active." +msgstr "" +"Kada je omogućeno, svaka promjena pozadine ažurira i postavke zamućenja i " +"naglasnih boja (boja obruba, tint pozadine, boja sjene). Boje ploče i " +"skočnih izbornika uvijek se izvlače bez obzira na ovu postavku. Zahtijeva " +"aktivnu detekciju pozadine." + +msgid "" +"When OFF: Panel color is auto-detected from the active GTK theme (adapts " +"automatically on theme change). When ON: Panel uses the color picker below. " +"This does NOT affect popup color - see 'Override popup color' setting." +msgstr "" +"Kada je ISKLJUČENO: Boja ploče se automatski otkriva iz aktivne GTK teme " +"(prilagođava se automatski pri promjeni teme). Kada je UKLJUČENO: Ploča " +"koristi birač boja ispod. Ovo NE utječe na boju skočnih izbornika - " +"pogledajte postavku 'Nadjačaj boju iskačućeg izbornika'." + +msgid "" +"When OFF: Popup menus and some popup-based controls inherit the current " +"panel color (auto-detected theme color, or the panel override color if that " +"is enabled). When ON: Uses the color picker below, independent of panel " +"color." +msgstr "" +"Kada je ISKLJUČENO: Skočni izbornici i neke kontrole temeljene na popup-u " +"nasljeđuju trenutnu boju ploče (automatski otkrivena boja teme ili boja " +"nadjačavanja ploče ako je omogućena). Kada je UKLJUČENO: Koristi birač boja " +"ispod, neovisno o boji ploče." + +#~ msgid "" +#~ "Adjust the transparency of popup menus. Creates a semi-transparent " +#~ "appearance combined with tint and glow effects." +#~ msgstr "" +#~ "Podešava prozirnost skočnih izbornika. Stvara poluproziran izgled " +#~ "kombiniran s tint i glow efektima." + +#~ msgid "" +#~ "Adds a tint overlay to the blur effect. Use semi-transparent colors (e." +#~ "g., light blue for cool tones or warm orange for cozy feel) to customize " +#~ "the glass appearance - higher opacity for stronger tint, lower for subtle " +#~ "enhancement." +#~ msgstr "" +#~ "Dodaje tint premaz na blur efekt. Koristite poluprozirne boje (npr. " +#~ "svijetloplavu za hladne tonove ili toplu narančastu za ugodan osjećaj) za " +#~ "prilagodbu staklastog izgleda - viša neprozirnost za jači tint, niža za " +#~ "suptilno poboljšanje." + +#~ msgid "" +#~ "Adjusts color vibrancy in the blurred background. Values above 1.0 make " +#~ "colors more vivid and lively (for brighter, more energetic glass effect), " +#~ "while below 1.0 create muted, desaturated tones for a softer, more " +#~ "elegant appearance." +#~ msgstr "" +#~ "Podešava živost boja u zamagljenoj pozadini. Vrijednosti iznad 1.0 čine " +#~ "boje živahnijima i živahnijima (za svjetliji, energičniji staklasti " +#~ "efekt), dok ispod 1.0 stvaraju prigušene, desaturirane tonove za mekši, " +#~ "elegantniji izgled." + +#~ msgid "" +#~ "Adjust the transparency of popup menus. Creates modern frosted glass " +#~ "appearance when combined with blur effects." +#~ msgstr "" +#~ "Podešava prozirnost skočnih izbornika. Stvara moderan zamrznuti staklasti " +#~ "izgled kada se kombinira s blur efektima." + +#~ msgid "" +#~ "Adjust the transparency of the main panel (taskbar). Lower values create " +#~ "more glass-like effect. Click the system tray icon to cycle through quick " +#~ "presets." +#~ msgstr "" +#~ "Podešava prozirnost glavne ploče (trake zadataka). Niže vrijednosti " +#~ "stvaraju više staklasti efekt. Kliknite ikonu u sistemskoj traci za brzo " +#~ "prebacivanje kroz unaprijed postavljene vrijednosti." + +#~ msgid "" +#~ "Apply the selected blur template to all blur effect controls. This will " +#~ "update radius, saturation, contrast, brightness, background, border " +#~ "color, border width, transition, and opacity to match the chosen template." +#~ msgstr "" +#~ "Primijeni odabrani blur predložak na sve kontrole blur efekata. Ovo će " +#~ "ažurirati radijus, zasićenost, kontrast, svjetlinu, pozadinu, boju " +#~ "obruba, širinu obruba, prijelaz i neprozirnost kako bi odgovarali " +#~ "odabranom predlošku." + +#~ msgid "Auto-detect theme border radius" +#~ msgstr "Automatsko otkrivanje radijusa obruba teme" + +#~ msgid "" +#~ "Automatically detect and use border-radius from current theme for " +#~ "consistent appearance. When enabled, the extension analyzes your theme to " +#~ "match its design." +#~ msgstr "" +#~ "Automatski otkrij i koristi radijus obruba iz trenutne teme za dosljedan " +#~ "izgled. Kada je omogućeno, ekstenzija analizira vašu temu kako bi se " +#~ "uskladila s njezinim dizajnom." + +#~ msgid "Basic Transparency Controls" +#~ msgstr "Osnovne kontrole prozirnosti" + +#~ msgid "Blur Effects" +#~ msgstr "Blur efekti" + +#~ msgid "Blur opacity" +#~ msgstr "Neprozirnost blura" + +#~ msgid "Blur Template" +#~ msgstr "Blur predložak" + +#~ msgid "Blur transition duration" +#~ msgstr "Trajanje prijelaza blura" + +#~ msgid "Border Radius" +#~ msgstr "Radijus obruba" + +#~ msgid "Border width" +#~ msgstr "Širina obruba" + +#~ msgid "" +#~ "Controls the difference between light and dark areas in the blur. Higher " +#~ "values (above 1.0) enhance sharpness and depth for a more defined, modern " +#~ "look, while lower values soften the effect for a smoother, less harsh " +#~ "glass appearance." +#~ msgstr "" +#~ "Kontrolira razliku između svijetlih i tamnih područja u blur-u. Više " +#~ "vrijednosti (iznad 1.0) poboljšavaju oštrinu i dubinu za definirani, " +#~ "moderan izgled, dok niže vrijednosti omekšavaju efekt za glađi, manje " +#~ "oštar staklasti izgled." + +#~ msgid "" +#~ "Controls the intensity of the blur effect. Higher values create a " +#~ "stronger, more diffused glass-like appearance (e.g., 30px+ for foggy " +#~ "effect), while lower values (1-10px) produce sharper, more subtle " +#~ "blurring for a cleaner look." +#~ msgstr "" +#~ "Kontrolira intenzitet blur efekta. Više vrijednosti stvaraju jači, " +#~ "difuzniji staklasti izgled (npr. 30px+ za magličasti efekt), dok niže " +#~ "vrijednosti (1-10px) proizvode oštriji, suptilniji blur za čišći izgled." + +#~ msgid "" +#~ "Controls the transparency of the entire blur layer. Higher values " +#~ "(0.8-1.0) make the glass effect more prominent and solid, while lower " +#~ "values (0.1-0.5) create a lighter, more ethereal appearance that blends " +#~ "seamlessly with the background." +#~ msgstr "" +#~ "Kontrolira prozirnost cijelog blur sloja. Više vrijednosti (0.8-1.0) čine " +#~ "staklasti efekt istaknutijim i solidnijim, dok niže vrijednosti (0.1-0.5) " +#~ "stvaraju lakši, eteričniji izgled koji se besprijekorno stapa s pozadinom." + +#~ msgid "Custom Blur Settings" +#~ msgstr "Prilagođene postavke blura" + +#~ msgid "" +#~ "Defines the thickness of the border around blurred elements. Thicker " +#~ "borders (3-5px) create a more prominent frame for emphasis, while thinner " +#~ "(0-1px) or none (0px) give a seamless, integrated glass look." +#~ msgstr "" +#~ "Definira debljinu obruba oko zamagljenih elemenata. Deblji obrubi (3-5px) " +#~ "stvaraju istaknutiji okvir za naglasak, dok tanji (0-1px) ili nijedan " +#~ "(0px) daju besprijekoran, integrirani staklasti izgled." + +#~ msgid "" +#~ "Display control icon in system tray for quick access to transparency " +#~ "settings. Click to open extension preferences." +#~ msgstr "" +#~ "Prikaži ikonu kontrole u sistemskoj traci za brz pristup postavkama " +#~ "prozirnosti. Kliknite za otvaranje postavki ekstenzije." + +#~ msgid "Menu Opacity" +#~ msgstr "Neprozirnost izbornika" + +#~ msgid "" +#~ "Modifies the overall lightness of the blurred layer. Increase above 1.0 " +#~ "for a brighter, more illuminated glass effect (ideal for light themes), " +#~ "or decrease below 1.0 for darker, moodier tones that blend better with " +#~ "dark backgrounds." +#~ msgstr "" +#~ "Mijenja ukupnu svjetlinu zamagljenog sloja. Povećajte iznad 1.0 za " +#~ "svjetliji, osvijetljeniji staklasti efekt (idealno za svijetle teme), ili " +#~ "smanjite ispod 1.0 za tamnije, raspoloženije tonove koji se bolje stapaju " +#~ "s tamnim pozadinama." + +#~ msgid "Panel Appearance" +#~ msgstr "Izgled ploče" + +#~ msgid "Panel Opacity" +#~ msgstr "Neprozirnost ploče" + +#~ msgid "" +#~ "Select a blur template to apply when using the 'Apply Selected Template' " +#~ "button. Each template defines preset values for all blur effect controls." +#~ msgstr "" +#~ "Odaberite blur predložak za primjenu kada koristite gumb 'Primijeni " +#~ "odabrani predložak'. Svaki predložak definira unaprijed postavljene " +#~ "vrijednosti za sve kontrole blur efekata." + +#~ msgid "" +#~ "Select the background color to use for popup menus when 'Override popup " +#~ "color' is enabled. Supports transparency (alpha channel)." +#~ msgstr "" +#~ "Odaberite boju pozadine koja će se koristiti za iskačuće izbornike kada " +#~ "je omogućeno 'Nadjačaj boju iskačućeg izbornika'. Podržava prozirnost " +#~ "(alfa kanal)." + +#~ msgid "" +#~ "Select the background color to use for the panel when 'Override panel " +#~ "color' is enabled. Supports transparency (alpha channel)." +#~ msgstr "" +#~ "Odaberite boju pozadine koja će se koristiti za panel kada je omogućeno " +#~ "'Nadjačaj boju panela'. Podržava prozirnost (alfa kanal)." + +#~ msgid "" +#~ "Sets the color of the subtle border framing the blurred elements. Choose " +#~ "white/light colors for a clean, modern edge, or darker tones for better " +#~ "contrast - adjust opacity for softer or more defined borders." +#~ msgstr "" +#~ "Postavlja boju suptilnog obruba koji okružuje zamagljene elemente. " +#~ "Odaberite bijele/svijetle boje za čist, moderan rub, ili tamnije tonove " +#~ "za bolji kontrast - prilagodite neprozirnost za mekše ili definirane " +#~ "obrube." + +#~ msgid "" +#~ "Sets the speed of blur effect animations when settings change. Shorter " +#~ "durations (0.1-0.5s) create snappy, responsive transitions for quick " +#~ "adjustments, while longer ones (1-2s) provide smooth, elegant fades for a " +#~ "polished feel." +#~ msgstr "" +#~ "Postavlja brzinu animacija blur efekta kada se postavke mijenjaju. Kraća " +#~ "trajanja (0.1-0.5s) stvaraju brze, odzivne prijelaze za brza podešavanja, " +#~ "dok duža (1-2s) pružaju glatke, elegantne fade-ove za uglađeni osjećaj." + +#~ msgid "Show system tray indicator" +#~ msgstr "Prikaži indikator sistemskog tray-a" + +#~ msgid "Transparency Settings" +#~ msgstr "Postavke prozirnosti" + +#~ msgid "" +#~ "When enabled, use the selected override color for popup menu backgrounds " +#~ "instead of the panel color. Requires 'Override panel color' to be enabled " +#~ "for full effect." +#~ msgstr "" +#~ "Kada je omogućeno, koristi odabranu boju za nadjačavanje boje pozadine " +#~ "iskačućih izbornika umjesto boje panela. Za puni učinak potrebno je " +#~ "omogućiti 'Nadjačaj boju panela'." + +#~ msgid "" +#~ "When enabled, use the selected override color for the panel background. " +#~ "If 'Override popup color' is disabled, this color is also used for popup " +#~ "menus." +#~ msgstr "" +#~ "Kada je omogućeno, koristi odabranu boju za nadjačavanje boje pozadine " +#~ "panela. Ako 'Nadjačaj boju iskačućeg izbornika' nije omogućeno, ta se " +#~ "boja koristi i za iskačuće izbornike." + +#~ msgid "" +#~ "Dynamic control of panel and popups transparency and blur effects - based " +#~ "on BlurCinnamon@klangman" +#~ msgstr "" +#~ "Dinamičko upravljanje efektima prozirnosti i zamućenja panela i iskačućih " +#~ "izbornika - temeljeno na BlurCinnamon@klangman" + +#~ msgid "" +#~ "Apply transparency and blur effects to On-Screen Display elements like " +#~ "volume sliders, brightness controls, and other overlay elements. Creates " +#~ "a consistent glass appearance." +#~ msgstr "" +#~ "Primijeni efekte prozirnosti i zamućenja na elemente On-Screen Display-a " +#~ "poput klizača glasnoće, kontrola svjetline i drugih preklopnih elemenata. " +#~ "Stvara dosljedan staklasti izgled." + +#~ msgid "" +#~ "Apply transparency and blur effects to system notifications (volume, " +#~ "brightness, etc.). This will make notifications match your panel's glass " +#~ "morphism style." +#~ msgstr "" +#~ "Primijeni efekte prozirnosti i zamućenja na sistemske obavijesti " +#~ "(glasnoća, svjetlina, itd.). Ovo će učiniti da obavijesti odgovaraju " +#~ "staklastom stilu vaše ploče." + +#~ msgid "" +#~ "Apply transparency and blur effects to Alt-Tab window switcher. Creates a " +#~ "consistent glass appearance for the application switcher." +#~ msgstr "" +#~ "Primijeni efekte prozirnosti i zamućenja na Alt-Tab prebacivač prozora. " +#~ "Stvara dosljedan staklasti izgled za prebacivač aplikacija." + +#~ msgid "" +#~ "Apply transparency and blur effects to desktop right-click context menus" +#~ msgstr "" +#~ "Primijeni efekte prozirnosti i zamućenja na kontekstne izbornike radne " +#~ "površine (desni klik)" + +#~ msgid "" +#~ "Apply transparency and blur effects to tooltip elements that appear when " +#~ "hovering over panel items and other UI elements. Creates a consistent " +#~ "glass appearance." +#~ msgstr "" +#~ "Primijeni efekte prozirnosti i zamućenja na elemente tooltip-a koji se " +#~ "pojavljuju pri prelasku mišem preko stavki panela i drugih UI elemenata. " +#~ "Stvara dosljedan staklasti izgled." + +#~ msgid "Appearance settings" +#~ msgstr "Postavke izgleda" + +#~ msgid "" +#~ "Automatically extract and apply colors from your current wallpaper to " +#~ "panel and popup menus. Enabling this also activates panel and popup color " +#~ "overrides — without them, extracted colors would be ignored. Use the " +#~ "'Extract colors from wallpaper' button for a manual one-time extraction." +#~ msgstr "" +#~ "Automatski izvlači i primjenjuje boje s trenutne pozadine na ploču i " +#~ "skočne izbornike. Omogućivanjem se aktiviraju i nadjačavanja boja ploče i " +#~ "izbornika — bez njih bi izvučene boje bile zanemarene. Koristite gumb " +#~ "'Izvuci boje s pozadine' za ručnu jednokratnu ekstrakciju." + +#~ msgid "" +#~ "Detect accent colors from your current GTK theme and apply them to blur-" +#~ "border-color, blur-background, and accent-shadow-color. Also resets panel/" +#~ "popup color overrides and disables wallpaper detection, providing a clean " +#~ "theme-based color baseline." +#~ msgstr "" +#~ "Otkriva naglasne boje iz trenutne GTK teme i primjenjuje ih na blur-" +#~ "border-color, blur-background i accent-shadow-color. Također poništava " +#~ "nadjačavanja boja ploče/izbornika i onemogućuje detekciju pozadine, " +#~ "pružajući čistu osnovu boja temeljenu na temi." + +#~ msgid "Effect Template" +#~ msgstr "Predložak efekta" + +#~ msgid "Glow Blur Size" +#~ msgstr "Veličina sjaja" + +#~ msgid "Glow Effect Mode" +#~ msgstr "Način efekta sjaja" + +#~ msgid "Glow Intensity (Opacity)" +#~ msgstr "Intenzitet sjaja (neprozirnost)" + +#~ msgid "" +#~ "Immediately extract colors from your current wallpaper and apply them to " +#~ "panel, popup, border, tint, and shadow. Always runs in full-auto mode " +#~ "(updates all color settings). Does not require wallpaper detection to be " +#~ "enabled." +#~ msgstr "" +#~ "Odmah izvlači boje s trenutne pozadine i primjenjuje ih na ploču, skočne " +#~ "izbornike, obrub, tint i sjenu. Uvijek radi u potpuno automatskom načinu " +#~ "(ažurira sve postavke boja). Ne zahtijeva da detekcija pozadine bude " +#~ "omogućena." diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/popupStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/popupStyler.js new file mode 100644 index 00000000..e1ace10b --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/popupStyler.js @@ -0,0 +1,573 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const Applet = imports.ui.applet; +const Panel = imports.ui.panel; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const StylerBase = require("./stylerBase"); +const { TRAVERSAL, CSS_CLASSES, TIMING, STYLING, DEFAULT_COLORS } = require("./constants"); + +const APPMENU_SIDEBAR_SEARCH_DEPTH = 5; + +/** + * Popup Styler handles popup menu transparency and blur effects + * Uses monkey patching to intercept popup menu creation + */ +class PopupStyler extends StylerBase { + /** + * Initialize Popup Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "PopupStyler"); + this.originalPopupMenuOpen = null; + this.originalPopupSubMenuOpen = null; + this.activePopupMenus = new Map(); + } + + /** + * Enable popup menu styling + */ + enable() { + super.enable(); + this.setupPopupMenuMonkeyPatch(); + this.debugLog("Popup styler enabled"); + } + + /** + * Disable popup menu styling + */ + disable() { + this.restorePopupMenuMonkeyPatch(); + this.cleanupActiveMenus(); + this.debugLog("Popup styler disabled"); + super.disable(); + } + + /** + * Setup monkey patching for popup menu handling + */ + setupPopupMenuMonkeyPatch() { + try { + this.originalPopupMenuOpen = PopupMenu.PopupMenu.prototype.open; + this.originalPopupSubMenuOpen = PopupMenu.PopupSubMenu.prototype.open; + let self = this; + + // Store patched function references to enable idempotent restore + this._patchedPopupMenuOpen = function (animate) { + self.debugLog("Intercepted popup menu open event"); + if (self.shouldStyleMenu(this)) { + self.stylePopupMenu(this); + } + self.originalPopupMenuOpen.call(this, animate); + }; + this._patchedPopupSubMenuOpen = function (animate) { + self.debugLog("Intercepted popup sub-menu open event"); + if (self.shouldStyleMenu(this)) { + self.stylePopupMenu(this); + } + self.originalPopupSubMenuOpen.call(this, animate); + }; + + PopupMenu.PopupMenu.prototype.open = this._patchedPopupMenuOpen; + PopupMenu.PopupSubMenu.prototype.open = this._patchedPopupSubMenuOpen; + + this.debugLog("Popup menu monkey patch setup successfully"); + } catch (e) { + this.debugLog("Error setting up popup menu monkey patch:", e); + } + } + + /** + * Check if a menu should be styled + * @param {Object} menu - The popup menu to check + * @returns {boolean} True if menu should be styled + */ + shouldStyleMenu(menu) { + return ( + menu instanceof Applet.AppletPopupMenu || + menu instanceof Applet.AppletContextMenu || + menu instanceof Panel.PanelContextMenu || + menu instanceof PopupMenu.PopupMenu || + menu instanceof PopupMenu.PopupSubMenu || + menu.sourceActor === this.extension.systemIndicator.indicator || + (menu.sourceActor && menu.sourceActor.get_parent && this.isElementInPanel(menu.sourceActor)) || + (menu.actor && + menu.actor.get_parent() && + menu.actor.get_parent().get_style_class_name && + menu.actor.get_parent().get_style_class_name().includes("panel")) || + (menu.box && menu.actor) + ); + } + + /** + * Check if an element is contained within a panel + * @param {Clutter.Actor} element - The element to check + * @returns {boolean} True if element is within a panel + */ + isElementInPanel(element) { + if (!element) return false; + + let current = element; + let depth = 0; + const MAX_DEPTH = TRAVERSAL.MAX_DEPTH_PANEL; + + while (current && depth < MAX_DEPTH) { + // Check if current element is a panel + if (current === Main.panel.actor || (Main.panel2 && current === Main.panel2.actor)) { + //this.extension.debugLog("Element found in panel at depth:", depth); + return true; + } + + // Check style classes + if (current.get_style_class_name) { + let styleClasses = current.get_style_class_name(); + if ( + styleClasses && + (styleClasses.includes(CSS_CLASSES.PANEL) || + styleClasses.includes(CSS_CLASSES.PANEL_BUTTON) || + styleClasses.includes(CSS_CLASSES.APPLET_BOX)) + ) { + //this.extension.debugLog("Element found in panel via style class:", styleClasses); + return true; + } + } + + current = current.get_parent(); + depth++; + } + + return false; + } + + /** + * Apply styles to popup menus + * @param {Object} menu - The popup menu to style + */ + stylePopupMenu(menu) { + if (!menu || !menu.actor) { + this.debugLog("stylePopupMenu: Invalid menu or actor"); + return; + } + + try { + //this.extension.debugLog("stylePopupMenu: Styling popup menu"); + + if (!this.activePopupMenus.has(menu)) { + let originalData = { + boxStyle: menu.box ? menu.box.get_style() : null, + actorStyle: menu.actor.get_style(), + boxColor: menu.box ? menu.box.get_background_color() : null, + boxStyleClasses: menu.box ? menu.box.get_style_class_name() : null, + actorStyleClasses: menu.actor.get_style_class_name(), + sidebarActor: null, + sidebarStyle: null, + }; + + const sidebarActor = this._findAppMenuSidebar(menu.actor); + if (sidebarActor) { + originalData.sidebarActor = sidebarActor; + originalData.sidebarStyle = sidebarActor.get_style(); + } + + this.activePopupMenus.set(menu, originalData); + + // Connect to close signals for cleanup + this.setupMenuCloseHandlers(menu); + } + + let menuColor = this.extension.themeDetector.getEffectivePopupColor(); + + this.extension.cssManager.updateAllVariables(); + + // Build configuration object for template generation + const isSubMenu = menu instanceof PopupMenu.PopupSubMenu; + // Detect attached orientation for main menus; submenus always get uniform radius + const attachedOrientation = (!isSubMenu && menu._orientation !== undefined && menu._orientation !== null) + ? menu._orientation + : null; + const baseRadius = this.getAdjustedBorderRadius("menu"); + const config = { + backgroundColor: `rgba(${menuColor.r}, ${menuColor.g}, ${menuColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: baseRadius, + borderRadiusCSS: this.getAttachedBorderRadiusCSS(baseRadius, attachedOrientation), + blurRadius: this.getAdjustedBlurRadius("menu"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + shadowMode: isSubMenu ? 'sides' : undefined, + }; + + // Generate CSS via template manager + const popupCSS = this.extension.blurTemplateManager.generatePopupCSS(config); + this.debugLog("Applying popup menu styles via template generation"); + + // Apply to both box and actor + if (menu.box) { + menu.box.set_style(popupCSS); + } + // Allow side shadows to bleed outside sub-menu container bounds; symmetric margins for balanced appearance + const shadowSpread = this.extension.settings.getValue("shadow-spread") || 0.4; + const sideMargin = Math.round(shadowSpread * STYLING.SHADOW_BASE_MULTIPLIER) + STYLING.SUBMENU_MARGIN_OFFSET; + const actorCSS = isSubMenu ? popupCSS + ` margin-right: ${sideMargin}px; margin-left: ${sideMargin}px;` : popupCSS; + menu.actor.set_style(actorCSS); + + // Wire hover hooks on popup-menu-item actors for our custom hover color + if (this.extension.hoverStyleManager) { + this.extension.hoverStyleManager.hookPopupMenu(menu.actor); + } + + const storedData = this.activePopupMenus.get(menu); + if (storedData && storedData.sidebarActor) { + this._styleAppMenuSidebar(storedData.sidebarActor).catch(e => + this.debugLog(`Error styling appmenu sidebar: ${e.message}`) + ); + } + } catch (e) { + this.debugLog("Error styling popup menu:", e); + } + } + + /** + * Apply style to menu elements (box and actor) - DEPRECATED, kept for compatibility + * Now handled directly in stylePopupMenu() + * @param {Object} menu - The popup menu + * @param {string} style - The CSS style to apply + */ + applyStyleToMenuElements(menu, style) { + if (menu.box) { + menu.box.set_style(style); + } + + menu.actor.set_style(style); + } + + /** + * Setup close handlers for proper menu cleanup + * @param {Object} menu - The popup menu + */ + setupMenuCloseHandlers(menu) { + if (!menu._transparencyCloseConnection) { + // Track connection for automatic cleanup + this.addConnection(menu, "menu-animated-closed", () => { + this.cleanupPopupMenu(menu); + }); + menu._transparencyCloseConnection = true; // Mark as connected + } + + if (!menu._transparencyStateConnection) { + // Track connection for automatic cleanup + this.addConnection(menu, "open-state-changed", (menu, open) => { + if (!open) { + this.cleanupPopupMenu(menu); + } + }); + menu._transparencyStateConnection = true; // Mark as connected + } + } + + /** + * Clean up styling for a popup menu + * @param {Object} menu - The popup menu to clean up + */ + cleanupPopupMenu(menu) { + try { + let originalData = this.activePopupMenus.get(menu); + if (originalData) { + // Use fade-out to prevent flicker when restoring original style + if (menu.box && menu.box.get_stage()) { + this.restoreElementWithFade(menu.box, true, () => { + this.restorePopupMenuStyle(menu, originalData); + this.debugLog("Popup menu restored with fade-out transition"); + }); + } else { + // Fallback to immediate restore if box is not available + this.restorePopupMenuStyle(menu, originalData); + this.debugLog("Popup menu restored immediately (no stage)"); + } + + // Disconnect hover hooks from this menu's items + if (this.extension.hoverStyleManager) { + this.extension.hoverStyleManager.unhookPopupMenu(menu.actor); + // Reset active state on the applet button that opened this menu + if (menu.sourceActor) { + this.extension.hoverStyleManager.resetActorActiveState(menu.sourceActor); + } + } + this.activePopupMenus.delete(menu); + } + // No need to manually disconnect - just reset flags + if (menu._transparencyCloseConnection) { + menu._transparencyCloseConnection = null; + } + + if (menu._transparencyStateConnection) { + menu._transparencyStateConnection = null; + } + } catch (e) { + this.debugLog("Error cleaning up popup menu:", e); + } + } + + /** + * Restore original popup menu styling + * @param {Object} menu - The popup menu to restore + * @param {Object} originalData - The original styling data + */ + restorePopupMenuStyle(menu, originalData) { + try { + if (menu.box) { + menu.box.set_style(originalData.boxStyle || ""); + if (originalData.boxColor) { + menu.box.set_background_color(originalData.boxColor); + } else { + menu.box.set_background_color(null); + } + if (originalData.boxStyleClasses) menu.box.set_style_class_name(originalData.boxStyleClasses); + + // Remove our style classes + menu.box.remove_style_class_name("transparency-menu-blur"); + menu.box.remove_style_class_name("transparency-fallback-blur"); + menu.box.remove_style_class_name("profile-custom"); + } + + if (menu.actor) { + menu.actor.set_style(originalData.actorStyle || ""); + if (originalData.actorStyleClasses) menu.actor.set_style_class_name(originalData.actorStyleClasses); + + // Remove our style classes + menu.actor.remove_style_class_name("transparency-menu-blur"); + menu.actor.remove_style_class_name("transparency-fallback-blur"); + menu.actor.remove_style_class_name("profile-custom"); + } + + if (originalData.sidebarActor) { + originalData.sidebarActor.set_style(originalData.sidebarStyle || ""); + } + } catch (e) { + this.debugLog("Error restoring popup menu style:", e); + } + } + + /** + * Restore original popup menu functionality + */ + restorePopupMenuMonkeyPatch() { + try { + if (this.originalPopupMenuOpen) { + // Only restore if our patch is still active (guard against other extensions patching after us) + if (PopupMenu.PopupMenu.prototype.open === this._patchedPopupMenuOpen) { + PopupMenu.PopupMenu.prototype.open = this.originalPopupMenuOpen; + } + this.originalPopupMenuOpen = null; + this._patchedPopupMenuOpen = null; + } + if (this.originalPopupSubMenuOpen) { + if (PopupMenu.PopupSubMenu.prototype.open === this._patchedPopupSubMenuOpen) { + PopupMenu.PopupSubMenu.prototype.open = this.originalPopupSubMenuOpen; + } + this.originalPopupSubMenuOpen = null; + this._patchedPopupSubMenuOpen = null; + } + this.debugLog("Popup menu monkey patch restored"); + } catch (e) { + this.debugLog("Error restoring popup menu monkey patch:", e); + } + } + + /** + * Clean up all active menus + */ + cleanupActiveMenus() { + this.activePopupMenus.forEach((originalData, menu) => { + this.restorePopupMenuStyle(menu, originalData); + }); + this.activePopupMenus.clear(); + } + + /** + * Refresh popup menu styling when settings change + */ + refresh() { + super.refresh(); + this.refreshActiveMenus(); + } + + /** + * Refresh all currently active popup menus + */ + refreshActiveMenus() { + try { + this.debugLog(`Refreshing ${this.activePopupMenus.size} active popup menus`); + + this.activePopupMenus.forEach((originalData, menu) => { + if (menu && menu.actor && menu.actor.visible) { + this.stylePopupMenu(menu); + } + }); + } catch (e) { + this.debugLog("Error refreshing active popup menus:", e); + } + } + + /** + * Find the appmenu-sidebar actor within a menu actor tree. + * Performs a BFS up to APPMENU_SIDEBAR_SEARCH_DEPTH levels deep. + * Only runs when the menu root actor has the appmenu-background class + * (i.e. menu@cinnamon.org). + * @param {Clutter.Actor} menuActor - Root actor of the popup menu + * @returns {Clutter.Actor|null} The sidebar actor, or null if not found + */ + _findAppMenuSidebar(menuActor) { + if (!menuActor || !menuActor.get_style_class_name) return null; + + const rootClass = menuActor.get_style_class_name() || ""; + this.debugLog(`_findAppMenuSidebar: root actor class="${rootClass}"`); + + if (!rootClass.includes(CSS_CLASSES.APPMENU_BACKGROUND)) return null; + + this.debugLog("_findAppMenuSidebar: appmenu-background matched, starting BFS"); + + const queue = [[menuActor, 0]]; + while (queue.length > 0) { + const [actor, depth] = queue.shift(); + if (depth > APPMENU_SIDEBAR_SEARCH_DEPTH) continue; + + if (!actor || !actor.get_style_class_name) continue; + const cls = actor.get_style_class_name() || ""; + this.debugLog(`_findAppMenuSidebar: depth=${depth} class="${cls}"`); + + if (cls.includes(CSS_CLASSES.APPMENU_SIDEBAR) && actor !== menuActor) { + this.debugLog(`_findAppMenuSidebar: FOUND sidebar at depth=${depth}`); + return actor; + } + + if (actor.get_children) { + for (const child of actor.get_children()) { + queue.push([child, depth + 1]); + } + } + } + this.debugLog("_findAppMenuSidebar: sidebar NOT found after full BFS"); + return null; + } + + /** + * Apply GTK theme sidebar color and glow effects to the appmenu-sidebar actor. + * Reads color directly from cinnamon.css to bypass the unstable getPanelBaseColor() + * blackbox. Preserves max-width inline style set by the applet's _sidebarToggle(). + * @param {Clutter.Actor} sidebarActor - The appmenu-sidebar actor + */ + async _styleAppMenuSidebar(sidebarActor) { + if (!sidebarActor) return; + try { + // Sidebar color mode: when user opts-in, sidebar matches popup override color; + // otherwise it reads the Cinnamon theme color (grey for dark, white for light). + const useSidebarStyling = this.extension.settings.getValue( + "enable-appmenu-sidebar-styling" + ); + const panelColor = useSidebarStyling + ? this.extension.themeDetector.getEffectivePopupColor() + : await this._getAppMenuSidebarThemeColor(); + const bgColor = `rgba(${panelColor.r}, ${panelColor.g}, ${panelColor.b}, ${this.extension.menuOpacity})`; + this.debugLog(`_styleAppMenuSidebar: panel base color r=${panelColor.r} g=${panelColor.g} b=${panelColor.b}`); + + // Build config matching popup config, but with panel base color + const baseRadius = this.getAdjustedBorderRadius("menu"); + const config = { + backgroundColor: bgColor, + opacity: this.extension.blurOpacity, + borderRadius: baseRadius, + borderRadiusCSS: `${baseRadius}px 0 0 ${baseRadius}px`, + blurRadius: this.getAdjustedBlurRadius("menu"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Preserve applet-set max-width (toggled by _sidebarToggle) + const existing = sidebarActor.get_style() || ""; + const maxWidthMatch = existing.match(/max-width\s*:\s*[^;]+;?/); + const maxWidthPart = maxWidthMatch ? maxWidthMatch[0].replace(/;?\s*$/, "") + "; " : ""; + + const sidebarCSS = this.extension.blurTemplateManager.generatePopupCSS(config); + this.debugLog(`_styleAppMenuSidebar: applying CSS with theme color and glow effects`); + sidebarActor.set_style(`${maxWidthPart}${sidebarCSS}`); + } catch (e) { + this.debugLog("Error styling appmenu sidebar:", e); + } + } + + /** + * Read the appmenu-sidebar background-color directly from the active Cinnamon + * theme's cinnamon.css file. Stable alternative to getPanelBaseColor() blackbox. + * Falls back to MINT_Y_DARK_FALLBACK if parsing fails. + * @returns {Promise<{r: number, g: number, b: number}>} RGB color object + */ + async _getAppMenuSidebarThemeColor() { + // When tone mode is explicitly forced, skip CSS reading and use constant directly. + // CSS parsing reflects the active theme regardless of force override, so we must + // bypass it to honour the user's explicit dark/light intent. + const toneMode = this.extension.darkLightOverride || 'auto'; + if (toneMode !== 'auto') { + const isDark = this.extension.themeDetector.isDarkModePreferred(); + this.debugLog(`_getAppMenuSidebarThemeColor: forced ${isDark ? 'dark (MINT_Y_DARK_FALLBACK)' : 'light (SIDEBAR_LIGHT_FALLBACK)'} (toneMode=${toneMode})`); + return isDark ? DEFAULT_COLORS.MINT_Y_DARK_FALLBACK : DEFAULT_COLORS.SIDEBAR_LIGHT_FALLBACK; + } + + try { + const themeName = this.extension.themeDetector.getActiveGtkTheme(); + const themePaths = [ + `${GLib.get_home_dir()}/.local/share/themes/${themeName}`, + `${GLib.get_home_dir()}/.themes/${themeName}`, + `/usr/share/themes/${themeName}`, + `/usr/local/share/themes/${themeName}`, + ]; + + for (const themePath of themePaths) { + const cssPath = `${themePath}/cinnamon/cinnamon.css`; + const cssFile = Gio.File.new_for_path(cssPath); + + const contents = await new Promise((resolve) => { + cssFile.load_contents_async(null, (source, result) => { + try { + const [success, data] = source.load_contents_finish(result); + resolve(success ? data : null); + } catch (e) { + resolve(null); + } + }); + }); + + if (!contents) continue; + + const cssText = new TextDecoder().decode(contents); + const match = cssText.match(/\.appmenu-sidebar\s*\{[^}]*background-color\s*:\s*(#[0-9a-fA-F]{6})/); + if (match) { + const hex = match[1].slice(1); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + this.debugLog(`_getAppMenuSidebarThemeColor: found ${match[1]} in cinnamon.css`); + return { r, g, b }; + } + } + } catch (e) { + this.debugLog(`_getAppMenuSidebarThemeColor: error reading theme CSS: ${e}`); + } + + const isDark = this.extension.themeDetector.isDarkModePreferred(); + this.debugLog(`_getAppMenuSidebarThemeColor: fallback to ${isDark ? "MINT_Y_DARK_FALLBACK" : "SIDEBAR_LIGHT_FALLBACK"}`); + return isDark ? DEFAULT_COLORS.MINT_Y_DARK_FALLBACK : DEFAULT_COLORS.SIDEBAR_LIGHT_FALLBACK; + } +} + +module.exports = PopupStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/settings-schema.json b/csspanels@dr.drummie/files/csspanels@dr.drummie/settings-schema.json new file mode 100644 index 00000000..a06160f3 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/settings-schema.json @@ -0,0 +1,462 @@ +{ + "layout": { + "type": "layout", + "pages": ["theme-page", "transparency-page", "blur-page", "advanced-page"], + + "theme-page": { + "type": "page", + "title": "Theme Settings", + "sections": ["theme-detection", "advanced-tools"] + }, + + "transparency-page": { + "type": "page", + "title": "Appearance Settings", + "sections": ["basic-transparency", "inset-glow-settings"] + }, + + "blur-page": { + "type": "page", + "title": "Visual Effects", + "sections": ["blur-custom"] + }, + + "advanced-page": { + "type": "page", + "title": "Advanced Settings", + "sections": ["extended-styling", "indicator-settings", "debugging"] + }, + + "basic-transparency": { + "type": "section", + "title": "Basic Appearance Controls", + "keys": [ + "panel-opacity", + "menu-opacity", + "override-panel-color", + "choose-override-panel-color", + "override-popup-color", + "choose-override-popup-color" + ] + }, + + "theme-detection": { + "type": "section", + "title": "Theme Integration", + "keys": [ + "auto-apply-accent-on-theme-change", + "dark-light-override", + "apply-detected-accent-button", + "border-radius", + "apply-panel-radius" + ] + }, + + "blur-custom": { + "type": "section", + "title": "Visual Effect Controls", + "keys": [ + "blur-radius", + "blur-saturate", + "blur-contrast", + "blur-brightness", + "blur-background", + "blur-border-color", + "blur-border-width", + "blur-transition", + "blur-opacity", + "accent-shadow-color", + "shadow-spread" + ] + }, + + "inset-glow-settings": { + "type": "section", + "title": "Glow Effect Controls", + "keys": ["glow-mode", "glow-blur", "glow-intensity"] + }, + + "advanced-tools": { + "type": "section", + "title": "Advanced Tools", + "keys": ["enable-wallpaper-detection", "full-auto-mode", "wallpaper-color-strategy", "extract-wallpaper-colors-button", "blur-template", "reset-blur-button"] + }, + + "extended-styling": { + "type": "section", + "title": "Extended UI Styling", + "keys": [ + "enable-notification-styling", + "enable-osd-styling", + "enable-tooltip-styling", + "enable-alttab-styling", + "enable-appmenu-sidebar-styling", + "enable-desktop-context-styling", + "enable-desklet-styling" + ] + }, + + "indicator-settings": { + "type": "section", + "title": "System Tray Indicator", + "keys": ["hide-tray-icon"] + }, + + "debugging": { + "type": "section", + "title": "Debugging", + "keys": ["debug-logging"] + } + }, + + "panel-opacity": { + "type": "scale", + "default": 1, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Panel opacity", + "tooltip": "Adjust the transparency of all panels. Lower values create a more transparent panel." + }, + + "menu-opacity": { + "type": "scale", + "default": 1, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Menu opacity", + "tooltip": "Adjust the transparency of popup menus and some popup-based controls. Creates a semi-transparent appearance combined with tint and glow effects. Note: some Mint theme menus have a hardcoded background color that overrides transparency — this is a theme limitation, not an extension bug." + }, + + "override-panel-color": { + "type": "checkbox", + "default": true, + "description": "Override panel color", + "tooltip": "When OFF: Panel color is auto-detected from the active GTK theme (adapts automatically on theme change). When ON: Panel uses the color picker below. This does NOT affect popup color - see 'Override popup color' setting." + }, + + "choose-override-panel-color": { + "type": "colorchooser", + "default": "rgba(46, 52, 64, 0.8)", + "description": "Choose override panel color", + "tooltip": "Select the background color to use for the panel when 'Override panel color' is enabled. Supports transparency (alpha channel). This color is saved independently of theme changes.", + "dependency": "override-panel-color" + }, + + "override-popup-color": { + "type": "checkbox", + "default": false, + "description": "Override popup color", + "tooltip": "When OFF: Popup menus and some popup-based controls inherit the current panel color (auto-detected theme color, or the panel override color if that is enabled). When ON: Uses the color picker below, independent of panel color." + }, + + "choose-override-popup-color": { + "type": "colorchooser", + "default": "rgba(255, 255, 255, 0.9)", + "description": "Choose override popup color", + "tooltip": "Select the background color to use for popup menus when 'Override popup color' is enabled. Supports transparency (alpha channel). Only active when 'Override popup color' is checked.", + "dependency": "override-popup-color" + }, + + "border-radius": { + "type": "spinbutton", + "default": 6, + "min": 0, + "max": 12, + "step": 1, + "units": "px", + "description": "Border radius", + "tooltip": "Rounded corners for panels and menus. Used as fallback when auto-detect fails or finds inconsistent values. Set to 0 for completely flat appearance." + }, + + "apply-panel-radius": { + "type": "checkbox", + "default": false, + "description": "Apply border radius to main panel", + "tooltip": "Enable rounded corners on taskbar for modern appearance. May look odd at screen edges depending on your theme." + }, + + "auto-detect-radius": { + "type": "generic", + "default": false + }, + + "enable-notification-styling": { + "type": "checkbox", + "default": true, + "description": "Style system notifications", + "tooltip": "Apply transparency and visual effect styles to system notifications (volume, brightness, etc.). This will make notifications match your panel's visual style." + }, + + "enable-osd-styling": { + "type": "checkbox", + "default": true, + "description": "Style OSD (On-Screen Display) elements", + "tooltip": "Apply transparency and visual effect styles to On-Screen Display elements like volume sliders, brightness controls, and other overlay elements. Creates a consistent visual appearance." + }, + + "enable-tooltip-styling": { + "type": "checkbox", + "default": false, + "description": "Style tooltip elements", + "tooltip": "Apply transparency and visual effect styles to tooltip elements that appear when hovering over panel items and other UI elements. Creates a consistent visual appearance." + }, + + "enable-alttab-styling": { + "type": "checkbox", + "default": true, + "description": "Style Alt-Tab switcher elements", + "tooltip": "Apply transparency and visual effect styles to Alt-Tab window switcher. Creates a consistent visual appearance for the application switcher." + }, + + "enable-appmenu-sidebar-styling": { + "type": "checkbox", + "default": false, + "description": "Style start menu sidebar", + "tooltip": "Apply popup color override to the start menu (menu@cinnamon.org) sidebar. When disabled (default), sidebar uses the Cinnamon theme color (grey/white). When enabled, sidebar matches the popup override color. Has no effect if the original Cinnamon menu applet is not active." + }, + + "enable-desktop-context-styling": { + "type": "generic", + "default": false, + "description": "Enable desktop context menu styling", + "tooltip": "Apply transparency and visual effect styles to desktop right-click context menus" + }, + + "enable-desklet-styling": { + "type": "checkbox", + "default": false, + "description": "Style desklet elements", + "tooltip": "Apply transparency and visual effect styles to desktop widgets (desklets). Creates a consistent visual appearance for desklets matching your panel style." + }, + + "full-auto-mode": { + "type": "checkbox", + "default": false, + "dependency": "enable-wallpaper-detection", + "description": "Wallpaper manages all shell colors (experimental)", + "tooltip": "When enabled, every wallpaper change also updates blur and accent color settings (border color, background tint, shadow color). Panel and popup colors are always extracted regardless of this setting. Requires wallpaper detection to be active." + }, + + "wallpaper-color-strategy": { + "type": "combobox", + "default": "contrast", + "options": { + "Standard (weighted average)": "default", + "Contrast (polar tones)": "contrast" + }, + "description": "Wallpaper color extraction mode", + "tooltip": "Standard: panel color is a weighted average of the entire image tone (stable, smooth). Contrast: panel uses the darkest/lightest pixels from the image — stronger tonal expression, more sensitive to extreme pixels in the wallpaper." + }, + + "dark-light-override": { + "type": "combobox", + "default": "auto", + "options": { + "Auto (follow system/theme)": "auto", + "Force dark": "dark", + "Force light": "light" + }, + "description": "Dark/light mode override", + "tooltip": "Globally overrides dark/light mode detection for the entire extension — affects sidebar color fallback, accent color generation, and wallpaper extraction tone. 'Auto' follows the active GTK color scheme and theme name. 'Force dark' is recommended for mixed themes (e.g. Mint-Y-Aqua) where the panel is dark but the GTK theme has no -Dark suffix." + }, + + "blur-radius": { + "type": "spinbutton", + "default": 22, + "min": 1, + "max": 50, + "step": 1, + "units": "px", + "description": "Blur radius", + "tooltip": "Controls the intended blur intensity. Higher values produce stronger diffusion (e.g. 30px+ for a foggy look), lower values give a sharper, more subtle appearance. Note: actual blur rendering depends on compositor support — on most Cinnamon setups this has no visible effect, but the setting is preserved for compatible environments." + }, + + "blur-saturate": { + "type": "spinbutton", + "default": 0.95, + "min": 0.4, + "max": 2.0, + "step": 0.05, + "description": "Saturation multiplier", + "tooltip": "Adjusts color vibrancy of the transparent background. Values above 1.0 make colors more vivid, below 1.0 create muted, desaturated tones. Applied as part of backdrop-filter — effective only on compositors that support it." + }, + + "blur-contrast": { + "type": "spinbutton", + "default": 0.75, + "min": 0.4, + "max": 2.0, + "step": 0.05, + "description": "Contrast multiplier", + "tooltip": "Controls the difference between light and dark areas in the visual effect. Higher values (above 1.0) enhance depth and sharpness, lower values soften the appearance. Applied as part of backdrop-filter — effective only on compositors that support it." + }, + + "blur-brightness": { + "type": "spinbutton", + "default": 0.65, + "min": 0.4, + "max": 2.0, + "step": 0.05, + "description": "Brightness multiplier", + "tooltip": "Modifies the overall lightness of the effect layer. Increase above 1.0 for a brighter, illuminated look (ideal for light themes), decrease below 1.0 for darker, moodier tones. Applied as part of backdrop-filter — effective only on compositors that support it." + }, + + "blur-background": { + "type": "colorchooser", + "default": "rgba(0, 0, 0, 0.3)", + "description": "Background color/tint", + "tooltip": "Accent tint color, automatically populated from the active GTK theme (low-opacity variant of the accent color). Also used as a fallback glow color when no border color is set. You can adjust it manually - use semi-transparent colors for subtle color tinting." + }, + + "blur-border-color": { + "type": "colorchooser", + "default": "rgba(255, 255, 255, 0.15)", + "description": "Border color", + "tooltip": "Color of the subtle border framing styled elements. Also used as the primary glow color for the Glow Effect system, and as a fallback for the background tint. Automatically populated from the active GTK theme accent or wallpaper extraction. Adjust opacity for softer or more defined borders." + }, + + "blur-border-width": { + "type": "generic", + "default": 0, + "description": "Border width (deprecated - hardcoded to 0)", + "tooltip": "This setting is deprecated and hardcoded to 0. Border effects are now handled by the Glow Effect system." + }, + + "blur-transition": { + "type": "spinbutton", + "default": 0.3, + "min": 0, + "max": 2, + "step": 0.1, + "description": "Transition duration", + "tooltip": "Sets the speed of visual effect transitions when settings change. Shorter durations (0.1-0.5s) create snappy, responsive transitions for quick adjustments, while longer ones (1-2s) provide smooth, elegant fades for a polished feel." + }, + + "blur-opacity": { + "type": "spinbutton", + "default": 0.8, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Effect layer opacity", + "tooltip": "Controls the transparency of the entire effect layer. Higher values (0.8-1.0) make the visual effect more prominent and solid, while lower values (0.1-0.5) create a lighter, more subtle appearance that blends with the background." + }, + + "shadow-spread": { + "type": "spinbutton", + "default": 0.4, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Shadow spread", + "tooltip": "Controls the spread of the shadow effect. Higher values (0.8-1.0) create a more pronounced shadow, while lower values (0.1-0.5) result in a softer, more diffused shadow." + }, + + "reset-blur-button": { + "type": "button", + "description": "Apply selected template", + "tooltip": "Apply the selected effect template to all visual effect controls. This will update radius, saturation, contrast, brightness, background, border color, border width, transition, and opacity to match the chosen template.", + "callback": "resetBlurToDefaults" + }, + + "enable-wallpaper-detection": { + "type": "checkbox", + "default": false, + "description": "Enable wallpaper detection", + "tooltip": "Automatically extract and apply colors from your current wallpaper to the panel. Enabling this also activates the panel color override — without it, extracted colors would be ignored. Popup menus automatically inherit the panel color; enable 'Override popup color' separately if you want independent popup customization. Use the 'Extract colors from wallpaper' button for a manual one-time extraction." + }, + + "extract-wallpaper-colors-button": { + "type": "button", + "description": "Extract colors from wallpaper", + "tooltip": "Immediately extract colors from your current wallpaper and apply them to the panel and popup color pickers. If 'Wallpaper manages all shell colors' is also enabled, also updates border, tint, and shadow colors. Does not require wallpaper detection to be enabled.", + "callback": "extractWallpaperColors" + }, + + "blur-template": { + "type": "combobox", + "default": "frosted-glass-dark", + "options": { + "Frosted Glass": "frosted-glass", + "Wet Glass": "wet-glass", + "Foggy Glass": "foggy-glass", + "Clear Crystal": "clear-crystal", + "Frosted Glass Dark": "frosted-glass-dark", + "Wet Glass Dark": "wet-glass-dark", + "Foggy Glass Dark": "foggy-glass-dark", + "Clear Crystal Dark": "clear-crystal-dark" + }, + "description": "Effect template", + "tooltip": "Select an effect template to apply when using the 'Apply Selected Template' button. Each template defines preset values for all visual effect controls." + }, + + "auto-apply-accent-on-theme-change": { + "type": "checkbox", + "default": true, + "description": "Auto-apply accent colors on theme change", + "tooltip": "Automatically detect and apply accent colors from your active GTK theme whenever you change themes. When disabled, you can still manually apply accent colors using the button below. This allows you to keep your custom colors while changing themes." + }, + + "accent-shadow-color": { + "type": "colorchooser", + "default": "rgba(20, 29, 34, 0.3)", + "description": "Accent shadow/glow color", + "tooltip": "Shadow and glow color for box-shadow effects on all elements (panels, popups, notifications). Deep dark for dark themes or soft light for light themes. Automatically populated when accent colors are detected from the active GTK theme." + }, + + "apply-detected-accent-button": { + "type": "button", + "description": "Detect and apply accent from current theme", + "tooltip": "Detect accent colors from your current GTK theme and apply them to panel/popup color pickers and blur effects (border, background, shadow). Enables the panel color override so the detected accent is immediately applied to the panel. Popup menus inherit the panel color automatically. Disables wallpaper detection if active.", + "callback": "applyDetectedAccent" + }, + + "glow-mode": { + "type": "combobox", + "default": "inset", + "description": "Glow effect mode", + "tooltip": "Inset: Glow at edges/corners, darker center (classic). Outset: Glow at center, fade to edges (reverse). None: No glow effect.", + "options": { + "Inset (edges)": "inset", + "Outset (center)": "outset", + "None": "none" + } + }, + + "glow-blur": { + "type": "spinbutton", + "default": 22, + "min": 4, + "max": 40, + "step": 1, + "units": "px", + "description": "Glow blur size", + "tooltip": "Controls the spread/size of the glow effect. Higher values = more diffused glow. For panels, minimum glow size is 4px to maintain visual consistency." + }, + + "glow-intensity": { + "type": "spinbutton", + "default": 0.20, + "min": 0.05, + "max": 0.5, + "step": 0.05, + "description": "Glow intensity (opacity)", + "tooltip": "Controls the brightness/visibility of the glow. Lower values (0.05-0.15) = subtle highlight, higher values (0.3-0.5) = prominent glossy effect. Demo example: 0.15 for balanced glossy look." + }, + + "debug-logging": { + "type": "checkbox", + "default": false, + "description": "Enable debug logging", + "tooltip": "Enable detailed logging for troubleshooting extension issues. Check terminal output with 'journalctl -f' for detailed information." + }, + + "hide-tray-icon": { + "type": "checkbox", + "default": true, + "description": "Hide system tray indicator", + "tooltip": "Hide the transparency control icon from the system tray. You can still access settings through Cinnamon Settings > Extensions." + } +} diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/signalHandler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/signalHandler.js new file mode 100644 index 00000000..1aba5f25 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/signalHandler.js @@ -0,0 +1,198 @@ +/* signalHandler.js + * + * Global Signal Management for CSSPanels Extension + * Pattern adapted from zorin-taskbar@zorinos.com/utils.js (BasicHandler) + * Compatible with Cinnamon 6.0+ (Linux Mint 22.1+) + * + * Provides centralized signal connection tracking to prevent memory leaks + * and ensure proper cleanup on extension disable. + */ + +/** + * GlobalSignalsHandler - Centralized signal connection management + * + * Handles signal connections with automatic tracking and cleanup. + * Prevents memory leaks by ensuring all signals are properly disconnected. + * + * Usage: + * this._signalsHandler = new GlobalSignalsHandler(); + * + * // Add single signal + * this._signalsHandler.add( + * [object, 'signal-name', callback] + * ); + * + * // Add multiple signals to same object + * this._signalsHandler.add( + * [object, ['signal-1', 'signal-2'], callback] + * ); + * + * // Add many signals at once + * this._signalsHandler.add( + * [obj1, 'signal-a', callbackA], + * [obj2, 'signal-b', callbackB], + * [obj3, ['signal-c', 'signal-d'], callbackC] + * ); + * + * // Cleanup - disconnect all tracked signals + * this._signalsHandler.destroy(); + * + * @class GlobalSignalsHandler + */ +class GlobalSignalsHandler { + /** + * Create new signal handler with empty signal tracking + */ + constructor() { + /** + * Array of tracked signal connections + * Each entry: { object: GObject, signalId: number } + * @private + */ + this._signals = []; + } + + /** + * Add signal connections to tracking + * + * Accepts variable number of signal definitions. Each definition is an array: + * - [object, signalName, callback] - Single signal + * - [object, [signalNames...], callback] - Multiple signals to same callback + * + * All connections are automatically tracked for cleanup in destroy(). + * + * @param {...Array} signals - Variable number of signal definitions to add + * + * @example + * // Single signal + * handler.add([settings, 'changed', this._onChanged.bind(this)]); + * + * // Multiple signals to same object + * handler.add([settings, ['changed::key1', 'changed::key2'], callback]); + * + * // Multiple entries at once + * handler.add( + * [obj1, 'signal1', cb1], + * [obj2, 'signal2', cb2] + * ); + */ + add(...signals) { + signals.forEach((entry) => { + let object = entry[0]; + let signalNames = entry[1]; + let callback = entry[2]; + + // Validate entry structure + if (!object || !signalNames || !callback) { + global.logWarning( + "[CSSPanels:GlobalSignalsHandler] Invalid signal entry - missing object, signal, or callback" + ); + return; + } + + // Ensure signalNames is array (support both single string and array) + if (!Array.isArray(signalNames)) { + signalNames = [signalNames]; + } + + // Connect each signal and track connection ID + signalNames.forEach((signal) => { + try { + let signalId = object.connect(signal, callback); + this._signals.push({ object, signalId }); + } catch (e) { + // Silent failure for missing signals + // This allows connecting to optional signals without throwing + global.logError( + `[CSSPanels:GlobalSignalsHandler] Error in signal connection '${signal}': ${e.message}\n${e.stack || ""}` + ); + } + }); + }); + } + + /** + * Remove specific signal connection from tracking + * + * Disconnects the signal and removes it from internal tracking array. + * + * @param {GObject.Object} object - Object that emitted the signal + * @param {number} signalId - Signal connection ID to remove + */ + remove(object, signalId) { + const index = this._signals.findIndex((s) => s.object === object && s.signalId === signalId); + if (index !== -1) { + try { + if (object && signalId) { + object.disconnect(signalId); + } + } catch (e) { + // Object may already be destroyed - safe to ignore + global.logWarning( + "[CSSPanels:GlobalSignalsHandler] Failed to disconnect signal (object may be destroyed)" + ); + } + this._signals.splice(index, 1); + } + } + + /** + * Remove all signals from specific object + * + * Useful when destroying a specific component while keeping others active. + * + * @param {GObject.Object} object - Object to disconnect all signals from + */ + removeAll(object) { + const toRemove = this._signals.filter((s) => s.object === object); + toRemove.forEach(({ signalId }) => this.remove(object, signalId)); + } + + /** + * Disconnect all tracked signals and clear tracking + * + * MUST be called in disable() method to prevent memory leaks! + * + * This method ensures proper cleanup by disconnecting all tracked signals, + * even if objects are already destroyed. Handles edge cases gracefully. + */ + destroy() { + this._signals.forEach(({ object, signalId }) => { + try { + if (object && signalId) { + object.disconnect(signalId); + } + } catch (e) { + // Object may already be destroyed during Cinnamon shutdown + // This is expected and safe to ignore + } + }); + this._signals = []; + } + + /** + * Check if handler has any tracked signals + * + * Useful for debugging and testing to verify cleanup was successful. + * + * @returns {boolean} True if signals are tracked, false if empty + */ + hasSignals() { + return this._signals.length > 0; + } + + /** + * Get count of tracked signals + * + * Debug method to monitor signal accumulation and verify cleanup. + * Expected to be 0 after destroy() is called. + * + * @returns {number} Number of tracked signal connections + */ + getSignalCount() { + return this._signals.length; + } +} + +// Export for Cinnamon +module.exports = { GlobalSignalsHandler }; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/stylerBase.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/stylerBase.js new file mode 100644 index 00000000..5584d159 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/stylerBase.js @@ -0,0 +1,350 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const { TIMESTAMP, CSS_CLASSES, STYLING, TIMING } = require("./constants"); +const { GlobalSignalsHandler } = require("./signalHandler"); + +/** + * Base class for all styler modules providing common functionality + * Implements Strategy Pattern for consistent enable/disable/refresh behavior + */ +class StylerBase { + /** + * Initialize base styler + * @param {Object} extension - Reference to main extension instance + * @param {string} stylerName - Name of the styler for debug logging + */ + constructor(extension, stylerName) { + this.extension = extension; + this.stylerName = stylerName; + this.isEnabled = false; + this._signalsHandler = new GlobalSignalsHandler(); + this.activeElements = new Map(); + this._enableFailed = false; // Track enable failure state + } + + /** + * Safe enable wrapper with automatic rollback on failure + * Wraps enable() with error boundary and user notification + * + * @returns {boolean} True if enable succeeded, false otherwise + */ + safeEnable() { + try { + this.enable(); + this._enableFailed = false; + return true; + } catch (error) { + this._enableFailed = true; + this.debugLog(`CRITICAL: Enable failed for ${this.stylerName}:`, error.message); + + // Attempt automatic rollback + try { + this.disable(); + this.debugLog(`Rollback successful for ${this.stylerName}`); + } catch (rollbackError) { + this.debugLog(`Rollback failed for ${this.stylerName}:`, rollbackError.message); + } + + // Notify user of failure + this._notifyError(`Failed to enable ${this.stylerName}`, error.message); + return false; + } + } + + /** + * Enable the styler - to be overridden by subclasses + * NOTE: Use safeEnable() to call this with error boundary protection + */ + enable() { + this.isEnabled = true; + this.debugLog("Styler enabled"); + } + + /** + * Disable the styler - to be overridden by subclasses + */ + disable() { + this.isEnabled = false; + this._signalsHandler.destroy(); + this.cleanupActiveElements(); + this.debugLog("Styler disabled"); + } + + /** + * Notify user of critical error + * + * @param {string} title - Error title + * @param {string} message - Error message + * @private + */ + _notifyError(title, message) { + try { + // Use Cinnamon's notification system if available + if (Main.notifyError) { + Main.notifyError(title, message); + } else { + // Fallback to global log + global.logError(`[CSSPanels] Error in ${title}: ${message}`); + } + } catch (e) { + // Silent fail - already in error state + global.logError(`[CSSPanels] Error notification failed: ${e.message}`); + } + } + + /** + * Refresh the styler - to be overridden by subclasses + */ + refresh() { + if (!this.isEnabled) return; + this.debugLog("Styler refreshed"); + } + + /** + * Cleanup active elements - remove all tracked timeout IDs and clear map + */ + cleanupActiveElements() { + this.activeElements.forEach((data) => { + if (data.fadeTimeout) { + imports.gi.GLib.source_remove(data.fadeTimeout); + } + }); + this.activeElements.clear(); + } + + /** + * Add signal connection for automatic cleanup + * + * Wrapper method for GlobalSignalsHandler.add() to maintain compatibility + * with existing code patterns while providing cleaner API. + * + * @param {GObject.Object} object - Object to connect signal to + * @param {string|Array} signal - Signal name(s) to connect + * @param {Function} callback - Callback function (should be bound if needed) + * + * @example + * // Single signal + * this.addConnection(settings, 'changed::key', this._onChanged.bind(this)); + * + * // Multiple signals + * this.addConnection(menu, ['open-state-changed', 'destroy'], this._onMenuEvent.bind(this)); + */ + addConnection(object, signal, callback) { + this._signalsHandler.add([object, signal, callback]); + } + + /** + * Get count of tracked signals for debugging + * + * @returns {number} Number of active signal connections + */ + getSignalCount() { + return this._signalsHandler.getSignalCount(); + } + + /** + * Debug logging with styler prefix + * @param {...any} args - Arguments to log + */ + debugLog(...args) { + // Allow logging during disable/cleanup phase + const isCleanupMessage = + args[0]?.includes("Disabling") || + args[0]?.includes("Cleaning") || + args[0]?.includes("Restored") || + args[0]?.includes("disabled") || + args[0]?.includes("cleanup"); + + if (!this.extension.isEnabled && !isCleanupMessage) return; + if (!this.extension.debugLogging) return; // Only log when debug logging is enabled + const timestamp = new Date().toISOString().slice(TIMESTAMP.ISO_TIME_START, TIMESTAMP.ISO_TIME_END); + global.log(`[CSSPanels] [${this.stylerName}] [${timestamp}] ${args.join(" ")}`); + } + + /** + * Check if element should be styled - to be overridden by subclasses + * @param {Clutter.Actor} element - Element to check + * @returns {boolean} True if should be styled + */ + shouldStyleElement(element) { + return false; // Default implementation + } + + /** + * Apply style to element - to be overridden by subclasses + * @param {Clutter.Actor} element - Element to style + */ + applyStyleToElement(element) { + // Default implementation - subclasses should override + } + + /** + * Restore original style - to be overridden by subclasses + * @param {Clutter.Actor} element - Element to restore + * @param {Object} originalData - Original styling data + */ + restoreElementStyle(element, originalData) { + // Default implementation - subclasses should override + } + + /** + * Apply fade-out effect before removing styling to prevent flicker + * @param {Clutter.Actor} element - Element to fade out + * @param {Function} callback - Callback to execute after fade + */ + fadeOutStyling(element, callback) { + if (!element || !element.get_stage) { + return callback && callback(); + } + + try { + // Add fade-out class for smooth transition + element.add_style_class_name(CSS_CLASSES.FADE_OUT); + + // Use GLib timeout for fade duration + const timeoutId = imports.gi.GLib.timeout_add( + imports.gi.GLib.PRIORITY_DEFAULT, + TIMING.FADE_OUT_DURATION, // Fade-out animation duration + () => { + try { + // Remove fade class and execute callback + element.remove_style_class_name(CSS_CLASSES.FADE_OUT); + element.remove_style_class_name(CSS_CLASSES.PERSISTENT_OVERLAY); + callback && callback(); + } catch (e) { + this.debugLog("Error in fade-out callback:", e); + callback && callback(); // Ensure callback runs even on error + } + return false; // Remove timeout + } + ); + + // Store timeout for cleanup if needed + this.activeElements.set(element, { fadeTimeout: timeoutId }); + } catch (e) { + this.debugLog("Error applying fade-out:", e); + callback && callback(); // Fallback to immediate callback + } + } + + /** + * Restore element styling with optional fade-out for anti-flicker + * @param {Clutter.Actor} element - Element to restore + * @param {boolean} useFadeOut - Whether to use fade-out transition + * @param {Function} restoreFunction - Function to call for actual restore + */ + restoreElementWithFade(element, useFadeOut, restoreFunction) { + if (!element) { + return restoreFunction && restoreFunction(); + } + + if (useFadeOut && element.get_stage()) { + // Use fade-out to prevent flicker + this.fadeOutStyling(element, () => { + restoreFunction && restoreFunction(); + }); + } else { + // Immediate restore + restoreFunction && restoreFunction(); + } + } + + /** + * Calculate backdrop-filter string for blur effects + * @param {number} blurRadius - Base blur radius + * @param {number} saturate - Saturation multiplier + * @param {number} contrast - Contrast multiplier + * @param {number} brightness - Brightness multiplier + * @returns {string} Backdrop-filter CSS string + */ + calculateBackdropFilter(blurRadius, saturate = 1.0, contrast = 1.0, brightness = 1.0) { + return `blur(${blurRadius}px) saturate(${saturate}) contrast(${contrast}) brightness(${brightness})`; + } + + /** + * Apply common CSS classes for blur styling + * @param {Clutter.Actor} element - Element to style + * @param {string} elementType - Type identifier (e.g., 'menu', 'notification') + */ + applyCommonBlurClasses(element, elementType) { + element.add_style_class_name(`transparency-${elementType}-blur`); + element.add_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + + if (!this.extension.cssManager.hasBackdropFilter) { + element.add_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + } + } + + /** + * Remove common CSS classes for blur styling + * @param {Clutter.Actor} element - Element to clean + * @param {string} elementType - Type identifier (e.g., 'menu', 'notification') + */ + removeCommonBlurClasses(element, elementType) { + element.remove_style_class_name(`transparency-${elementType}-blur`); + element.remove_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + element.remove_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + } + + /** + * Get adjusted blur radius for different element types + * @param {string} elementType - Type of element ('menu', 'notification', 'osd', 'tooltip') + * @returns {number} Adjusted blur radius + */ + getAdjustedBlurRadius(elementType) { + const baseRadius = this.extension.blurRadius; + const multipliers = { + menu: STYLING.BLUR_ADJUSTMENT_MENU, + notification: STYLING.BLUR_ADJUSTMENT_OSD, + osd: STYLING.BLUR_ADJUSTMENT_OSD, + tooltip: STYLING.BLUR_ADJUSTMENT_TOOLTIP, + alttab: STYLING.BLUR_ADJUSTMENT_ALTTAB, + }; + return Math.round(baseRadius * (multipliers[elementType] || 1.0)); + } + + /** + * Get adjusted border radius for different element types + * @param {string} elementType - Type of element + * @returns {number} Adjusted border radius + */ + getAdjustedBorderRadius(elementType) { + const baseRadius = this.extension.borderRadius; + const multipliers = { + menu: STYLING.BORDER_ADJUSTMENT_MENU, + notification: STYLING.BORDER_ADJUSTMENT_OSD, + osd: STYLING.BORDER_ADJUSTMENT_OSD, + tooltip: STYLING.BORDER_ADJUSTMENT_TOOLTIP, + alttab: STYLING.BORDER_ADJUSTMENT_ALTTAB, + }; + return Math.round(baseRadius * (multipliers[elementType] || 1.0)); + } + + /** + * Generate border-radius CSS value string for attached popup menus. + * Returns a 4-value CSS string with 0px on the side touching the panel. + * @param {number} baseRadius - Base border radius in pixels + * @param {number|null} orientation - St.Side enum value (0=TOP,1=BOTTOM,2=LEFT,3=RIGHT), or null for uniform + * @returns {string} CSS border-radius value, e.g. "0 0 8px 8px" or "8px" + */ + getAttachedBorderRadiusCSS(baseRadius, orientation) { + const r = `${baseRadius}px`; + const z = "0"; + + // Map St.Side to CSS border-radius: top-left top-right bottom-right bottom-left + // Empirically verified from logs: 0=TOP, 1=RIGHT, 2=BOTTOM, 3=LEFT + const radiusMap = { + 0: `${z} ${z} ${r} ${r}`, // TOP: top corners flat (touching top panel) + 1: `${r} ${z} ${z} ${r}`, // RIGHT: right corners flat (touching right panel) + 2: `${r} ${r} ${z} ${z}`, // BOTTOM: bottom corners flat (touching bottom panel) + 3: `${z} ${r} ${r} ${z}`, // LEFT: left corners flat (touching left panel) + }; + + // Return mapped value or uniform fallback + return (orientation !== null && orientation !== undefined && radiusMap[orientation] !== undefined) + ? radiusMap[orientation] + : r; + } +} + +module.exports = StylerBase; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/stylesheet.css b/csspanels@dr.drummie/files/csspanels@dr.drummie/stylesheet.css new file mode 100644 index 00000000..1d47250e --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/stylesheet.css @@ -0,0 +1,45 @@ +/* Panel Transparency Control Extension - Minimal Static Styles */ +/* CSSPanels Phase 2.5A: Template-based CSS generation */ +/* Most styles are now applied via inline CSS generated by blurTemplateManager.js */ +/* This file contains ONLY static anti-flicker transitions and layout helpers */ + +/* ============================================================================ + * ANTI-FLICKER TRANSITION CLASSES + * Used by stylerBase.js for smooth element cleanup and fade effects + * ============================================================================ */ + +/** + * Fade-out state for smooth element removal + * Applied during cleanup to prevent visual flicker + */ +.transparency-fade-out { + opacity: 0 !important; + backdrop-filter: blur(0px) !important; + transition: all 150ms ease-out !important; +} + +/** + * Persistent overlay for elements being hidden + * Prevents interaction during fade-out animations + */ +.transparency-persistent-overlay { + transition: all 150ms ease-out !important; + pointer-events: none; +} + +/* ============================================================================ + * LAYOUT & PERFORMANCE HELPERS + * Static rules that don't require dynamic generation + * ============================================================================ */ + +/** + * GPU acceleration hint for blur effects + * Applied to elements with backdrop-filter for better performance + */ +.transparency-gpu-accelerated { + transform: translateZ(0); + will-change: backdrop-filter, background-color; + contain: layout style paint; +} + +/* End of static stylesheet - All blur effects generated via templates */ diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/systemIndicator.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/systemIndicator.js new file mode 100644 index 00000000..69abd06c --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/systemIndicator.js @@ -0,0 +1,151 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const Util = imports.misc.util; +const Tooltips = imports.ui.tooltips; +const { SYSTEM_INDICATOR, SIZE } = require("./constants"); + +/** + * System Indicator handles the system tray indicator + * Provides quick access to extension settings + */ +class SystemIndicator { + /** + * Initialize System Indicator + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + this.extension = extension; + this.indicator = null; + this.tooltip = null; + } + + /** + * Create the system tray indicator + */ + create() { + if (this.indicator) return; + + try { + this.extension.debugLog("Creating system tray indicator..."); + + // Create a button container for the indicator + this.indicator = new St.Button({ + style_class: "panel-button", + reactive: true, + track_hover: true, + can_focus: true, + style: SYSTEM_INDICATOR.PADDING_STYLE, + }); + + // Create icon for the button + let icon = new St.Icon({ + icon_name: SYSTEM_INDICATOR.ICON_NAME, + icon_size: SIZE.SYSTEM_INDICATOR_ICON_SIZE, + style_class: "system-status-icon", + }); + + // Add icon to the button + this.indicator.set_child(icon); + + // Add click handler to open extension settings + this.indicator.connect("button-press-event", (actor, event) => { + const button = event.get_button(); + if (button === 1) { + // Left click - open extension settings directly + this.extension.debugLog("Indicator clicked - opening extension settings"); + Util.spawnCommandLineAsync("xlet-settings extension csspanels@dr.drummie"); + } + }); + + // Add to system tray using Cinnamon panel API + if (Main.panel && Main.panel._rightBox) { + Main.panel._rightBox.insert_child_at_index(this.indicator, 0); + this.extension.debugLog("Indicator added to panel using _rightBox"); + + if (this.extension.hoverStyleManager) { + this.extension.hoverStyleManager.hookExternalActor(this.indicator); + } + + // Create tooltip with custom positioning for top panel + this.tooltip = new Tooltips.Tooltip(this.indicator, this.extension.metadata.name || "CSS Panels"); + this.extension.debugLog("SystemIndicator: Tooltip created, type:", typeof this.tooltip); + + // Force tooltip to position above the panel + if (this.tooltip && this.tooltip._tooltip) { + // Store original show method on instance for explicit restore in destroy() + this._originalTooltipShow = this.tooltip.show.bind(this.tooltip); + this.tooltip.show = () => { + this._originalTooltipShow(); + // Position tooltip above the indicator + let [x, y] = this.indicator.get_transformed_position(); + let [width, height] = this.indicator.get_size(); + this.tooltip._tooltip.set_position( + x + width / 2 - this.tooltip._tooltip.get_width() / 2, + y - this.tooltip._tooltip.get_height() - SYSTEM_INDICATOR.TOOLTIP_OFFSET + ); + }; + } + + this.extension.debugLog("Tooltip created successfully"); + } else { + throw new Error("No suitable method found for adding indicator"); + } + + this.extension.debugLog("System tray indicator created successfully"); + } catch (e) { + this.extension.debugLog("Error creating indicator:", e.message); + global.logError("[CSSPanels] Error in createIndicator: " + e.message); + this.indicator = null; + } + } + + /** + * Destroy the system tray indicator + */ + destroy() { + try { + if (!this.indicator) { + this.extension.debugLog("destroyIndicator: No indicator to destroy"); + return; + } + + // Clean up tooltip + if (this.tooltip) { + try { + if (this._originalTooltipShow) { + this.tooltip.show = this._originalTooltipShow; + this._originalTooltipShow = null; + } + this.tooltip.destroy(); + this.tooltip = null; + this.extension.debugLog("Tooltip destroyed successfully"); + } catch (tooltipError) { + this.extension.debugLog("Error destroying tooltip:", tooltipError.message); + } + } + + // Remove from panel + if (Main.panel && Main.panel._rightBox) { + try { + if (this.extension.hoverStyleManager) { + this.extension.hoverStyleManager.unhookExternalActor(this.indicator); + } + Main.panel._rightBox.remove_child(this.indicator); + this.extension.debugLog("Indicator removed from panel successfully"); + } catch (removeError) { + this.extension.debugLog("Error removing indicator from panel:", removeError.message); + } + } + + // Clean up the indicator + this.indicator = null; + this.extension.debugLog("System tray indicator destroyed successfully"); + } catch (e) { + this.extension.debugLog("Error destroying indicator:", e.message || e.toString()); + global.logError("[CSSPanels] Error in destroyIndicator: " + e.message); + this.indicator = null; + } + } +} + +module.exports = SystemIndicator; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/themeDetector.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/themeDetector.js new file mode 100644 index 00000000..ee2b821c --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/themeDetector.js @@ -0,0 +1,1346 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const { TIMING, SIGNALS, DEFAULT_COLORS } = require("./constants"); +const { ThemeUtils } = require("./themeUtils"); +const { GlobalSignalsHandler } = require("./signalHandler"); + +/** + * Theme Detector handles theme color and border-radius detection + * Provides auto-detection capabilities for consistent theming + */ +class ThemeDetector { + /** + * Initialize Theme Detector + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + this.extension = extension; + this.cachedPanelColor = null; + this.cachedMenuColor = null; + this.cachedBorderRadius = null; + this.cachedPopupColor = null; + this.currentPanelBaseColor = null; // Track base color after detection (Phase 2.5B+) + this.lastThemeCheck = 0; + this.lastBorderRadiusCheck = 0; + this.currentTheme = null; // Store current theme name + this._cachedGtkCssColor = undefined; // undefined = not yet computed; null = computed but not found + + // Performance optimization - cache detectAllThemeProperties + this.themePropertiesCache = null; + this.lastThemePropertiesCheck = 0; + this.themePropertiesCacheTimeout = TIMING.CACHE_THEME_PROPERTIES; + + // Signal management - Phase 2 GlobalSignalsHandler integration + this._signalsHandler = new GlobalSignalsHandler(); + + // Event-driven monitoring + this.radiusDetectionTimeout = null; + this._themeChangeTimeout = null; // Added for theme change race condition fix + + // Theme monitoring (Phase 2.5B) - Keep Gio.Settings references to prevent GC + this._interfaceSettings = null; + this._cinnamonInterfaceSettings = null; + + this._printAndSaveCurrentTheme(); + } + + /** + * Setup theme change monitoring with event-driven approach + */ + setup() { + try { + if (Main.themeManager) { + // Use GlobalSignalsHandler for automatic cleanup + this._signalsHandler.add([ + Main.themeManager, + SIGNALS.THEME_CHANGED, + () => { + this.extension.debugLog("Theme changed signal received, waiting for CSS load..."); + + // CRITICAL FIX: Debounce theme detection to avoid race condition + // CSS files need time to load into DOM before we can read colors + if (this._themeChangeTimeout) { + GLib.source_remove(this._themeChangeTimeout); + } + + this._themeChangeTimeout = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + TIMING.DEBOUNCE_MEDIUM, // 100ms delay for CSS to load + () => { + this.extension.debugLog("CSS loaded, re-detecting theme data..."); + + // NEW: Unified theme re-detection with proper module separation + const detectedData = this.redetectAllThemeData(); + this.extension.applyDetectedThemeData(detectedData); + + this._themeChangeTimeout = null; + return false; // Remove timeout + } + ); + }, + ]); + + // Event-driven panel size monitoring for radius detection + if (Main.panel && Main.panel.actor && this.extension.autoDetectRadius) { + this._signalsHandler.add([ + Main.panel.actor, + SIGNALS.ALLOCATION_CHANGED, + () => { + // Debounce radius detection to avoid excessive calls during resize + if (this.radiusDetectionTimeout) { + GLib.source_remove(this.radiusDetectionTimeout); + } + this.radiusDetectionTimeout = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + TIMING.DEBOUNCE_LONG, + () => { + this.extension.onAutoDetectRadiusChanged(); + this.radiusDetectionTimeout = null; + return false; + } + ); + }, + ]); + this.extension.debugLog("Panel size change monitoring setup for radius detection"); + } + + this.extension.debugLog("Theme change handler setup successfully"); + } + + // Setup color-scheme monitoring (Phase 2.5B) + this.setupColorSchemeMonitoring(); + } catch (e) { + this.extension.debugLog("Failed to setup theme change handler:", e); + } + this._printAndSaveCurrentTheme(); + } + + /** + * Invalidate all cached values + */ + invalidateCache() { + this.cachedPanelColor = null; + this.cachedMenuColor = null; + this.cachedBorderRadius = null; + this.cachedPopupColor = null; + this.lastThemeCheck = 0; + this.lastBorderRadiusCheck = 0; + this._cachedGtkCssColor = undefined; + } + + /** + * Cleanup theme change monitoring and event-driven signals + */ + cleanup() { + // Cleanup all signal connections automatically + this._signalsHandler.destroy(); + + // Cleanup radius detection timeout (not a signal, needs manual cleanup) + if (this.radiusDetectionTimeout) { + GLib.source_remove(this.radiusDetectionTimeout); + this.radiusDetectionTimeout = null; + this.extension.debugLog("Radius detection timeout cleaned up"); + } + + // Cleanup theme change debounce timeout + if (this._themeChangeTimeout) { + GLib.source_remove(this._themeChangeTimeout); + this._themeChangeTimeout = null; + } + + // Clear Gio.Settings references + this._interfaceSettings = null; + this._cinnamonInterfaceSettings = null; + + this.extension.debugLog("ThemeDetector cleanup complete"); + } + + /** + * Safely parse color string with validation and fallback + * Wrapper around parseColorString() that validates the result and provides sensible fallbacks. + * Use this for user-provided color inputs (settings, overrides) where invalid values are possible. + * + * @param {string} colorString - Color string to parse (rgba/rgb/hex format) + * @param {Object} fallback - Fallback color if parsing fails or returns default gray + * @param {string} context - Context description for debug logging (e.g., "popup color", "panel override") + * @returns {Object} Parsed color {r, g, b} or fallback + */ + _safeParseColor(colorString, fallback = DEFAULT_COLORS.FALLBACK_DARK, context = "color") { + try { + // Validate input + if (!colorString || typeof colorString !== "string") { + this.extension.debugLog(`[SafeParse] Invalid ${context} string (not a string):`, colorString); + return fallback; + } + + // Parse color + const parsed = this.parseColorString(colorString); + + // Validate parsed result + if ( + !parsed || + typeof parsed.r !== "number" || + typeof parsed.g !== "number" || + typeof parsed.b !== "number" + ) { + this.extension.debugLog(`[SafeParse] ${context} parsing returned invalid object:`, parsed); + return fallback; + } + + // Check if we got the parseColorString default fallback (128, 128, 128) + // This indicates parsing failed but didn't throw an error + if (parsed.r === 128 && parsed.g === 128 && parsed.b === 128) { + this.extension.debugLog( + `[SafeParse] ${context} parsing failed (got default gray), using fallback for input:`, + colorString + ); + return fallback; + } + + // Valid color parsed successfully + return parsed; + } catch (e) { + this.extension.debugLog(`[SafeParse] Exception parsing ${context}:`, e.message); + return fallback; + } + } + + /** + * Parse color string (rgba or hex) to RGB object + * @param {string} colorString - Color string in rgba(r,g,b,a) or #hex format + * @returns {Object} RGB color object with r, g, b properties + */ + parseColorString(colorString) { + try { + // Handle rgba format: rgba(r, g, b, a) + if (colorString.startsWith("rgba(")) { + const values = colorString.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/); + if (values) { + return { + r: parseInt(values[1]), + g: parseInt(values[2]), + b: parseInt(values[3]), + }; + } + } + + // Handle rgb format: rgb(r, g, b) + if (colorString.startsWith("rgb(")) { + const values = colorString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (values) { + return { + r: parseInt(values[1]), + g: parseInt(values[2]), + b: parseInt(values[3]), + }; + } + } + + // Handle hex format: #rrggbb + if (colorString.startsWith("#")) { + const hex = colorString.slice(1); + if (hex.length === 6) { + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; + } + } + + this.extension.debugLog("Failed to parse color string:", colorString); + return DEFAULT_COLORS.FALLBACK_GREY; + } catch (e) { + this.extension.debugLog("Error parsing color string:", e); + return DEFAULT_COLORS.FALLBACK_GREY; + } + } + + /** + * Detect panel base color from current theme + * @returns {Object} RGB color object with r, g, b properties (enhanced with HSP metadata) + */ + getPanelBaseColor() { + // Use override color if enabled - READ FROM SETTINGS (not cached property!) + if (this.extension.overridePanelColor) { + const overrideColorString = this.extension.settings.getValue("choose-override-panel-color"); + this.extension.debugLog("Using panel override color:", overrideColorString); + + // Use safe parser for user-provided color picker value + const color = this._safeParseColor( + overrideColorString, + DEFAULT_COLORS.FALLBACK_DARK, + "panel color override" + ); + + // Add HSP metadata to override color + if (!color.hsp) { + color.hsp = ThemeUtils.getHSP(color.r, color.g, color.b); + color.isDark = ThemeUtils.getBgDark(color.r, color.g, color.b); + } + + return color; + } + + // Cache panel color for 10 seconds to avoid redundant detection + if (this.cachedPanelColor !== null && Date.now() - this.lastThemeCheck < 10000) { + return this.cachedPanelColor; + } + + try { + this.extension.debugLog("Detecting panel base color..."); + + if (Main.panel.actor && Main.panel.actor.get_theme_node) { + let themeNode = Main.panel.actor.get_theme_node(); + let backgroundColor = themeNode.get_background_color(); + + if (backgroundColor) { + let color = { + r: backgroundColor.red, + g: backgroundColor.green, + b: backgroundColor.blue, + }; + + // Calculate HSP perceived brightness + const hsp = ThemeUtils.getHSP(color.r, color.g, color.b); + const isDark = ThemeUtils.getBgDark(color.r, color.g, color.b); + + this.extension.debugLog(`Detected panel color: rgb(${color.r}, ${color.g}, ${color.b})`); + this.extension.debugLog(` → HSP brightness: ${hsp.toFixed(1)} (threshold: 127.5)`); + this.extension.debugLog(` → Is dark theme: ${isDark}`); + + // Store HSP metadata in color object + color.hsp = hsp; + color.isDark = isDark; + + this.cachedPanelColor = color; + this.lastThemeCheck = Date.now(); + return color; + } + } + + // Fallback to dark color for most themes + this.extension.debugLog("Using fallback panel color: rgb(46, 52, 64)"); + this.cachedPanelColor = { + r: 46, + g: 52, + b: 64, + hsp: ThemeUtils.getHSP(46, 52, 64), + isDark: true, + }; + this.lastThemeCheck = Date.now(); + return this.cachedPanelColor; + } catch (e) { + this.extension.debugLog("Error detecting panel color:", e); + this.cachedPanelColor = { + r: 46, + g: 52, + b: 64, + hsp: ThemeUtils.getHSP(46, 52, 64), + isDark: true, + }; + this.lastThemeCheck = Date.now(); + return this.cachedPanelColor; + } + } + + /** + * Detect border-radius from current theme by inspecting UI elements + * @returns {number} Detected border radius in pixels + */ + detectThemeBorderRadius() { + // Cache border-radius for 10 seconds (was 1 second - optimized for frequent calls) + if (this.cachedBorderRadius !== null && Date.now() - this.lastBorderRadiusCheck < 10000) { + return this.cachedBorderRadius; + } + + try { + this.extension.debugLog("Detecting theme border-radius..."); + + let detectedRadii = []; + + // Check main panel first + let panelRadius = this.getElementBorderRadius(Main.panel.actor); + detectedRadii.push(panelRadius); + if (panelRadius > 0) { + this.extension.debugLog(`Detected panel border-radius: ${panelRadius}px`); + this.cachedBorderRadius = panelRadius; + this.lastBorderRadiusCheck = Date.now(); + return panelRadius; + } + + // Check popup menus + let menuRadius = this.getMenuBorderRadius(); + detectedRadii.push(menuRadius); + if (menuRadius > 0) { + this.extension.debugLog(`Detected menu border-radius: ${menuRadius}px`); + this.cachedBorderRadius = menuRadius; + this.lastBorderRadiusCheck = Date.now(); + return menuRadius; + } + + // Check notification area + let notificationRadius = this.getNotificationBorderRadius(); + detectedRadii.push(notificationRadius); + if (notificationRadius > 0) { + this.extension.debugLog(`Detected notification border-radius: ${notificationRadius}px`); + this.cachedBorderRadius = notificationRadius; + this.lastBorderRadiusCheck = Date.now(); + return notificationRadius; + } + + // Check if theme is truly flat + let allZero = detectedRadii.every((radius) => radius === 0); + if (allZero) { + this.extension.debugLog("Theme uses flat design, not applying border radius"); + this.cachedBorderRadius = 0; + this.lastBorderRadiusCheck = Date.now(); + return 0; + } + + // Use fallback value + this.extension.debugLog( + `Detection inconsistency, using fallback border-radius: ${this.extension.borderRadius}px` + ); + this.cachedBorderRadius = this.extension.borderRadius; + this.lastBorderRadiusCheck = Date.now(); + return this.cachedBorderRadius; + } catch (e) { + this.extension.debugLog("Error detecting theme border-radius:", e); + this.cachedBorderRadius = this.extension.borderRadius; + this.lastBorderRadiusCheck = Date.now(); + return this.extension.borderRadius; + } + } + + /** + * Extract border-radius value from a Clutter.Actor element + * @param {Clutter.Actor} actor - The actor to inspect + * @returns {number} Border radius in pixels + */ + getElementBorderRadius(actor) { + if (!actor || !actor.get_theme_node) return 0; + + try { + let themeNode = actor.get_theme_node(); + let radius = themeNode.get_border_radius(St.Corner.TOPLEFT); + this.extension.debugLog(`Element border-radius: ${radius}px`); + return Math.round(radius); + } catch (e) { + this.extension.debugLog("Failed to get element border-radius:", e); + return 0; + } + } + + /** + * Detect border-radius from popup menus + * @returns {number} Menu border radius in pixels + */ + getMenuBorderRadius() { + try { + this.extension.debugLog("Attempting menu border-radius detection..."); + + // Check existing menu elements first + let menuManager = Main.panel.menuManager; + if (menuManager && menuManager._menus && menuManager._menus.length > 0) { + let existingMenu = menuManager._menus[0]; + if (existingMenu && existingMenu.actor) { + let radius = this.getElementBorderRadius(existingMenu.actor); + if (radius > 0) { + this.extension.debugLog(`Found menu radius from menuManager: ${radius}px`); + return radius; + } + } + } + + // Fallback: check panel elements + if (Main.panel._leftBox && Main.panel._leftBox.get_children().length > 0) { + let firstButton = Main.panel._leftBox.get_children()[0]; + if (firstButton) { + let radius = this.getElementBorderRadius(firstButton); + if (radius > 0) { + this.extension.debugLog(`Found radius from panel button: ${radius}px`); + return radius; + } + } + } + + this.extension.debugLog("No menu border-radius detected"); + return 0; + } catch (e) { + this.extension.debugLog("Menu border-radius detection failed:", e.message); + return 0; + } + } + + /** + * Detect border-radius from notification area elements + * @returns {number} Notification border radius in pixels + */ + getNotificationBorderRadius() { + try { + // Check if notification area exists + if (Main.messageTray && Main.messageTray.actor) { + return this.getElementBorderRadius(Main.messageTray.actor); + } + + // Check system indicators + if (Main.panel.statusArea) { + for (let indicator in Main.panel.statusArea) { + let statusActor = Main.panel.statusArea[indicator]; + if (statusActor && statusActor.actor) { + let radius = this.getElementBorderRadius(statusActor.actor); + if (radius > 0) return radius; + } + } + } + + return 0; + } catch (e) { + this.extension.debugLog("Notification border-radius detection failed:", e); + return 0; + } + } + + /** + * Print and save the current GTK theme name + */ + _printAndSaveCurrentTheme() { + try { + const settings = new Gio.Settings({ schema: "org.cinnamon.desktop.interface" }); + this.currentTheme = settings.get_string("gtk-theme"); + this.extension.debugLog(`Current GTK theme: ${this.currentTheme}`); + } catch (e) { + this.extension.debugLog("Error getting current theme:", e); + } + } + + /** + * Centralized detection of all theme properties - caches everything without forcing theme reload + * Called once in extension.enable() to avoid multiple theme loading + */ + detectAllThemeProperties() { + const now = Date.now(); + + // Return early if cache is still valid + if (this.themePropertiesCache && now - this.lastThemePropertiesCheck < this.themePropertiesCacheTimeout) { + this.extension.debugLog("Using cached theme properties"); + return; + } + + this.extension.debugLog("Detecting all theme properties at once to avoid multiple theme loads"); + + // Detect and cache all properties at once (theme loads automatically when accessing elements) + this.getPanelBaseColor(); // Caches panelColor + this.detectThemeBorderRadius(); // Caches borderRadius + + // Cache the detection timestamp + this.themePropertiesCache = true; + this.lastThemePropertiesCheck = now; + + this.extension.debugLog("All theme properties detected and cached"); + } + + // ===== THEMEUTILS INTEGRATION - NEW METHODS ===== + + /** + * Generate automatic highlight color for menu items based on panel color + * Uses ThemeUtils to create appropriate hover/highlight effects + * + * @param {number} intensity - Highlight intensity (0-1, default: 0.3) + * @returns {string} CSS rgba string for highlight color + */ + getAutoHighlightColor(intensity = 0.3) { + const bgColor = this.getPanelBaseColor(); + + // Generate highlight with specified intensity + const highlightRgb = ThemeUtils.getAutoHighlightColor([bgColor.r, bgColor.g, bgColor.b], intensity); + + // Convert to CSS with subtle transparency + const highlightCss = ThemeUtils.rgbaToCss( + highlightRgb[0], + highlightRgb[1], + highlightRgb[2], + 0.15 // Subtle transparency for hover effect + ); + + this.extension.debugLog(`Auto-generated highlight color: ${highlightCss}`); + this.extension.debugLog(` → Base: rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`); + this.extension.debugLog(` → Intensity: ${(intensity * 100).toFixed(0)}%`); + + return highlightCss; + } + + /** + * Ensure minimum WCAG contrast between foreground and background colors + * Automatically adjusts foreground if contrast is too low + * + * @param {Object} fgColor - RGB object {r, g, b} + * @param {Object} bgColor - RGB object {r, g, b} + * @param {number} minRatio - Minimum contrast ratio (default: 4.5 for WCAG AA) + * @returns {Object} Adjusted foreground color {r, g, b} + */ + ensureTextContrast(fgColor, bgColor, minRatio = 4.5) { + const fgArray = [fgColor.r, fgColor.g, fgColor.b]; + const bgArray = [bgColor.r, bgColor.g, bgColor.b]; + + // Calculate initial contrast + const initialRatio = ThemeUtils.contrastRatio(fgArray, bgArray); + + // Check if adjustment is needed + if (initialRatio >= minRatio) { + this.extension.debugLog(`Text contrast OK: ${initialRatio.toFixed(2)}:1 (min: ${minRatio}:1)`); + return fgColor; + } + + this.extension.debugLog(`Text contrast too low: ${initialRatio.toFixed(2)}:1 (min: ${minRatio}:1)`); + this.extension.debugLog(" → Auto-adjusting foreground color..."); + + // Adjust foreground to meet minimum contrast + const adjusted = ThemeUtils.ensureContrast(fgArray, bgArray, minRatio); + const finalRatio = ThemeUtils.contrastRatio(adjusted, bgArray); + + const result = { + r: adjusted[0], + g: adjusted[1], + b: adjusted[2], + }; + + this.extension.debugLog(` → Adjusted to: rgb(${result.r}, ${result.g}, ${result.b})`); + this.extension.debugLog(` → New contrast: ${finalRatio.toFixed(2)}:1`); + + return result; + } + + /** + * Generate automatic foreground (text) color based on background + * Returns white for dark backgrounds, black for light backgrounds + * + * @param {Object} bgColor - RGB object {r, g, b} (optional, uses panel color if not provided) + * @param {number} alpha - Alpha channel (0-1, default: 1.0) + * @returns {Object} Foreground color {r, g, b, a} + */ + getAutoForegroundColor(bgColor = null, alpha = 1.0) { + if (!bgColor) { + bgColor = this.getPanelBaseColor(); + } + + const fgArray = ThemeUtils.getAutoFgColor([bgColor.r, bgColor.g, bgColor.b], alpha); + + const result = { + r: fgArray[0], + g: fgArray[1], + b: fgArray[2], + a: fgArray[3], + }; + + this.extension.debugLog(`Auto foreground color: rgba(${result.r}, ${result.g}, ${result.b}, ${result.a})`); + this.extension.debugLog(` → Background is ${bgColor.isDark ? "dark" : "light"} theme`); + + return result; + } + + /** + * Validate if a color is suitable for use as accent color + * Rejects grey, white, and black colors + * + * @param {Object} color - RGB object {r, g, b} + * @returns {Object} {isValid: boolean, reason: string} + */ + validateAccentColor(color) { + const result = ThemeUtils.isValidAccent(color.r, color.g, color.b); + + this.extension.debugLog(`Accent color validation: rgb(${color.r}, ${color.g}, ${color.b})`); + this.extension.debugLog(` → ${result.isValid ? "✓" : "✗"} ${result.reason}`); + + return result; + } + + /** + * Generate a color palette (lighter and darker shades) from base color + * + * @param {Object} baseColor - RGB object {r, g, b} (optional, uses panel color if not provided) + * @param {number} count - Number of colors to generate (default: 5) + * @returns {Array} Array of RGB objects [{r, g, b}, {r, g, b}, ...] + */ + generateColorPalette(baseColor = null, count = 5) { + if (!baseColor) { + baseColor = this.getPanelBaseColor(); + } + + const palette = ThemeUtils.generateColorPalette([baseColor.r, baseColor.g, baseColor.b], count); + + // Convert array format to object format + const result = palette.map((rgb) => ({ + r: rgb[0], + g: rgb[1], + b: rgb[2], + })); + + this.extension.debugLog( + `Generated ${count}-color palette from rgb(${baseColor.r}, ${baseColor.g}, ${baseColor.b})` + ); + + return result; + } + + // ===== PHASE 2.5B - THEME ACCENT DETECTION ===== + + /** + * Get system color scheme preference from Quick Settings (Dark/Light mode toggle) + * @returns {string} 'prefer-dark' | 'prefer-light' | 'default' + */ + getSystemColorScheme() { + try { + const interfaceSettings = new Gio.Settings({ + schema_id: "org.gnome.desktop.interface", + }); + const scheme = interfaceSettings.get_string("color-scheme"); + this.extension.debugLog(`System color-scheme: ${scheme}`); + return scheme; + } catch (e) { + this.extension.debugLog(`Cannot read color-scheme: ${e}`); + return "default"; + } + } + + /** + * Get active GTK theme name + * @returns {string} Theme name (e.g., "Mint-Y-Dark") + */ + getActiveGtkTheme() { + try { + const settings = new Gio.Settings({ + schema_id: "org.cinnamon.desktop.interface", + }); + const themeName = settings.get_string("gtk-theme"); + this.extension.debugLog(`Active GTK theme: ${themeName}`); + return themeName || "Mint-Y"; + } catch (e) { + this.extension.debugLog(`Error getting gtk-theme: ${e}`); + return "Mint-Y"; + } + } + + /** + * Determine if dark mode should be preferred + * PRIORITY: color-scheme setting > gtk-theme suffix > HSP brightness fallback + * + * CRITICAL FIX (Phase 2.5B+): FALLBACK 2 now reads panel color from GTK CSS files, + * not from DOM, to avoid stale color from previous theme switch. + * + * @returns {boolean} True if dark mode preferred + */ + isDarkModePreferred() { + // PRIMARY: Check system color-scheme preference (Quick Settings toggle) + const scheme = this.getSystemColorScheme(); + + if (scheme === "prefer-dark") { + this.extension.debugLog("Dark mode: color-scheme = prefer-dark ✓"); + return true; + } + if (scheme === "prefer-light") { + this.extension.debugLog("Dark mode: color-scheme = prefer-light (FALSE)"); + return false; + } + + // EXTENSION OVERRIDE: User-set dark/light mode override (dark-light-override setting) + // Applies globally: affects sidebar fallback, accent generation, and wallpaper extraction + const toneMode = this.extension.darkLightOverride || 'auto'; + if (toneMode === 'dark') { + this.extension.debugLog("Dark mode: extension override = force dark ✓"); + return true; + } + if (toneMode === 'light') { + this.extension.debugLog("Dark mode: extension override = force light (FALSE)"); + return false; + } + + // FALLBACK 1: Check gtk-theme suffix or contains -Dark/-Light + const gtkTheme = this.getActiveGtkTheme(); + this.extension.debugLog(`[isDarkModePreferred] Checking theme name: "${gtkTheme}"`); + + // Check if theme contains -Dark (e.g., "Mint-Y-Dark-Orange", "Adwaita-Dark") + if (gtkTheme.includes("-Dark") || gtkTheme.includes("-dark")) { + this.extension.debugLog(`Dark mode: theme contains -Dark (${gtkTheme}) ✓`); + return true; + } + // Check if theme explicitly contains -Light + if (gtkTheme.includes("-Light") || gtkTheme.includes("-light")) { + this.extension.debugLog(`Dark mode: theme contains -Light (${gtkTheme}) (FALSE)`); + return false; + } + + this.extension.debugLog(`[isDarkModePreferred] No -Dark/-Light in theme name, falling back to HSP`); + + // FALLBACK 2: HSP brightness calculation (read from CSS, NOT DOM!) + // Try reading panel color from GTK CSS first (accurate), fallback to DOM if needed + let panelColor = this._detectPanelColorFromGtkCss(); + + if (!panelColor) { + // Last resort: read from DOM (may have stale color) + this.extension.debugLog(`[isDarkModePreferred] CSS read failed, using DOM (may be stale)`); + const wasOverride = this.extension.overridePanelColor; + this.extension.overridePanelColor = false; + panelColor = this.getPanelBaseColor(); + this.extension.overridePanelColor = wasOverride; + } + + const isDark = panelColor.isDark; // Already calculated in getPanelBaseColor() + this.extension.debugLog( + `Dark mode: HSP fallback (${panelColor.hsp.toFixed(1)}) → ${isDark ? "dark ✓" : "light (FALSE)"}` + ); + + return isDark; + } + + /** + * Setup color-scheme and GTK theme monitoring + * Automatically refreshes accent colors when user changes theme or dark/light mode + */ + setupColorSchemeMonitoring() { + try { + // Monitor org.gnome.desktop.interface for color-scheme (dark/light preference) + // Store reference to prevent garbage collection + this._interfaceSettings = new Gio.Settings({ + schema_id: "org.gnome.desktop.interface", + }); + + this._signalsHandler.add([ + this._interfaceSettings, + "changed::color-scheme", + () => { + const newScheme = this._interfaceSettings.get_string("color-scheme"); + this.extension.debugLog(`Color scheme changed to: ${newScheme}`); + + // Invalidate all cached theme properties + this.invalidateCache(); + + // NEW UNIFIED FLOW: Detect all theme properties and apply based on switches + this.extension.debugLog("Using unified detection flow for color-scheme change..."); + const detectedThemeData = this.redetectAllThemeData(); + this.extension.applyDetectedThemeData(detectedThemeData); + }, + ]); + + // Monitor org.cinnamon.desktop.interface for GTK theme changes + // Store reference to prevent garbage collection + this._cinnamonInterfaceSettings = new Gio.Settings({ + schema_id: "org.cinnamon.desktop.interface", + }); + + this._signalsHandler.add([ + this._cinnamonInterfaceSettings, + "changed::gtk-theme", + () => { + const newTheme = this._cinnamonInterfaceSettings.get_string("gtk-theme"); + this.extension.debugLog(`GTK theme changed to: ${newTheme}`); + + // Invalidate all cached theme properties + this.invalidateCache(); + + // NEW UNIFIED FLOW: Detect all theme properties and apply based on switches + this.extension.debugLog("Using unified detection flow for GTK theme change..."); + const detectedThemeData = this.redetectAllThemeData(); + this.extension.applyDetectedThemeData(detectedThemeData); + }, + ]); + + this.extension.debugLog("Color-scheme and GTK theme monitoring setup successfully"); + } catch (e) { + this.extension.debugLog(`Failed to setup theme monitoring: ${e}`); + } + } + + /** + * Detect accent color from active GTK theme CSS + * Searches for accent color patterns in priority order: + * 1. switch:checked { background-color: #xxxxxx } (most reliable) + * 2. @define-color theme_selected_bg_color #xxxxxx + * + * @returns {Object|null} {r, g, b} or null if no valid accent found + */ + detectThemeAccentColor() { + const themeName = this.getActiveGtkTheme(); + this.extension.debugLog(`Detecting accent from theme: ${themeName}`); + + // Theme paths to check (user themes take priority over system themes) + // Priority order: XDG user > legacy user > system > local system + const themePaths = [ + `${GLib.get_home_dir()}/.local/share/themes/${themeName}`, // XDG standard (highest priority) + `${GLib.get_home_dir()}/.themes/${themeName}`, // Legacy location + `/usr/share/themes/${themeName}`, // System themes + `/usr/local/share/themes/${themeName}`, // Local system themes + ]; + + for (const themePath of themePaths) { + const gtkCssPath = `${themePath}/gtk-3.0/gtk.css`; + const cssFile = Gio.File.new_for_path(gtkCssPath); + + try { + // Sync file read: runs only on theme changes and explicit button clicks, + // not on the CSS generation hot path. Async conversion not warranted here. + const [success, contents] = cssFile.load_contents(null); + if (!success) { + this.extension.debugLog(` → Failed to read: ${gtkCssPath}`); + continue; + } + + const cssText = new TextDecoder().decode(contents); + this.extension.debugLog(` → Parsing CSS file: ${gtkCssPath} (${cssText.length} bytes)`); + + // Priority 1: switch:checked { background-color: #xxxxxx } + this.extension.debugLog(` → Searching for switch:checked pattern...`); + const switchMatch = cssText.match(/switch:checked\s*\{[^}]*background-color:\s*#([0-9a-fA-F]{6})/); + + if (switchMatch) { + this.extension.debugLog(` → Found switch:checked match: #${switchMatch[1]}`); + try { + const hex = switchMatch[1]; + const color = this._hexToRgb(hex); + this.extension.debugLog(` → Converted to RGB: rgb(${color.r}, ${color.g}, ${color.b})`); + this.extension.debugLog( + ` → Checking 'this' context: ${typeof this}, has validateAccentColor: ${typeof this + .validateAccentColor}` + ); + + // Validate accent (reject grey/white/black) + const validation = this.validateAccentColor(color); + if (validation.isValid) { + this.extension.debugLog( + ` ✓ Accent from switch:checked: rgb(${color.r}, ${color.g}, ${color.b})` + ); + return color; + } else { + this.extension.debugLog(` ✗ Rejected switch:checked - ${validation.reason}`); + } + } catch (e) { + this.extension.debugLog(` → Error processing switch:checked: ${e}`); + this.extension.debugLog(` → Error stack: ${e.stack}`); + } + } else { + this.extension.debugLog(` → No switch:checked match found`); + } + + // Priority 2: @define-color theme_selected_bg_color #xxxxxx + this.extension.debugLog(` → Searching for theme_selected_bg_color pattern...`); + const selectedMatch = cssText.match(/@define-color\s+theme_selected_bg_color\s+#([0-9a-fA-F]{6})/); + + if (selectedMatch) { + this.extension.debugLog(` → Found theme_selected_bg_color match: #${selectedMatch[1]}`); + try { + const hex = selectedMatch[1]; + const color = this._hexToRgb(hex); + this.extension.debugLog(` → Converted to RGB: rgb(${color.r}, ${color.g}, ${color.b})`); + + const validation = this.validateAccentColor(color); + if (validation.isValid) { + this.extension.debugLog( + ` ✓ Accent from theme_selected_bg_color: rgb(${color.r}, ${color.g}, ${color.b})` + ); + return color; + } else { + this.extension.debugLog(` ✗ Rejected theme_selected_bg_color - ${validation.reason}`); + } + } catch (e) { + this.extension.debugLog(` → Error processing theme_selected_bg_color: ${e}`); + } + } else { + this.extension.debugLog(` → No theme_selected_bg_color match found`); + } + + // No valid accent found in this CSS file + this.extension.debugLog(` → No valid accent patterns found in ${gtkCssPath}`); + } catch (e) { + this.extension.debugLog(` → Error reading ${gtkCssPath}: ${e}`); + } + } + + this.extension.debugLog(` ✗ No accent color found for theme: ${themeName} (neutral/grey theme)`); + return null; + } + + /** + * Detect panel base color directly from GTK theme CSS files + * Reads @theme_bg_color definition from gtk.css (PURE theme color, not DOM-applied) + * @returns {Object|null} {r, g, b, hsp, isDark} or null if not found + */ + _detectPanelColorFromGtkCss() { + // Guard: return cached result to avoid sync file I/O on repeated calls. + // isDarkModePreferred() is called from blurTemplateManager (CSS generation hot path), + // so without caching every template render would trigger a filesystem read for + // themes without a -Dark/-Light suffix. Invalidated by invalidateCache() on every + // gtk-theme or color-scheme change. + if (this._cachedGtkCssColor !== undefined) { + return this._cachedGtkCssColor; + } + + const themeName = this.getActiveGtkTheme(); + this.extension.debugLog(`Reading panel bg from theme CSS: ${themeName}`); + + const themePaths = [ + `${GLib.get_home_dir()}/.local/share/themes/${themeName}`, + `${GLib.get_home_dir()}/.themes/${themeName}`, + `/usr/share/themes/${themeName}`, + `/usr/local/share/themes/${themeName}`, + ]; + + for (const themePath of themePaths) { + const gtkCssPath = `${themePath}/gtk-3.0/gtk.css`; + const cssFile = Gio.File.new_for_path(gtkCssPath); + + try { + // Sync file read: result cached at function entry so this loop runs at most once + // per theme session. Async conversion would cascade through blurTemplateManager. + const [success, contents] = cssFile.load_contents(null); + if (!success) continue; + + const cssText = new TextDecoder().decode(contents); + + // Search for: @define-color theme_bg_color #xxxxxx + const bgMatch = cssText.match(/@define-color\s+theme_bg_color\s+#([0-9a-fA-F]{6})/); + + if (bgMatch) { + const hex = bgMatch[1]; + const color = this._hexToRgb(hex); + + // Add HSP metadata + color.hsp = ThemeUtils.getHSP(color.r, color.g, color.b); + color.isDark = ThemeUtils.getBgDark(color.r, color.g, color.b); + + this.extension.debugLog( + ` ✓ Panel bg from theme_bg_color: rgb(${color.r}, ${color.g}, ${color.b})` + ); + this.extension.debugLog(` → HSP: ${color.hsp.toFixed(1)}, isDark: ${color.isDark}`); + this._cachedGtkCssColor = color; + return color; + } + } catch (e) { + this.extension.debugLog(` → Error reading ${gtkCssPath}: ${e}`); + } + } + + this.extension.debugLog(` ✗ No theme_bg_color found in gtk.css, falling back to DOM detection`); + this._cachedGtkCssColor = null; + return null; + } + + /** + * Helper: Convert hex string to RGB object + * @param {string} hex - Hex color without # (e.g., "ff0000") + * @returns {Object} {r, g, b} + */ + _hexToRgb(hex) { + return { + r: parseInt(hex.substring(0, 2), 16), + g: parseInt(hex.substring(2, 4), 16), + b: parseInt(hex.substring(4, 6), 16), + }; + } + + /** + * Generate complete accent color system from base accent color + * Creates 3 variants optimized for dark/light themes: + * - accent: Base color (for primary accents) + * - border: ±15%/10% adjusted (for borders and highlights) + * - tint: Same as border but with low alpha (for overlays/hovers) + * - shadow: ±85% adjusted (deep dark or soft light for glows) + * + * @param {Object} accentColor - Base accent {r, g, b} + * @param {boolean} isDarkMode - Optional dark mode override (if not provided, will detect) + * @returns {Object} {accent, border, tint, shadow} all in CSS rgba() format + */ + generateAccentSystem(accentColor, isDarkMode = null) { + if (!accentColor) { + this.extension.debugLog("No accent color provided, returning null"); + return null; + } + + // Use provided isDarkMode or detect automatically + const isDark = isDarkMode !== null ? isDarkMode : this.isDarkModePreferred(); + this.extension.debugLog(`Generating accent system for ${isDark ? "DARK" : "LIGHT"} theme`); + this.extension.debugLog(` → Base accent: rgb(${accentColor.r}, ${accentColor.g}, ${accentColor.b})`); + + const accentArray = [accentColor.r, accentColor.g, accentColor.b]; + + // Generate variants based on theme mode + const borderArray = isDark + ? ThemeUtils.colorShade(accentArray, 0.15) // 15% lighter for dark themes + : ThemeUtils.colorShade(accentArray, -0.1); // 10% darker for light themes + + const shadowArray = isDark + ? ThemeUtils.colorShade(accentArray, -0.85) // 85% darker (deep dark) + : ThemeUtils.colorShade(accentArray, 0.85); // 85% lighter (soft light) + + // Convert to CSS rgba strings with appropriate alpha values + const result = { + accent: ThemeUtils.rgbaToCss(accentArray[0], accentArray[1], accentArray[2], 0.8), + border: ThemeUtils.rgbaToCss(borderArray[0], borderArray[1], borderArray[2], 0.6), + tint: ThemeUtils.rgbaToCss(borderArray[0], borderArray[1], borderArray[2], 0.15), + shadow: ThemeUtils.rgbaToCss(shadowArray[0], shadowArray[1], shadowArray[2], 0.3), + }; + + this.extension.debugLog(`Accent system generated:`); + this.extension.debugLog(` → accent: ${result.accent}`); + this.extension.debugLog(` → border: ${result.border}`); + this.extension.debugLog(` → tint: ${result.tint}`); + this.extension.debugLog(` → shadow: ${result.shadow}`); + + return result; + } + + // ======================================================================== + // THEME RE-DETECTION METHODS (Refactored from extension.js) + // ======================================================================== + + /** + * Re-detect ALL theme properties on theme change + * This is the MAIN method called from extension.js theme-set callback + * + * CRITICAL FIX (Phase 2.5B+): Detect panel color FIRST to get accurate isDarkMode! + * Old flow: isDarkMode from DOM → accent system → panel color from CSS + * New flow: panel color from CSS → isDarkMode from new color → accent system ✓ + * + * @returns {Object} Detected properties with apply flags + * { + * borderRadius: {detected: number, shouldApply: boolean}, + * accentColor: {detected: string, shouldApply: boolean, variants: object}, + * panelColor: {detected: string, shouldApply: boolean}, + * popupColor: {detected: string, shouldApply: boolean}, + * isDarkMode: boolean + * } + */ + redetectAllThemeData() { + this.extension.debugLog("Re-detecting all theme properties..."); + + // Refresh current theme name before logging detection summary + this._printAndSaveCurrentTheme(); + + // Step 1: Clear cache ONCE at the beginning + this.invalidateCache(); + + // 2. Detect panel color FROM THEME CSS FIRST (to get accurate isDarkMode) + const detectedPanelColor = this.detectPanelColorFromTheme(); + this.currentPanelBaseColor = detectedPanelColor; // Store original theme panel color + const shouldApplyPanel = this.shouldApplyPanelColor(); + this.extension.debugLog(`Panel color: ${detectedPanelColor} (apply: ${shouldApplyPanel})`); + + // 3. Detect dark/light mode from NEW panel color (needed for accent generation) + const isDarkMode = this.isDarkModePreferred(); + this.extension.debugLog(`Dark mode: ${isDarkMode}`); + + // 4. Detect border-radius + const detectedRadius = this.detectThemeBorderRadius(); + const shouldApplyRadius = this.shouldApplyBorderRadius(); + this.extension.debugLog(`Border radius: ${detectedRadius}px (apply: ${shouldApplyRadius})`); + + // 5. Detect accent color + let detectedAccent = null; + let accentVariants = null; + const shouldApplyAccent = this.shouldApplyAccent(); + + if (shouldApplyAccent) { + detectedAccent = this.detectThemeAccentColor(); + if (detectedAccent) { + accentVariants = this.generateAccentSystem(detectedAccent, isDarkMode); + this.extension.debugLog(`Accent color: rgb(${detectedAccent.r}, ${detectedAccent.g}, ${detectedAccent.b}) (apply: true)`); + } + } else { + this.extension.debugLog(`Accent auto-apply disabled (apply: false)`); + } + + // 6. Determine popup color (inherits from current panel color if override is OFF) + const shouldApplyPopup = this.shouldApplyPopupColor(); + const detectedPopupColor = shouldApplyPopup ? this.getCurrentPanelColor() : null; + this.extension.debugLog(`Popup color: ${detectedPopupColor || "N/A"} (apply: ${shouldApplyPopup})`); + + // Log complete detection summary for comparison + this.extension.debugLog("=".repeat(60)); + this.extension.debugLog("✓ THEME DETECTION SUMMARY:"); + this.extension.debugLog(` Theme Name: ${this.currentTheme || "Unknown"}`); + this.extension.debugLog(` Theme Mode: ${isDarkMode ? "DARK" : "LIGHT"}`); + this.extension.debugLog( + ` Border Radius: ${detectedRadius}px ${shouldApplyRadius ? "→ WILL APPLY" : "(skip)"}` + ); + this.extension.debugLog( + ` Panel Color: ${detectedPanelColor} ${shouldApplyPanel ? "→ WILL APPLY" : "(skip)"}` + ); + this.extension.debugLog( + ` Popup Color: ${detectedPopupColor || "N/A"} ${shouldApplyPopup ? "→ WILL APPLY" : "(skip)"}` + ); + + if (shouldApplyAccent && accentVariants) { + this.extension.debugLog(` Accent Colors: → WILL APPLY`); + this.extension.debugLog(` • Base: ${accentVariants.accent}`); + this.extension.debugLog(` • Border: ${accentVariants.border}`); + this.extension.debugLog(` • Tint: ${accentVariants.tint}`); + this.extension.debugLog(` • Shadow: ${accentVariants.shadow}`); + } else { + this.extension.debugLog(` Accent Colors: (skip - auto-apply disabled)`); + } + this.extension.debugLog("=".repeat(60)); + + return { + borderRadius: { + detected: detectedRadius, + shouldApply: shouldApplyRadius, + }, + accentColor: { + detected: detectedAccent, + shouldApply: shouldApplyAccent, + variants: accentVariants, + }, + panelColor: { + detected: detectedPanelColor, + shouldApply: shouldApplyPanel, + }, + popupColor: { + detected: detectedPopupColor, + shouldApply: shouldApplyPopup, + }, + isDarkMode: isDarkMode, + }; + } + + /** + * Check if auto-apply border-radius is enabled + * @returns {boolean} True if border-radius should be applied from theme + */ + shouldApplyBorderRadius() { + return this.extension.settings.getValue("auto-detect-radius"); + } + + /** + * Check if auto-apply accent is enabled + * @returns {boolean} True if accent colors should be applied from theme + */ + shouldApplyAccent() { + return this.extension.settings.getValue("auto-apply-accent-on-theme-change"); + } + + /** + * Check if panel color should be applied from theme + * Panel color applies from theme ONLY if override is OFF + * @returns {boolean} True if panel should use theme color (not user override) + */ + shouldApplyPanelColor() { + return !this.extension.settings.getValue("override-panel-color"); + } + + /** + * Check if popup color should be applied + * Logic: + * - If popup override is ON → use popup color picker (don't apply from theme) + * - If popup override is OFF → inherit from CURRENT panel color + * @returns {boolean} True if popup should inherit from panel + */ + shouldApplyPopupColor() { + return !this.extension.settings.getValue("override-popup-color"); + } + + /** + * Get CURRENT active panel color as CSS string (BLACK BOX) + * Returns the effective panel color based on override switch state. + * + * LOGIC (Phase 2.5B+ simplified): + * - If override-panel-color ON: User manual mode → read from picker + * - If override-panel-color OFF: Auto mode → use stored base color (theme or accent shadow) + * + * This BLACK BOX approach eliminates cache invalidation issues and provides + * consistent behavior for popup color inheritance. + * + * @returns {string} CSS color string "rgb(r,g,b)" or "rgba(r,g,b,a)" + */ + getCurrentPanelColor() { + if (this.extension.overridePanelColor) { + // User manual override active - read from picker + const overrideColor = this.extension.settings.getValue("choose-override-panel-color"); + this.extension.debugLog(`Panel color: ${overrideColor} (from manual override)`); + return overrideColor; + } else { + // Auto mode - use stored base color (set in applyDetectedThemeData) + const baseColor = this.currentPanelBaseColor || this.detectPanelColorFromTheme(); + this.extension.debugLog(`Panel color: ${baseColor} (from auto-detected base)`); + return baseColor; + } + } + + /** + * Get effective popup/menu color based on override settings + * + * Encapsulates popup color inheritance logic - proper separation of concerns. + * Replaces deprecated CSSManager.getMenuColor() method. + * + * This implements the THREE-MODE LOGIC: + * 1. Popup override ON → use popup color picker + * 2. Panel override ON (popup OFF) → use panel color picker + * 3. Both OFF → use auto-detected theme color + * + * @returns {Object} RGB color object {r, g, b} + */ + getEffectivePopupColor() { + if (this.extension.overridePopupColor) { + // Mode 1: User explicitly overrode popup color → use popup color picker + const popupColorString = this.extension.settings.getValue("choose-override-popup-color"); + this.extension.debugLog("[ThemeDetector] Popup color: using override", popupColorString); + + // Use safe parser for user-provided color picker value + return this._safeParseColor( + popupColorString, + DEFAULT_COLORS.FALLBACK_DARK, + "popup color override" + ); + } else { + // Mode 2 & 3: Popup inherits from CURRENT panel color (theme OR panel override) + const currentPanelColor = this.getCurrentPanelColor(); // BLACK BOX pattern + this.extension.debugLog("[ThemeDetector] Popup color: inheriting from panel", currentPanelColor); + + // getCurrentPanelColor returns string → parse safely + return this._safeParseColor( + currentPanelColor, + DEFAULT_COLORS.FALLBACK_DARK, + "popup inherited panel color" + ); + } + } + + /** + * Detect panel color from current theme (original theme color) + * CRITICAL FIX (Phase 2.5B+): Read from GTK CSS files, NOT DOM! + * + * PROBLEM: Old implementation read Main.panel.actor color from DOM, + * which returns ALREADY APPLIED color from previous theme switch. + * This caused dark panel color to persist when switching dark→light. + * + * SOLUTION: Read @theme_bg_color from gtk.css files directly. + * Fallback to DOM detection only if CSS parsing fails. + * + * @returns {string} CSS color string "rgb(r,g,b)" + */ + detectPanelColorFromTheme() { + // Try reading from GTK CSS first (PURE theme color) + const cssColor = this._detectPanelColorFromGtkCss(); + + if (cssColor) { + return `rgb(${cssColor.r}, ${cssColor.g}, ${cssColor.b})`; + } + + // Fallback: Read from DOM (may have stale color from previous switch) + this.extension.debugLog(" ⚠ Falling back to DOM color detection (may be stale)"); + + const wasOverride = this.extension.overridePanelColor; + this.extension.overridePanelColor = false; + + this.invalidateCache(); // Force fresh DOM detection + this._cachedGtkCssColor = null; // CSS already searched above; preserve not-found result + const colorObj = this.getPanelBaseColor(); // Read DOM color + + this.extension.overridePanelColor = wasOverride; // Restore original setting + + return `rgb(${colorObj.r}, ${colorObj.g}, ${colorObj.b})`; + } +} + +module.exports = ThemeDetector; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/themeUtils.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/themeUtils.js new file mode 100644 index 00000000..9aa18a93 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/themeUtils.js @@ -0,0 +1,675 @@ +/** + * ThemeUtils - Color mathematics and theme analysis utilities + * + * Provides advanced color manipulation, WCAG contrast calculations, + * HSP perceived brightness detection, and theme color analysis. + * + * Adapted from GNOME Shell 43.9 ThemeUtils for Cinnamon compatibility + * + * @module themeUtils + */ + +const { THEME_UTILS } = require("./constants"); + +/** + * Theme utilities class with static methods for color operations + */ +class ThemeUtils { + // ===== COLOR MATHEMATICS ===== + + /** + * Calculate HSP (Highly Sensitive Poo) perceived brightness + * Uses human eye sensitivity weighting for accurate brightness perception + * + * @param {number|Array} r - Red value (0-255) or [r,g,b] array + * @param {number} g - Green value (0-255) + * @param {number} b - Blue value (0-255) + * @returns {number} HSP brightness value (0-255) + * + * @example + * ThemeUtils.getHSP(46, 52, 64); // Returns ~52.8 + * ThemeUtils.getHSP([255, 255, 255]); // Returns 255 + */ + static getHSP(r, g, b) { + if (Array.isArray(r)) { + [r, g, b] = r.map((c) => parseInt(c)); + } + // HSP equation for perceived brightness + // Red: 29.9%, Green: 58.7%, Blue: 11.4% (human eye sensitivity) + return Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); + } + + /** + * Determine if background is dark based on HSP threshold + * + * @param {number|Array} r - Red value (0-255) or [r,g,b] array + * @param {number} g - Green value (0-255) + * @param {number} b - Blue value (0-255) + * @returns {boolean} True if background is dark + * + * @example + * ThemeUtils.getBgDark(46, 52, 64); // Returns true (dark theme) + * ThemeUtils.getBgDark(245, 246, 247); // Returns false (light theme) + */ + static getBgDark(r, g, b) { + let hsp = this.getHSP(r, g, b); + return hsp <= THEME_UTILS.HSP_DARK_THRESHOLD; + } + + /** + * Mix two colors by a factor + * + * @param {number} startColor - Start color component (0-255) + * @param {number} endColor - End color component (0-255) + * @param {number} factor - Mix factor (0 to 1) + * @returns {number} Mixed color component (0-255) + * + * @example + * ThemeUtils.colorMix(0, 255, 0.5); // Returns 128 + * ThemeUtils.colorMix(100, 200, 0.25); // Returns 125 + */ + static colorMix(startColor, endColor, factor) { + let color = startColor + factor * (endColor - startColor); + return Math.max(0, Math.min(255, parseInt(color))); + } + + /** + * Create lighter/darker shade of color + * + * @param {Array} color - [r, g, b] array + * @param {number} factor - Shade factor (+ve = lighter, -ve = darker, range: -1 to 1) + * @returns {Array} Shaded color [r, g, b] + * + * @example + * ThemeUtils.colorShade([46, 52, 64], 0.3); // Lighten by 30% + * ThemeUtils.colorShade([200, 200, 200], -0.5); // Darken by 50% + */ + static colorShade(color, factor) { + const [r, g, b] = color.map((c) => parseInt(c)); + + if (factor > 0) { + // Lighten: mix with white + return [this.colorMix(r, 255, factor), this.colorMix(g, 255, factor), this.colorMix(b, 255, factor)]; + } else { + // Darken: reduce intensity + const darkFactor = 1 + factor; // Convert to 0-1 range + return [Math.round(r * darkFactor), Math.round(g * darkFactor), Math.round(b * darkFactor)]; + } + } + + /** + * Calculate WCAG 2.0 contrast ratio between two colors + * + * @param {Array} color1 - First color [r, g, b] + * @param {Array} color2 - Second color [r, g, b] + * @returns {number} Contrast ratio (1 to 21) + * + * @example + * ThemeUtils.contrastRatio([0, 0, 0], [255, 255, 255]); // Returns 21 (max contrast) + * ThemeUtils.contrastRatio([128, 128, 128], [130, 130, 130]); // Returns ~1.01 + */ + static contrastRatio(color1, color2) { + const relativeLuminance = (color) => { + const [r, g, b] = color.map((val) => { + val /= 255; + return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + + const luminance1 = relativeLuminance(color1); + const luminance2 = relativeLuminance(color2); + const lighter = Math.max(luminance1, luminance2); + const darker = Math.min(luminance1, luminance2); + + return (lighter + 0.05) / (darker + 0.05); + } + + /** + * Generate automatic foreground color based on background + * Returns white text for dark backgrounds, black text for light backgrounds + * + * @param {Array} bgColor - Background color [r, g, b] + * @param {number} alpha - Alpha channel (0-1, default: 1.0) + * @returns {Array} Foreground color [r, g, b, a] + * + * @example + * ThemeUtils.getAutoFgColor([46, 52, 64]); // Returns [250, 250, 250, 1.0] + * ThemeUtils.getAutoFgColor([245, 246, 247]); // Returns [5, 5, 5, 1.0] + */ + static getAutoFgColor(bgColor, alpha = 1.0) { + const [r, g, b] = bgColor.map((c) => parseInt(c)); + const isDark = this.getBgDark(r, g, b); + + if (isDark) { + return [250, 250, 250, alpha]; // Light text on dark bg + } else { + return [5, 5, 5, alpha]; // Dark text on light bg + } + } + + /** + * Generate automatic highlight/hover color for menu items + * Lightens dark backgrounds, darkens light backgrounds + * + * @param {Array} bgColor - Background color [r, g, b] + * @param {number} intensity - Intensity of highlight (0-1, default: from constants) + * @returns {Array} Highlight color [r, g, b] + * + * @example + * ThemeUtils.getAutoHighlightColor([46, 52, 64]); // Returns lighter shade + * ThemeUtils.getAutoHighlightColor([245, 246, 247], 0.2); // Returns darker shade + */ + static getAutoHighlightColor(bgColor, intensity = null) { + // Use constant default if not provided + if (intensity === null) { + intensity = THEME_UTILS.AUTO_HIGHLIGHT_INTENSITY; + } + + const [r, g, b] = bgColor.map((c) => parseInt(c)); + const isDark = this.getBgDark(r, g, b); + + if (isDark) { + // Lighten dark backgrounds + return this.colorShade([r, g, b], intensity); + } else { + // Darken light backgrounds + return this.colorShade([r, g, b], -intensity); + } + } + + // ===== COLOR FORMAT CONVERSIONS ===== + + /** + * Convert RGB to hex string + * + * @param {number|Array} r - Red (0-255) or [r,g,b] array + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {string} Hex color string (#RRGGBB) + * + * @example + * ThemeUtils.rgbToHex(46, 52, 64); // Returns "#2e3440" + * ThemeUtils.rgbToHex([255, 255, 255]); // Returns "#ffffff" + */ + static rgbToHex(r, g, b) { + if (Array.isArray(r)) { + [r, g, b] = r.map((c) => parseInt(c)); + } + return "#" + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1); + } + + /** + * Convert hex to RGB array + * + * @param {string} hex - Hex color string (#RRGGBB or #RGB) + * @returns {Array|null} [r, g, b] array or null if invalid + * + * @example + * ThemeUtils.hexToRgb("#2e3440"); // Returns [46, 52, 64] + * ThemeUtils.hexToRgb("#fff"); // Returns [255, 255, 255] + */ + static hexToRgb(hex) { + // Match 6-digit (#RRGGBB) or 3-digit (#RGB) hex color strings + const result6 = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result6) return [parseInt(result6[1], 16), parseInt(result6[2], 16), parseInt(result6[3], 16)]; + + const result3 = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(hex); + if (result3) + return [ + parseInt(result3[1] + result3[1], 16), + parseInt(result3[2] + result3[2], 16), + parseInt(result3[3] + result3[3], 16), + ]; + + return null; + } + + /** + * Parse CSS rgba string to array + * + * @param {string} rgbaStr - CSS rgba string + * @returns {Array|null} [r, g, b, a] array or null if invalid + * + * @example + * ThemeUtils.parseRgbaString("rgba(46, 52, 64, 0.8)"); // Returns [46, 52, 64, 0.8] + * ThemeUtils.parseRgbaString("rgb(255, 255, 255)"); // Returns [255, 255, 255, 1.0] + */ + static parseRgbaString(rgbaStr) { + const match = rgbaStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (!match) return null; + + return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), match[4] ? parseFloat(match[4]) : 1.0]; + } + + /** + * Create CSS rgba string with clamped RGB values + * + * @param {number|Array} r - Red (0-255) or [r,g,b] or [r,g,b,a] array + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @param {number} a - Alpha (0-1, default: 1.0) + * @returns {string} CSS rgba string + * + * @example + * ThemeUtils.rgbaToCss(46, 52, 64, 0.8); // Returns "rgba(46, 52, 64, 0.8)" + * ThemeUtils.rgbaToCss([255, 255, 255]); // Returns "rgba(255, 255, 255, 1)" + * ThemeUtils.rgbaToCss([200, 200, 200, 0.5]); // Returns "rgba(200, 200, 200, 0.5)" + */ + static rgbaToCss(r, g, b, a = 1.0) { + if (Array.isArray(r)) { + if (r.length === 4) { + [r, g, b, a] = r; + } else { + [r, g, b] = r; + } + } + // Clamp RGB values to valid 0-255 range + r = Math.max(0, Math.min(255, Math.round(r))); + g = Math.max(0, Math.min(255, Math.round(g))); + b = Math.max(0, Math.min(255, Math.round(b))); + // Clamp alpha to 0-1 range + a = Math.max(0, Math.min(1, a)); + + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + + /** + * Unified color parser - handles hex, rgb(), rgba(), and array formats + * + * @param {string|Array} input - Color in any supported format + * @returns {Object|null} {r, g, b, a, format} or null if invalid + * + * @example + * ThemeUtils.parseColor("#2e3440"); // {r:46, g:52, b:64, a:1.0, format:'hex'} + * ThemeUtils.parseColor("rgba(46, 52, 64, 0.8)"); // {r:46, g:52, b:64, a:0.8, format:'css'} + * ThemeUtils.parseColor([46, 52, 64]); // {r:46, g:52, b:64, a:1.0, format:'array'} + */ + static parseColor(input) { + // Handle null/undefined + if (!input) return null; + + // Array input [r, g, b] or [r, g, b, a] + if (Array.isArray(input)) { + if (input.length < 3) return null; + return { + r: parseInt(input[0]), + g: parseInt(input[1]), + b: parseInt(input[2]), + a: input[3] !== undefined ? parseFloat(input[3]) : 1.0, + format: "array", + }; + } + + // String input + if (typeof input !== "string") return null; + + // Hex format #RRGGBB or #RGB + if (input.startsWith("#")) { + const rgb = this.hexToRgb(input); + return rgb + ? { + r: rgb[0], + g: rgb[1], + b: rgb[2], + a: 1.0, + format: "hex", + } + : null; + } + + // RGBA/RGB format + const rgbaMatch = input.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/); + if (rgbaMatch) { + return { + r: parseInt(rgbaMatch[1]), + g: parseInt(rgbaMatch[2]), + b: parseInt(rgbaMatch[3]), + a: rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1.0, + format: "css", + }; + } + + return null; + } + + /** + * Convert color to requested output format + * + * @param {string|Array} input - Color in any format + * @param {string} outputFormat - 'hex', 'css', 'array', 'object' + * @param {number} alphaOverride - Optional alpha override (0.0-1.0) + * @returns {string|Array|Object|null} Color in requested format + * + * @example + * ThemeUtils.convertColor("#2e3440", "css"); // "rgba(46, 52, 64, 1)" + * ThemeUtils.convertColor([46, 52, 64], "hex"); // "#2e3440" + * ThemeUtils.convertColor("rgba(46, 52, 64, 0.8)", "array"); // [46, 52, 64, 0.8] + */ + static convertColor(input, outputFormat = "css", alphaOverride = null) { + const parsed = this.parseColor(input); + if (!parsed) return null; + + const finalAlpha = alphaOverride !== null ? alphaOverride : parsed.a; + + switch (outputFormat) { + case "hex": + // Hex doesn't support alpha + return this.rgbToHex(parsed.r, parsed.g, parsed.b); + + case "css": + return this.rgbaToCss(parsed.r, parsed.g, parsed.b, finalAlpha); + + case "array": + return [parsed.r, parsed.g, parsed.b, finalAlpha]; + + case "object": + return { r: parsed.r, g: parsed.g, b: parsed.b, a: finalAlpha }; + + default: + return null; + } + } + + /** + * Validate if color string/array is valid + * + * @param {string|Array} color - Color to validate + * @returns {boolean} True if valid color format + * + * @example + * ThemeUtils.isValidColor("#2e3440"); // true + * ThemeUtils.isValidColor("rgba(46, 52, 64, 0.8)"); // true + * ThemeUtils.isValidColor("invalid"); // false + */ + static isValidColor(color) { + return this.parseColor(color) !== null; + } + + // ===== VALIDATION UTILITIES ===== + + /** + * Validate color contrast meets WCAG guidelines + * + * @param {Array} fgColor - Foreground color [r, g, b] + * @param {Array} bgColor - Background color [r, g, b] + * @param {string} level - WCAG level ('AA', 'AA_LARGE', 'AAA', 'AAA_LARGE') + * @returns {boolean} True if contrast meets requirement + * + * @example + * ThemeUtils.validateContrast([0, 0, 0], [255, 255, 255], 'AA'); // true (21:1) + * ThemeUtils.validateContrast([128, 128, 128], [130, 130, 130], 'AA'); // false (~1:1) + */ + static validateContrast(fgColor, bgColor, level = "AA") { + const ratio = this.contrastRatio(fgColor, bgColor); + const minRatio = THEME_UTILS.MIN_CONTRAST_RATIO[level] || THEME_UTILS.MIN_CONTRAST_RATIO.AA; + return ratio >= minRatio; + } + + /** + * Ensure minimum contrast by adjusting foreground color + * Iteratively lightens/darkens foreground until minimum contrast is met + * + * @param {Array} fgColor - Foreground color [r, g, b] + * @param {Array} bgColor - Background color [r, g, b] + * @param {number} minRatio - Minimum contrast ratio (default: WCAG AA) + * @returns {Array} Adjusted foreground color [r, g, b] + * + * @example + * ThemeUtils.ensureContrast([100, 100, 100], [120, 120, 120], 4.5); // Returns adjusted color + */ + static ensureContrast(fgColor, bgColor, minRatio = null) { + // Use constant default if not provided + if (minRatio === null) { + minRatio = THEME_UTILS.MIN_CONTRAST_RATIO.AA; + } + + let adjustedFg = [...fgColor]; + let ratio = this.contrastRatio(adjustedFg, bgColor); + + if (ratio >= minRatio) return adjustedFg; + + const isDarkBg = this.getBgDark(...bgColor); + const direction = isDarkBg ? 1 : -1; // Lighten on dark, darken on light + + for ( + let adjustment = THEME_UTILS.CONTRAST_ADJUSTMENT_STEP; + adjustment <= 1; + adjustment += THEME_UTILS.CONTRAST_ADJUSTMENT_STEP + ) { + adjustedFg = this.colorShade(fgColor, direction * adjustment); + ratio = this.contrastRatio(adjustedFg, bgColor); + + if (ratio >= minRatio) return adjustedFg; + } + + // Fallback to high contrast + return isDarkBg ? [255, 255, 255] : [0, 0, 0]; + } + + /** + * Generate complementary color (opposite on color wheel) + * + * @param {Array|string} color - Color [r, g, b] or hex string + * @returns {Array} Complementary color [r, g, b] + * + * @example + * ThemeUtils.getComplementaryColor([46, 52, 64]); // Returns [209, 203, 191] + * ThemeUtils.getComplementaryColor("#2e3440"); // Returns [209, 203, 191] + */ + static getComplementaryColor(color) { + const [r, g, b] = Array.isArray(color) ? color : this.hexToRgb(color); + return [255 - r, 255 - g, 255 - b]; + } + + // ===== HSL CONVERSIONS ===== + + /** + * Convert RGB to HSL color space + * + * @param {number|Array} r - Red (0-255) or [r,g,b] array + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {Array} [h, s, l] where h is 0-360, s and l are 0-100 + * + * @example + * ThemeUtils.rgbToHsl(46, 52, 64); // Returns [220, 16.4, 21.6] + * ThemeUtils.rgbToHsl([255, 0, 0]); // Returns [0, 100, 50] + */ + static rgbToHsl(r, g, b) { + if (Array.isArray(r)) { + [r, g, b] = r; + } + + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, + s, + l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; + } + } + + return [h * 360, s * 100, l * 100]; + } + + /** + * Convert HSL to RGB color space + * + * @param {number|Array} h - Hue (0-360) or [h,s,l] array + * @param {number} s - Saturation (0-100) + * @param {number} l - Lightness (0-100) + * @returns {Array} [r, g, b] where each is 0-255 + * + * @example + * ThemeUtils.hslToRgb(220, 16.4, 21.6); // Returns [46, 52, 64] + * ThemeUtils.hslToRgb([0, 100, 50]); // Returns [255, 0, 0] + */ + static hslToRgb(h, s, l) { + if (Array.isArray(h)) { + [h, s, l] = h; + } + + h /= 360; + s /= 100; + l /= 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + } + + /** + * Validate if color is suitable for accent color usage + * Rejects grey/white/black colors, prefers saturated colors + * + * @param {number|Array} r - Red (0-255) or [r,g,b] array + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {Object} {isValid: boolean, reason: string} + * + * @example + * ThemeUtils.isValidAccent(255, 100, 50); // {isValid: true, reason: "Valid accent..."} + * ThemeUtils.isValidAccent(200, 200, 200); // {isValid: false, reason: "Too desaturated..."} + */ + static isValidAccent(r, g, b) { + if (Array.isArray(r)) { + [r, g, b] = r; + } + + // Convert to HSL for proper color analysis + const hsl = this.rgbToHsl(r, g, b); + const h = hsl[0]; // Hue (0-360) + const s = hsl[1]; // Saturation (0-100) + const l = hsl[2]; // Lightness (0-100) + + // Rule 1: Reject colors with very low saturation (grey/white detection) + if (s < 15) { + return { + isValid: false, + reason: `Too desaturated (S:${s.toFixed(1)}% < 15%) - likely grey/white`, + }; + } + + // Rule 2: Reject very dark colors (black detection) + if (l < 25) { + return { + isValid: false, + reason: `Too dark (L:${l.toFixed(1)}% < 25%) - likely black`, + }; + } + + // Rule 3: Reject very light colors (white detection) + if (l > 90) { + return { + isValid: false, + reason: `Too light (L:${l.toFixed(1)}% > 90%) - likely white`, + }; + } + + // Valid accent color + return { + isValid: true, + reason: `Valid accent (H:${h.toFixed(0)}° S:${s.toFixed(1)}% L:${l.toFixed(1)}%)`, + }; + } + + /** + * Enhance pastel colors for dark themes + * Increases saturation and reduces lightness to make colors more vibrant + * + * @param {Array} rgb - [r, g, b] array + * @param {number} saturationBoost - Saturation increase (0-1, default: 0.3) + * @param {number} lightnessReduction - Lightness reduction (0-1, default: 0.25) + * @returns {Array} Enhanced [r, g, b] array + * + * @example + * ThemeUtils.enhancePastelColor([200, 180, 220]); // Returns more vibrant version + * ThemeUtils.enhancePastelColor([220, 200, 230], 0.4, 0.3); // Stronger enhancement + */ + static enhancePastelColor(rgb, saturationBoost = 0.3, lightnessReduction = 0.25) { + const [r, g, b] = rgb; + const [h, s, l] = this.rgbToHsl(r, g, b); + + // Only enhance if lightness is high (pastel) and saturation is moderate + if (l > 65 && s > 20) { + // Boost saturation (but cap at 100) + const newS = Math.min(100, s + saturationBoost * 100); + + // Reduce lightness to make color more vivid + const newL = Math.max(35, l - lightnessReduction * 100); + + return this.hslToRgb(h, newS, newL); + } + + // Return original if not pastel + return [r, g, b]; + } + + /** + * Smart color palette generator + * Generates lighter and darker shades of a base color + * + * @param {Array|string} baseColor - Base color [r, g, b] or hex string + * @param {number} count - Number of colors to generate (default: 5) + * @returns {Array} Array of color arrays [[r,g,b], [r,g,b], ...] + * + * @example + * ThemeUtils.generateColorPalette([46, 52, 64], 5); + * // Returns 5 shades: darkest → base → lightest + */ + static generateColorPalette(baseColor, count = 5) { + const [r, g, b] = Array.isArray(baseColor) ? baseColor : this.hexToRgb(baseColor); + const palette = []; + + for (let i = 0; i < count; i++) { + const factor = (i - Math.floor(count / 2)) * 0.2; + palette.push(this.colorShade([r, g, b], factor)); + } + + return palette; + } +} + +// Export as Cinnamon module +module.exports = { ThemeUtils }; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/tooltipStyler.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/tooltipStyler.js new file mode 100644 index 00000000..4c97ede2 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/tooltipStyler.js @@ -0,0 +1,372 @@ +const St = imports.gi.St; +const Main = imports.ui.main; +const Tooltips = imports.ui.tooltips; +const StylerBase = require("./stylerBase"); +const { TIMING, TRAVERSAL, CSS_CLASSES, SIGNALS } = require("./constants"); + +/** + * Tooltip Styler handles tooltip transparency and blur effects + * Uses CSS-based monitoring to intercept tooltip creation and display + */ +class TooltipStyler extends StylerBase { + /** + * Initialize Tooltip Styler + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + super(extension, "TooltipStyler"); + this.activeTooltips = new Map(); + this.originalTooltipShow = null; + this.originalPanelItemTooltipShow = null; + } + + /** + * Enable tooltip styling + */ + enable() { + super.enable(); + this.setupTooltipMonkeyPatch(); // Add monkey patch for PanelItemTooltip + this.setupGeneralTooltipMonkeyPatch(); // Add monkey patch for general Tooltip + this.setupTooltipMonitoring(); // Add monitoring for existing and future tooltips + this.debugLog("Tooltip styler enabled"); + } + + /** + * Disable tooltip styling + */ + disable() { + this.debugLog("TooltipStyler: Starting disable cleanup"); + this.restoreTooltipMonkeyPatch(); // Restore PanelItemTooltip monkey patch + this.restoreGeneralTooltipMonkeyPatch(); // Restore general Tooltip monkey patch + + // Hide all active tooltips before cleanup to ensure proper reset + this.activeTooltips.forEach((originalData, tooltip) => { + try { + if (tooltip && tooltip.hide) { + tooltip.hide(); + } + } catch (e) { + this.debugLog("Error hiding tooltip during disable:", e); + } + }); + + this.cleanupActiveTooltips(); + + this.debugLog("TooltipStyler: Disable cleanup completed"); + super.disable(); // Automatic signal cleanup via GlobalSignalsHandler + } + + /** + * Setup monkey patching for general Tooltip handling + */ + setupGeneralTooltipMonkeyPatch() { + try { + // Store reference to original method + this.originalTooltipShow = Tooltips.Tooltip.prototype.show; + let self = this; + + // Store patched function reference to enable idempotent restore + this._patchedTooltipShow = function () { + // Call the original method first + self.originalTooltipShow.call(this); + if (this._tooltip && this._tooltip.visible) { + self.styleTooltip(this); + } + }; + Tooltips.Tooltip.prototype.show = this._patchedTooltipShow; + + this.debugLog("General Tooltip monkey patch setup successfully"); + } catch (e) { + this.debugLog("Error setting up general Tooltip monkey patch:", e); + } + } + + /** + * Setup monkey patching for PanelItemTooltip handling (adapted from BlurTooltips example) + */ + setupTooltipMonkeyPatch() { + try { + // Store reference to original method + this.originalPanelItemTooltipShow = Tooltips.PanelItemTooltip.prototype.show; + let self = this; + + // Store patched function reference to enable idempotent restore + this._patchedPanelItemTooltipShow = function () { + // Call the original method first + self.originalPanelItemTooltipShow.call(this); + if (this._tooltip && this._tooltip.visible) { + self.styleTooltip(this); + } + }; + Tooltips.PanelItemTooltip.prototype.show = this._patchedPanelItemTooltipShow; + + this.debugLog("PanelItemTooltip monkey patch setup successfully"); + } catch (e) { + this.debugLog("Error setting up PanelItemTooltip monkey patch:", e); + } + } + + /** + * Check if a tooltip should be styled + * @param {Object} tooltip - The tooltip to check + * @returns {boolean} True if tooltip should be styled + */ + shouldStyleTooltip(tooltip) { + // Style all tooltips for now - can be extended with filtering logic + return tooltip && tooltip._tooltip; + } + + /** + * Apply styles to tooltip + * @param {Object} tooltip - The tooltip to style + */ + styleTooltip(tooltip) { + if (!tooltip || !tooltip._tooltip) { + return; + } + + try { + if (!this.activeTooltips.has(tooltip)) { + let originalData = { + style: tooltip._tooltip.get_style(), + styleClasses: tooltip._tooltip.get_style_class_name(), + }; + + this.activeTooltips.set(tooltip, originalData); + + // Connect to hide signals for cleanup + this.setupTooltipCloseHandlers(tooltip); + } + + let tooltipColor = this.extension.themeDetector.getEffectivePopupColor(); + + this.extension.cssManager.updateAllVariables(); + + // Build configuration object for template generation + const config = { + backgroundColor: `rgba(${tooltipColor.r}, ${tooltipColor.g}, ${tooltipColor.b}, ${this.extension.menuOpacity})`, + opacity: this.extension.blurOpacity, + borderRadius: this.getAdjustedBorderRadius("tooltip"), + blurRadius: this.getAdjustedBlurRadius("tooltip"), + blurSaturate: this.extension.blurSaturate, + blurContrast: this.extension.blurContrast, + blurBrightness: this.extension.blurBrightness, + borderColor: this.extension.blurBorderColor, + borderWidth: this.extension.blurBorderWidth, + transition: this.extension.blurTransition, + }; + + // Generate CSS via template manager + const tooltipCSS = this.extension.blurTemplateManager.generateTooltipCSS(config); + tooltip._tooltip.set_style(tooltipCSS); + + this.debugLog("Applying tooltip styles via template generation"); + } catch (e) { + this.debugLog("Error styling tooltip:", e); + } + } + + /** + * Setup close handlers for proper tooltip cleanup + * @param {Object} tooltip - The tooltip + */ + setupTooltipCloseHandlers(tooltip) { + if (!tooltip._transparencyHideConnection) { + // Override hide method to cleanup (adapted from BlurTooltips example) + let originalHide = tooltip.hide.bind(tooltip); + tooltip.hide = () => { + this.cleanupTooltip(tooltip); + originalHide(); + }; + tooltip._transparencyHideConnection = true; + } + } + + /** + * Clean up styling for a tooltip + * @param {Object} tooltip - The tooltip to clean up + */ + cleanupTooltip(tooltip) { + try { + let originalData = this.activeTooltips.get(tooltip); + if (originalData) { + this.restoreTooltipStyle(tooltip, originalData); + this.activeTooltips.delete(tooltip); + } + } catch (e) { + this.debugLog("Error cleaning up tooltip:", e); + } + } + + /** + * Restore original tooltip styling + * @param {Object} tooltip - The tooltip to restore + * @param {Object} originalData - The original styling data + */ + restoreTooltipStyle(tooltip, originalData) { + try { + if (tooltip._tooltip) { + tooltip._tooltip.set_style(originalData.style || ""); + if (originalData.styleClasses) { + tooltip._tooltip.set_style_class_name(originalData.styleClasses); + } + + // Remove our style classes + tooltip._tooltip.remove_style_class_name(CSS_CLASSES.TOOLTIP_BLUR); + tooltip._tooltip.remove_style_class_name(CSS_CLASSES.FALLBACK_BLUR); + tooltip._tooltip.remove_style_class_name(CSS_CLASSES.CUSTOM_PROFILE); + } + } catch (e) { + this.debugLog("Error restoring tooltip style:", e); + } + } + + /** + * Restore original general Tooltip functionality + */ + restoreGeneralTooltipMonkeyPatch() { + try { + if (this.originalTooltipShow) { + if (Tooltips.Tooltip.prototype.show === this._patchedTooltipShow) { + Tooltips.Tooltip.prototype.show = this.originalTooltipShow; + } + this.originalTooltipShow = null; + this._patchedTooltipShow = null; + this.debugLog("General Tooltip monkey patch restored"); + } + } catch (e) { + this.debugLog("Error restoring general Tooltip monkey patch:", e); + } + } + + /** + * Restore original PanelItemTooltip functionality + */ + restoreTooltipMonkeyPatch() { + try { + if (this.originalPanelItemTooltipShow) { + if (Tooltips.PanelItemTooltip.prototype.show === this._patchedPanelItemTooltipShow) { + Tooltips.PanelItemTooltip.prototype.show = this.originalPanelItemTooltipShow; + } + this.originalPanelItemTooltipShow = null; + this._patchedPanelItemTooltipShow = null; + this.debugLog("PanelItemTooltip monkey patch restored"); + } + } catch (e) { + this.debugLog("Error restoring PanelItemTooltip monkey patch:", e); + } + } + + /** + * Clean up all active tooltips + */ + cleanupActiveTooltips() { + this.activeTooltips.forEach((originalData, tooltip) => { + this.restoreTooltipStyle(tooltip, originalData); + }); + this.activeTooltips.clear(); + } + + /** + * Refresh all currently active tooltips + */ + refreshActiveTooltips() { + try { + this.debugLog(`Refreshing ${this.activeTooltips.size} active tooltips`); + + this.activeTooltips.forEach((originalData, tooltip) => { + if (tooltip && tooltip._tooltip && tooltip.visible) { + this.styleTooltip(tooltip); + } + }); + } catch (e) { + this.debugLog("Error refreshing active tooltips:", e); + } + } + + /** + * Setup monitoring for existing and future tooltips (adapted from osdStyler) + */ + setupTooltipMonitoring() { + this.debugLog("Setting up tooltip monitoring"); + + // Monitor global stage for new tooltip elements - use GlobalSignalsHandler + if (global.stage) { + this.addConnection(global.stage, SIGNALS.ACTOR_ADDED, (stage, actor) => { + if (this.isTooltipElement(actor) && !this.activeTooltips.has(actor)) { + imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + this.styleTooltip(actor); + return false; + }); + } + }); + } + + // Initial search for existing tooltips + this.findAndStyleExistingTooltips(); + } + + /** + * Check if actor is a tooltip element + * @param {Clutter.Actor} actor - Actor to check + * @returns {boolean} True if tooltip element + */ + isTooltipByCSS(actor) { + return actor && actor.has_style_class_name && actor.has_style_class_name(CSS_CLASSES.TOOLTIP); + } + + /** + * Find and style existing tooltips that may already be displayed + */ + findAndStyleExistingTooltips() { + try { + let stage = global.stage || Main.uiGroup; + this.searchForExistingTooltips(stage, 0); + } catch (e) { + this.debugLog("Error finding existing tooltips:", e); + } + } + + /** + * Recursively search for existing tooltip actors + * @param {Clutter.Actor} actor - Actor to search + * @param {number} depth - Current search depth + */ + searchForExistingTooltips(actor, depth = 0) { + if (depth > TRAVERSAL.MAX_DEPTH_PANEL) return; + if (actor && actor instanceof Tooltips.Tooltip && actor._tooltip && actor.visible) { + this.styleTooltip(actor); + } + if (actor && actor.get_children) { + actor.get_children().forEach((child) => this.searchForExistingTooltips(child, depth + 1)); + } + } + + /** + * Recursively search for existing tooltip actors and force reset + * @param {Clutter.Actor} actor - Actor to search + * @param {number} depth - Current search depth + */ + findPanelItemTooltipsInStage(actor, depth = 0) { + if (depth > TRAVERSAL.MAX_DEPTH_PANEL) return; + if (actor && actor instanceof Tooltips.Tooltip && actor._tooltip) { + try { + // Force hide and show to reset tooltip state without styling + if (actor.visible) { + actor.hide(); + imports.mainloop.timeout_add(TIMING.DEBOUNCE_SHORT, () => { + actor.show(); + return false; + }); + } + } catch (e) { + this.debugLog("Error resetting tooltip:", e); + } + } + if (actor && actor.get_children) { + actor.get_children().forEach((child) => this.forceTooltipReset(child, depth + 1)); + } + } +} + +module.exports = TooltipStyler; diff --git a/csspanels@dr.drummie/files/csspanels@dr.drummie/wallpaperMonitor.js b/csspanels@dr.drummie/files/csspanels@dr.drummie/wallpaperMonitor.js new file mode 100644 index 00000000..1cb1bb75 --- /dev/null +++ b/csspanels@dr.drummie/files/csspanels@dr.drummie/wallpaperMonitor.js @@ -0,0 +1,642 @@ +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const { TIMING, SETTINGS_KEYS, WALLPAPER_COLORS, DEFAULT_COLORS } = require("./constants"); +const { ThemeUtils } = require("./themeUtils"); +const { GlobalSignalsHandler } = require("./signalHandler"); + +/** + * WallpaperMonitor - Foundation for Phase 3 ColorPalette integration + * + * Detects wallpaper changes and prepares for color extraction. + * Phase 2.5C: Detection, logging, and infrastructure only + * Phase 3: Actual ColorPalette extraction implementation + * + * Features: + * - Wallpaper change detection via org.cinnamon.desktop.background + * - Debouncing to prevent rapid-fire triggers + * - File hash checking to avoid duplicate processing + * - Dark/Light mode detection for context-aware extraction + * - Manual extraction trigger support + */ +class WallpaperMonitor { + /** + * Initialize wallpaper monitor + * @param {Object} extension - Reference to main extension instance + */ + constructor(extension) { + this.extension = extension; + this._enabled = false; + + // State tracking + this._wallpaperPath = null; + this._lastHash = null; + this._extractionInProgress = false; + this._colorPalette = null; // Lazy-initialized persistent ColorPalette instance + + // Signal management + this._signalsHandler = new GlobalSignalsHandler(); + this._backgroundSettings = null; + + // Debouncing + this._debounceTimeout = null; + + this.debugLog("WallpaperMonitor initialized"); + } + + /** + * Enable wallpaper monitoring + */ + enable() { + if (this._enabled) { + this.debugLog("Already enabled"); + return; + } + + try { + // Initialize background settings + this._backgroundSettings = new Gio.Settings({ + schema_id: "org.cinnamon.desktop.background", + }); + + // Connect to wallpaper change signal + this._signalsHandler.add([ + this._backgroundSettings, + "changed::picture-uri", + this._onWallpaperChanged.bind(this), + ]); + + this._enabled = true; + + // Log initial wallpaper + const initialPath = this._getCurrentWallpaperPath(); + this.debugLog(`Wallpaper monitoring enabled - Current: ${initialPath || "none"}`); + + // Initial hash calculation + if (initialPath) { + this._wallpaperPath = initialPath; + this._calculateHash(initialPath).then(hash => { + this._lastHash = hash; + this.debugLog(`Initial wallpaper hash: ${this._lastHash}`); + }).catch(e => this.debugLog(`Error calculating initial wallpaper hash: ${e.message}`)); + } + } catch (e) { + this.debugLog(`Error enabling wallpaper monitor: ${e.message}`); + global.logError(`[CSSPanels] [WallpaperMonitor] Error: ${e.message}\n${e.stack}`); + this._enabled = false; + } + } + /** + * Disable wallpaper monitoring + */ + disable() { + if (!this._enabled) { + return; + } + + try { + // Clear debounce timeout + if (this._debounceTimeout) { + GLib.source_remove(this._debounceTimeout); + this._debounceTimeout = null; + } + + // Disconnect all signals + this._signalsHandler.destroy(); + + // Clear background settings reference + this._backgroundSettings = null; + + // Reset state + this._wallpaperPath = null; + this._lastHash = null; + this._extractionInProgress = false; + this._colorPalette = null; + this._enabled = false; + + this.debugLog("Wallpaper monitoring disabled"); + } catch (e) { + this.debugLog(`Error disabling wallpaper monitor: ${e.message}`); + } + } + + /** + * Handle wallpaper change signal + * @private + */ + _onWallpaperChanged() { + // Clear existing debounce timeout + if (this._debounceTimeout) { + GLib.source_remove(this._debounceTimeout); + } + + // Debounce wallpaper changes (prevent rapid-fire triggers) + this._debounceTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, TIMING.WALLPAPER_DEBOUNCE || 1000, () => { + this._processWallpaperChange().catch(e => this.debugLog(`Error processing wallpaper change: ${e.message}`)); + this._debounceTimeout = null; + return GLib.SOURCE_REMOVE; + }); + + this.debugLog("Wallpaper change detected (debouncing...)"); + } + + /** + * Process wallpaper change after debounce + * @private + */ + async _processWallpaperChange() { + try { + const newPath = this._getCurrentWallpaperPath(); + + if (!newPath) { + this.debugLog("⚠️ No wallpaper path detected"); + return; + } + + // Calculate hash for new wallpaper + const newHash = await this._calculateHash(newPath); + + // Check if wallpaper actually changed + // Guard: skip only when both hashes are non-null AND path matches — + // if hash is null (file unreadable) we should NOT silently skip retries. + if (newHash !== null && newHash === this._lastHash && newPath === this._wallpaperPath) { + this.debugLog("⏭️ Same wallpaper (hash match), skipping extraction"); + return; + } + + // Update state + const oldPath = this._wallpaperPath; + + // Log change + this.debugLog(`🖼️ Wallpaper changed:`); + this.debugLog(` Old: ${oldPath || "none"}`); + this.debugLog(` New: ${newPath}`); + this.debugLog(` Hash: ${newHash}`); + + // Detect dark/light mode + const isDarkMode = this._detectDarkMode(); + this.debugLog(`🌓 Current mode: ${isDarkMode ? "DARK" : "LIGHT"}`); + + // Trigger extraction (if enabled) + if (this.extension.enableWallpaperDetection) { + this._triggerExtraction(newPath, isDarkMode, this.extension.fullAutoMode); + } else { + this.debugLog("Wallpaper detection disabled, skipping extraction"); + } + } catch (e) { + this.debugLog(`Error processing wallpaper change: ${e.message}`); + } + } + + /** + * Trigger color extraction from wallpaper image and apply to settings + * Lazy-loads ColorPalette module to avoid blocking initialization. + * Extracts panel color, accent variants, and secondary popup color. + * + * @param {string} wallpaperPath - Path to wallpaper file (plain path, not URI) + * @param {boolean} isDarkMode - Whether dark mode is active + * @param {boolean} fullAuto - When true, also updates blur/accent color settings + * @private + */ + _triggerExtraction(wallpaperPath, isDarkMode, fullAuto = false) { + if (this._extractionInProgress) { + this.debugLog("Extraction already in progress, skipping"); + return; + } + + this._extractionInProgress = true; + this.debugLog(`Color extraction started - Path: ${wallpaperPath}, Mode: ${isDarkMode ? "DARK" : "LIGHT"}`); + + try { + const { ColorPalette } = require("./colorPalette"); + if (!this._colorPalette) { + this._colorPalette = new ColorPalette(this.extension); + } + const cp = this._colorPalette; + + // Load pixbuf once — both tone extraction and palette analysis share the same image data + let sharedPixbuf; + try { + // GdkPixbuf.new_from_file_at_scale is synchronous but acceptable here: + // - only invoked on user-triggered wallpaper extraction, not in the event loop + // - GdkPixbuf has no stable async API in GJS/Cinnamon context + sharedPixbuf = imports.gi.GdkPixbuf.Pixbuf.new_from_file_at_scale( + wallpaperPath, WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION, WALLPAPER_COLORS.COLOR_ANALYSIS_MAX_DIMENSION, true + ); + } catch (loadErr) { + this.debugLog(`Failed to load pixbuf: ${loadErr.message}`); + sharedPixbuf = null; + } + this.debugLog(`Pixbuf loaded once for dual-analysis (path: ${wallpaperPath})`); + + const strategy = this.extension.wallpaperColorStrategy || 'default'; + + if (strategy === 'contrast') { + // === CONTRAST STRATEGY: polar tones === + + // STEP 1: Panel color — polar extreme (darkest/lightest pixels) + const dominantRgb = sharedPixbuf + ? cp.extractPolarTone(sharedPixbuf, isDarkMode) + : cp.extractPolarToneFromPath(wallpaperPath, isDarkMode); + const shadeFactor = isDarkMode + ? WALLPAPER_COLORS.CONTRAST_SHADE_DARK + : WALLPAPER_COLORS.CONTRAST_SHADE_LIGHT; + const panelRgb = ThemeUtils.colorShade(dominantRgb, shadeFactor); + const panelOpacity = this._getPanelOpacity(); + const panelCss = ThemeUtils.rgbaToCss(panelRgb[0], panelRgb[1], panelRgb[2], panelOpacity); + this.extension.settings.setValue('choose-override-panel-color', panelCss); + this.debugLog(`Panel color set (contrast): ${panelCss}`); + + // STEP 2: Palette from opposite end for accent/popup + // Invert preferLight: dark mode → prefer light palette for accent contrast + const preferLight = isDarkMode; + const palette = sharedPixbuf + ? cp.extractFromPixbuf(sharedPixbuf, 8, preferLight) + : cp.extractColorsFromImage(wallpaperPath, 8, preferLight); + this.debugLog(`Palette extracted (contrast): ${palette ? palette.length : 0} colors`); + + // Dispose shared pixbuf + if (sharedPixbuf) { + try { sharedPixbuf.run_dispose(); } catch (e) { /* ignore */ } + } + + // STEP 3: Accent system (identical to default flow) + const accentRgbArr = palette && palette.length > 0 + ? cp.getBestAccentColor(palette) + : [DEFAULT_COLORS.DEFAULT_ACCENT.r, DEFAULT_COLORS.DEFAULT_ACCENT.g, DEFAULT_COLORS.DEFAULT_ACCENT.b]; + + const accentColor = { r: accentRgbArr[0], g: accentRgbArr[1], b: accentRgbArr[2] }; + let accentForSystem = accentColor; + + if (this.extension.themeDetector && this.extension.themeDetector.validateAccentColor) { + const validation = this.extension.themeDetector.validateAccentColor(accentColor); + if (!validation.isValid) { + const isTooLight = validation.reason && validation.reason.includes('Too light'); + const isDesaturated = validation.reason && validation.reason.includes('Too desaturated'); + if (!isTooLight && !isDesaturated) { + const hsl = ThemeUtils.rgbToHsl(accentColor.r, accentColor.g, accentColor.b); + const boosted = ThemeUtils.hslToRgb(hsl[0], hsl[1], WALLPAPER_COLORS.ACCENT_BOOST_TARGET_LIGHTNESS); + const revalidation = this.extension.themeDetector.validateAccentColor( + { r: boosted[0], g: boosted[1], b: boosted[2] } + ); + if (revalidation.isValid) { + this.debugLog(`Accent brightened (contrast) rgb(${accentColor.r},${accentColor.g},${accentColor.b}) → rgb(${boosted[0]},${boosted[1]},${boosted[2]})`); + accentForSystem = { r: boosted[0], g: boosted[1], b: boosted[2] }; + } else { + this.debugLog(`Accent still invalid after boost (contrast) (${revalidation.reason}), using default`); + accentForSystem = DEFAULT_COLORS.DEFAULT_ACCENT; + } + } else { + this.debugLog(`Accent invalid (contrast) (${validation.reason}), using default`); + accentForSystem = DEFAULT_COLORS.DEFAULT_ACCENT; + } + } + } + + if (this.extension.themeDetector && this.extension.themeDetector.generateAccentSystem) { + const accentVariants = this.extension.themeDetector.generateAccentSystem(accentForSystem, isDarkMode); + if (accentVariants && fullAuto) { + this.extension.settings.setValue('blur-border-color', accentVariants.border); + this.extension.settings.setValue('blur-background', accentVariants.tint); + this.extension.settings.setValue('accent-shadow-color', accentVariants.shadow); + this.debugLog(`Accent system applied (contrast, full-auto): border=${accentVariants.border}`); + } + } + + // STEP 4: Popup color matches panel — same polar tone, menu opacity + const menuOpacity = this._getMenuOpacity(); + const secondaryCss = ThemeUtils.rgbaToCss(panelRgb[0], panelRgb[1], panelRgb[2], menuOpacity); + this.extension.settings.setValue('choose-override-popup-color', secondaryCss); + this.debugLog(`Popup color set (contrast, panel-match): ${secondaryCss}`); + + } else { + // === DEFAULT STRATEGY: existing weighted average flow === + + // === STEP 1: Panel color — weighted average of ALL pixels (no saturation filter) === + const dominantRgb = sharedPixbuf + ? cp.analyzePixbufForTone(sharedPixbuf, isDarkMode) + : cp.extractDominantTone(wallpaperPath, isDarkMode); + const shadeFactor = isDarkMode + ? WALLPAPER_COLORS.PANEL_SHADE_DARK + : WALLPAPER_COLORS.PANEL_SHADE_LIGHT; + const panelRgb = ThemeUtils.colorShade(dominantRgb, shadeFactor); + const panelOpacity = this._getPanelOpacity(); + const panelCss = ThemeUtils.rgbaToCss(panelRgb[0], panelRgb[1], panelRgb[2], panelOpacity); + this.extension.settings.setValue('choose-override-panel-color', panelCss); + this.debugLog(`Panel color set: ${panelCss}`); + + // === STEP 2: Saturated palette — for accent/tint/glow colors only === + const preferLight = !isDarkMode; + const palette = sharedPixbuf + ? cp.extractFromPixbuf(sharedPixbuf, 8, preferLight) + : cp.extractColorsFromImage(wallpaperPath, 8, preferLight); + this.debugLog(`Palette extracted: ${palette ? palette.length : 0} colors`); + + // Dispose shared pixbuf now that both analyses are complete + if (sharedPixbuf) { + try { sharedPixbuf.run_dispose(); } catch (e) { /* ignore */ } + } + + // === STEP 3: Accent system (border, tint, shadow) === + // Only apply when full-auto is active — leaves visual effects page untouched + // when full-auto experimental mode is disabled. + const accentRgbArr = palette && palette.length > 0 + ? cp.getBestAccentColor(palette) + : [DEFAULT_COLORS.DEFAULT_ACCENT.r, DEFAULT_COLORS.DEFAULT_ACCENT.g, DEFAULT_COLORS.DEFAULT_ACCENT.b]; + + const accentColor = { r: accentRgbArr[0], g: accentRgbArr[1], b: accentRgbArr[2] }; + let accentForSystem = accentColor; + + if (this.extension.themeDetector && this.extension.themeDetector.validateAccentColor) { + const validation = this.extension.themeDetector.validateAccentColor(accentColor); + if (!validation.isValid) { + // If the color is too dark (not too light or desaturated), attempt to + // brighten it to a usable lightness before falling back to a generic default. + // This keeps the accent tonally tied to the wallpaper palette. + const isTooLight = validation.reason && validation.reason.includes('Too light'); + const isDesaturated = validation.reason && validation.reason.includes('Too desaturated'); + if (!isTooLight && !isDesaturated) { + // Boost lightness to 38% — enough to pass L>=25% threshold, not washed out + const hsl = ThemeUtils.rgbToHsl(accentColor.r, accentColor.g, accentColor.b); + const boosted = ThemeUtils.hslToRgb(hsl[0], hsl[1], WALLPAPER_COLORS.ACCENT_BOOST_TARGET_LIGHTNESS); + const revalidation = this.extension.themeDetector.validateAccentColor( + { r: boosted[0], g: boosted[1], b: boosted[2] } + ); + if (revalidation.isValid) { + this.debugLog( + `Accent brightened L:${hsl[2].toFixed(1)}%→${WALLPAPER_COLORS.ACCENT_BOOST_TARGET_LIGHTNESS}% ` + + `rgb(${accentColor.r},${accentColor.g},${accentColor.b}) → ` + + `rgb(${boosted[0]},${boosted[1]},${boosted[2]})` + ); + accentForSystem = { r: boosted[0], g: boosted[1], b: boosted[2] }; + } else { + this.debugLog(`Accent still invalid after boost (${revalidation.reason}), using default`); + accentForSystem = DEFAULT_COLORS.DEFAULT_ACCENT; + } + } else { + this.debugLog(`Accent invalid (${validation.reason}), using default`); + accentForSystem = DEFAULT_COLORS.DEFAULT_ACCENT; + } + } + } + + if (this.extension.themeDetector && this.extension.themeDetector.generateAccentSystem) { + const accentVariants = this.extension.themeDetector.generateAccentSystem(accentForSystem, isDarkMode); + if (accentVariants && fullAuto) { + this.extension.settings.setValue('blur-border-color', accentVariants.border); + this.extension.settings.setValue('blur-background', accentVariants.tint); + this.extension.settings.setValue('accent-shadow-color', accentVariants.shadow); + this.debugLog(`Accent system applied (full-auto): border=${accentVariants.border}`); + } + } + + // === STEP 4: Secondary color for popup background === + const secondaryRgb = palette && palette.length > 0 + ? cp.getSecondaryColor(palette, dominantRgb, isDarkMode) + : dominantRgb; + const menuOpacity = this._getMenuOpacity(); + const secondaryCss = ThemeUtils.rgbaToCss(secondaryRgb[0], secondaryRgb[1], secondaryRgb[2], menuOpacity); + this.extension.settings.setValue('choose-override-popup-color', secondaryCss); + this.debugLog(`Popup color set: ${secondaryCss}`); + } + + this.debugLog("Color extraction completed successfully"); + + // Activate panel override so extracted color applies to panel. + // settings.setValue() does not fire the bindProperty IN callbacks for the color + // pickers — the switch must be enabled explicitly to propagate the new color. + // Popup override is intentionally NOT auto-enabled — user controls it explicitly. + this.extension.settings.setValue('override-panel-color', true); + + this._wallpaperPath = wallpaperPath; + this._calculateHash(wallpaperPath).then(hash => { this._lastHash = hash; }) + .catch(e => this.debugLog(`Error calculating wallpaper hash: ${e.message}`)); + + // settings.setValue() does not trigger bindProperty IN callbacks — manual refresh required + this._forceRefreshAfterExtraction(); + } catch (e) { + this.debugLog(`Error during color extraction: ${e.message}`); + global.logError(`[CSSPanels] [WallpaperMonitor] Extraction error: ${e.message}\n${e.stack}`); + } finally { + this._extractionInProgress = false; + } + } + + /** + * Force full UI refresh after color extraction. + * settings.setValue() bypasses bindProperty IN callbacks, so panel/popup styles + * must be manually refreshed to reflect the new picker values. + * @private + */ + _forceRefreshAfterExtraction() { + try { + const ext = this.extension; + + if (ext.themeDetector && ext.themeDetector.invalidateCache) { + ext.themeDetector.invalidateCache(); + } + + if (ext.cssManager && ext.cssManager.updateAllVariables) { + ext.cssManager.updateAllVariables(); + } + + if (ext.panelStyler && ext.panelStyler.applyPanelStyles) { + ext.panelStyler.applyPanelStyles(); + this.debugLog("Panel styles refreshed"); + } + + if (ext.refreshAllActiveStyles) { + ext.refreshAllActiveStyles(); + this.debugLog("All active styles refreshed"); + } + + this.debugLog("Post-extraction UI refresh completed"); + } catch (e) { + this.debugLog(`Error during post-extraction refresh: ${e.message}`); + } + } + + /** + * Get panel opacity from extension settings + * @returns {number} Opacity value (0.0-1.0), default 0.6 + * @private + */ + _getPanelOpacity() { + try { + return this.extension.panelOpacity || 0.6; + } catch (e) { + return 0.6; + } + } + + /** + * Get menu opacity from extension settings + * @returns {number} Opacity value (0.0-1.0), default 0.8 + * @private + */ + _getMenuOpacity() { + try { + return this.extension.menuOpacity || 0.8; + } catch (e) { + return 0.8; + } + } + + /** + * Manual extraction trigger (called from settings button) + * Works even when wallpaper detection is disabled — reads the wallpaper path + * on-demand from org.cinnamon.desktop.background settings. + * + * @param {boolean} fullAuto - When true, also updates blur/accent color settings + * @returns {boolean} True if extraction was triggered successfully + */ + manualExtract(fullAuto = false) { + this.debugLog("🔘 Manual extraction triggered"); + + if (this._extractionInProgress) { + this.debugLog("⏳ Extraction already in progress"); + return false; + } + + // Always read current wallpaper from GSettings — cache may be stale when + // detection is disabled (no signal updates) and user changed the wallpaper. + // Fall back to cached path only if GSettings read fails. + let wallpaperPath = this._resolveCurrentWallpaperPath(); + if (!wallpaperPath) { + wallpaperPath = this._wallpaperPath; + } + + if (!wallpaperPath) { + this.debugLog("❌ No wallpaper path available"); + return false; + } + + const isDarkMode = this._detectDarkMode(); + this._triggerExtraction(wallpaperPath, isDarkMode, fullAuto); + + return true; + } + + /** + * Resolve the current wallpaper path without requiring the monitor to be enabled. + * Opens a temporary Gio.Settings instance if _backgroundSettings is not active. + * + * @returns {string|null} Plain file path or null if unavailable + * @private + */ + _resolveCurrentWallpaperPath() { + try { + const settings = this._backgroundSettings || new Gio.Settings({ + schema_id: "org.cinnamon.desktop.background", + }); + const uri = settings.get_string("picture-uri"); + if (!uri) return null; + + const file = Gio.File.new_for_uri(uri); + return file.get_path(); + } catch (e) { + this.debugLog(`Error resolving wallpaper path: ${e.message}`); + return null; + } + } + + /** + * Get current wallpaper path from settings + * @returns {string|null} Wallpaper file path or null + * @private + */ + _getCurrentWallpaperPath() { + if (!this._backgroundSettings) { + return null; + } + + try { + const uri = this._backgroundSettings.get_string("picture-uri"); + if (!uri) { + return null; + } + + // Use Gio.File for proper URI decoding (handles %20, UTF-8 paths, etc.) + const file = Gio.File.new_for_uri(uri); + return file.get_path(); + } catch (e) { + this.debugLog(`Error getting wallpaper path: ${e.message}`); + return null; + } + } + + /** + * Calculate simple hash for file (to detect changes) + * @param {string} filePath - Path to file + * @returns {Promise} Hash string or null + * @private + */ + _calculateHash(filePath) { + return new Promise((resolve) => { + const file = Gio.File.new_for_path(filePath); + file.query_info_async( + "standard::size,time::modified", + Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, + null, + (source, result) => { + try { + const info = source.query_info_finish(result); + const size = info.get_size(); + const mtime = info.get_modification_time().tv_sec; + resolve(`${size}_${mtime}`); + } catch (e) { + this.debugLog(`Error calculating hash: ${e.message}`); + resolve(null); + } + } + ); + }); + } + + /** + * Detect if dark mode is active + * Uses ThemeDetector's comprehensive 3-tier detection logic: + * 1. Theme name suffix (-Dark/-Light) + * 2. GTK theme name patterns + * 3. HSP brightness analysis (fallback) + * + * @returns {boolean} True if dark mode is active + * @private + */ + _detectDarkMode() { + // Tone override: explicit setting takes priority over auto-detection + const toneMode = this.extension.darkLightOverride || 'auto'; + if (toneMode === 'dark') return true; + if (toneMode === 'light') return false; + // 'auto' falls through to existing isDarkModePreferred() logic below + + try { + // Use ThemeDetector's robust dark mode detection (3-tier priority) + if (this.extension.themeDetector && this.extension.themeDetector.isDarkModePreferred) { + return this.extension.themeDetector.isDarkModePreferred(); + } + + // Fallback: Check GTK theme name for -Dark suffix (if ThemeDetector unavailable) + const themeName = this.extension.themeDetector?.getCurrentThemeName?.() || ""; + const isDarkTheme = themeName.includes("-Dark") || themeName.includes("-dark"); + + return isDarkTheme; + } catch (e) { + this.debugLog(`Error detecting dark mode: ${e.message}`); + return false; // Default to light mode + } + } + + /** + * Debug logging helper + * @param {string} message - Message to log + * @private + */ + debugLog(message) { + if (this.extension.debugLog) { + this.extension.debugLog(`[WallpaperMonitor] ${message}`); + } + } +} + +module.exports = WallpaperMonitor; diff --git a/csspanels@dr.drummie/info.json b/csspanels@dr.drummie/info.json new file mode 100644 index 00000000..62639713 --- /dev/null +++ b/csspanels@dr.drummie/info.json @@ -0,0 +1,3 @@ +{ + "author": "drdrummie" +} diff --git a/csspanels@dr.drummie/screenshot.png b/csspanels@dr.drummie/screenshot.png new file mode 100644 index 00000000..556f6c42 Binary files /dev/null and b/csspanels@dr.drummie/screenshot.png differ