diff --git a/flex-taskbar/README.md b/flex-taskbar/README.md new file mode 100644 index 00000000..d82f56cf --- /dev/null +++ b/flex-taskbar/README.md @@ -0,0 +1,142 @@ +# Flex Taskbar + +Flex Taskbar is a configurable Niri taskbar for the Noctalia bar. It groups windows by application, shows one dot per window and a focused-app underline, and provides clickable window thumbnails on hover. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `sudiptacmd/flex-taskbar` | +| Entries | Bar widget: `taskbar`; panel: `previews` | + +## Screenshot + +![Flex Taskbar running in the Noctalia top bar](screenshot.webp) + +## Requirements + +- `niri` supplies the window list, focus actions, output information, and window screenshots. +- `gtk-launch` launches pinned desktop applications that do not have a custom command. It is normally provided by GTK. +- `wl-copy` and `wl-paste` preserve and restore the clipboard while Niri captures preview thumbnails. Both are provided by `wl-clipboard` on most distributions. +- `head` selects the first clipboard MIME type and `rm` removes the temporary clipboard backup. Both are normally provided by GNU coreutils. + +All six commands must be available on `PATH`. This plugin is designed for Niri and does not support other compositors. + +## Usage + +Add the `taskbar` widget to a bar in Noctalia Settings. Right-click the widget to open its settings, then edit **Pinned applications** using desktop-file IDs without the `.desktop` suffix, for example `firefox`, `kitty`, or `org.gnome.Nautilus`. + +- Left-click a stopped pin to launch it with `gtk-launch` or its configured custom command. +- Left-click a running app to focus it; repeated clicks cycle through that app's windows. +- Scroll over the widget to move focus through running application groups. +- Hover a running app to open its window previews, then click a preview to focus that window. +- Disable **Window previews on hover** in plugin settings if previews are not wanted. + +If Niri's `app_id` differs from the desktop-file ID, add a **Window app aliases** mapping from the Niri ID to the pinned ID. Inspect current IDs with: + +```sh +niri msg -j windows +``` + +The preview panel normally opens automatically from the bar widget. It can also be toggled directly, although it will be empty until the widget has populated preview state: + +```sh +noctalia msg panel-toggle sudiptacmd/flex-taskbar:previews +``` + +Flex Taskbar's own hover background is transparent by default. If Noctalia still draws a capsule around the whole widget, disable the bar's global **Hover highlight** option. + +## Settings + +Plugin-wide preview settings: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `preview_enabled` | `bool` | `true` | Enables window previews when a running app is hovered. | +| `preview_hover_delay` | `int` | `220` | Delay in milliseconds before opening previews. | +| `preview_hide_delay` | `int` | `450` | Grace period in milliseconds for moving into the preview panel. | +| `preview_max_windows` | `int` | `3` | Maximum preview cards shown for one app, from 1 to 3. | +| `preview_width` | `int` | `190` | Thumbnail width in pixels. | +| `preview_height` | `int` | `115` | Thumbnail height in pixels. | +| `preview_gap` | `int` | `10` | Gap between preview cards in pixels. | +| `preview_radius` | `int` | `8` | Thumbnail corner radius in pixels. | +| `preview_show_titles` | `bool` | `true` | Shows the window title below each thumbnail. | +| `preview_title_size` | `int` | `11` | Window-title font size in pixels. | +| `preview_refresh_seconds` | `int` | `12` | Seconds before a cached thumbnail is captured again. | +| `preview_card_color` | `color` | `#ffffff12` | Preview-card background color. | +| `preview_border_color` | `color` | `#ffffff20` | Border color for previews that are not focused. | +| `preview_border_width` | `double` | `1.0` | Preview-card border width in pixels. | + +Per-widget taskbar settings: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `pinned_apps` | `string_list` | common desktop apps | Ordered desktop-file IDs to keep as launchers. | +| `show_unpinned_running` | `bool` | `true` | Adds running applications that are not pinned. | +| `hide_stopped_pins` | `bool` | `false` | Hides a pin when it has no windows. | +| `app_aliases` | `string_map` | common aliases | Maps Niri `app_id` values to pinned desktop-file IDs. | +| `launch_commands` | `string_map` | empty | Maps an app ID to a shell launch command instead of `gtk-launch`. | +| `icon_overrides` | `string_map` | empty | Maps an app ID to an icon name or local image path. | +| `label_overrides` | `string_map` | empty | Maps an app ID to a custom visible label. | +| `current_output_only` | `bool` | `false` | Shows only windows on the monitor containing this bar; pins remain visible. | +| `order` | `select` | `pinned_first` | Orders groups pinned-first, running-first, or alphabetically. | +| `icon_size` | `int` | `24` | App icon size in pixels. | +| `icon_radius` | `int` | `5` | App icon corner radius in pixels. | +| `item_width` | `int` | `34` | Minimum width of each app item in pixels. | +| `item_gap` | `int` | `3` | Gap between app items in pixels. | +| `item_padding` | `int` | `3` | Horizontal padding inside an app item in pixels. | +| `item_radius` | `int` | `8` | App-item corner radius in pixels. | +| `item_border_width` | `double` | `0.0` | App-item border width in pixels. | +| `item_border_color` | `color` | transparent | App-item border color. | +| `background_style` | `select` | `active` | Draws item backgrounds for none, running, focused, or all apps. | +| `running_background_color` | `color` | `#ffffff12` | Background color for running app items. | +| `active_background_color` | `color` | `#7aa2f726` | Background color for the focused app item. | +| `hover_background_color` | `color` | transparent | Background color while hovering an app; transparent by default. | +| `active_opacity` | `double` | `1.0` | Focused icon and label opacity. | +| `inactive_opacity` | `double` | `1.0` | Running, unfocused icon and label opacity. | +| `stopped_opacity` | `double` | `1.0` | Stopped launcher icon and label opacity. | +| `show_labels` | `bool` | `false` | Shows application labels beside icons. | +| `label_size` | `int` | `12` | App-label font size in pixels. | +| `label_max_width` | `int` | `90` | Maximum app-label width in pixels. | +| `show_window_dots` | `bool` | `true` | Shows one indicator dot per window. | +| `dot_size` | `int` | `3` | Window-dot diameter in pixels. | +| `dot_gap` | `int` | `2` | Gap between window dots in pixels. | +| `max_dots` | `int` | `8` | Maximum dots drawn per app; all windows remain available for cycling. | +| `dot_color` | `color` | `#a9b1d6` | Unfocused window-dot color. | +| `focused_dot_color` | `color` | `#7aa2f7` | Focused window-dot color. | +| `show_active_underline` | `bool` | `true` | Shows an underline beneath the focused application. | +| `underline_width` | `int` | `18` | Focused underline width in pixels. | +| `underline_thickness` | `int` | `2` | Focused underline thickness in pixels. | +| `underline_radius` | `int` | `2` | Focused underline corner radius in pixels. | +| `underline_color` | `color` | `#7aa2f7` | Focused underline color. | +| `indicator_gap` | `int` | `1` | Vertical gap between the icon, dots, and underline in pixels. | +| `fallback_glyph` | `glyph` | `app-window` | Glyph used when no app icon can be resolved. | +| `poll_interval` | `int` | `350` | Milliseconds between Niri window queries. Lower values are more responsive and use more resources. | + +## IPC + +Refresh the taskbar immediately: + +```sh +noctalia msg plugin sudiptacmd/flex-taskbar:taskbar all refresh +``` + +Open plugin settings, close previews, or request previews for an app ID: + +```sh +noctalia msg plugin sudiptacmd/flex-taskbar:taskbar all settings +noctalia msg plugin sudiptacmd/flex-taskbar:taskbar all close-previews +noctalia msg plugin sudiptacmd/flex-taskbar:taskbar all preview firefox +``` + +## Notes + +- The plugin asynchronously runs `niri msg -j windows` at the configured polling interval and `niri msg -j workspaces` when output filtering is enabled. It also runs Niri focus and screenshot actions, `gtk-launch`, and user-provided custom launch commands. +- Preview PNGs and a temporary clipboard backup are written under `noctalia.pluginDataDir()`, in the plugin's Noctalia state-data directory. No network requests are made. +- Niri's window screenshot action writes the image to the clipboard. The plugin preserves and restores the first available clipboard MIME type with `wl-paste` and `wl-copy`, but clipboard-history tools may still briefly observe the screenshot. +- Niri windows configured with `block-out-from "screen-capture"` may produce hidden or black previews. +- Custom launch commands are executed as the current user. Only configure commands you trust. + +## License + +MIT diff --git a/flex-taskbar/plugin.toml b/flex-taskbar/plugin.toml new file mode 100644 index 00000000..983f03bc --- /dev/null +++ b/flex-taskbar/plugin.toml @@ -0,0 +1,473 @@ +id = "sudiptacmd/flex-taskbar" +name = "Flex Taskbar" +version = "1.0.0" +plugin_api = 15 +author = "sudiptacmd" +license = "MIT" +icon = "layout-navbar" +description = "A highly customizable Niri taskbar with pinned apps, per-window dots, and a focused-window underline." +tags = ["bar", "panel", "niri", "productivity", "theming", "utility"] +dependencies = ["niri", "gtk-launch", "wl-copy", "wl-paste", "head", "rm"] + +[[setting]] +key = "preview_enabled" +type = "bool" +label_key = "settings.preview_enabled.label" +description_key = "settings.preview_enabled.description" +default = true + +[[setting]] +key = "preview_hover_delay" +type = "int" +label_key = "settings.preview_hover_delay.label" +description_key = "settings.preview_hover_delay.description" +default = 220 +min = 0 +max = 1500 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_hide_delay" +type = "int" +label_key = "settings.preview_hide_delay.label" +description_key = "settings.preview_hide_delay.description" +default = 450 +min = 100 +max = 2000 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_max_windows" +type = "int" +label_key = "settings.preview_max_windows.label" +description_key = "settings.preview_max_windows.description" +default = 3 +min = 1 +max = 3 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_width" +type = "int" +label_key = "settings.preview_width.label" +default = 190 +min = 100 +max = 210 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_height" +type = "int" +label_key = "settings.preview_height.label" +default = 115 +min = 60 +max = 140 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_gap" +type = "int" +label_key = "settings.preview_gap.label" +default = 10 +min = 0 +max = 24 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_radius" +type = "int" +label_key = "settings.preview_radius.label" +default = 8 +min = 0 +max = 24 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_show_titles" +type = "bool" +label_key = "settings.preview_show_titles.label" +default = true +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_title_size" +type = "int" +label_key = "settings.preview_title_size.label" +default = 11 +min = 8 +max = 18 +visible_when = { key = "preview_show_titles", values = ["true"] } + +[[setting]] +key = "preview_refresh_seconds" +type = "int" +label_key = "settings.preview_refresh_seconds.label" +description_key = "settings.preview_refresh_seconds.description" +default = 12 +min = 2 +max = 120 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_card_color" +type = "color" +label_key = "settings.preview_card_color.label" +default = "#ffffff12" +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_border_color" +type = "color" +label_key = "settings.preview_border_color.label" +default = "#ffffff20" +visible_when = { key = "preview_enabled", values = ["true"] } + +[[setting]] +key = "preview_border_width" +type = "double" +label_key = "settings.preview_border_width.label" +default = 1.0 +min = 0.0 +max = 3.0 +visible_when = { key = "preview_enabled", values = ["true"] } + +[[widget]] +id = "taskbar" +entry = "taskbar.luau" + + [[widget.setting]] + key = "pinned_apps" + type = "string_list" + label_key = "settings.pinned_apps.label" + description_key = "settings.pinned_apps.description" + default = ["firefox", "kitty", "org.gnome.Nautilus", "code", "com.anthropic.Claude", "vesktop", "google-chrome", "github-desktop"] + + [[widget.setting]] + key = "show_unpinned_running" + type = "bool" + label_key = "settings.show_unpinned_running.label" + description_key = "settings.show_unpinned_running.description" + default = true + + [[widget.setting]] + key = "hide_stopped_pins" + type = "bool" + label_key = "settings.hide_stopped_pins.label" + description_key = "settings.hide_stopped_pins.description" + default = false + + [[widget.setting]] + key = "app_aliases" + type = "string_map" + label_key = "settings.app_aliases.label" + description_key = "settings.app_aliases.description" + default = { "com.google.Chrome" = "google-chrome", "code-url-handler" = "code", "github.desktop" = "github-desktop" } + advanced = true + + [[widget.setting]] + key = "launch_commands" + type = "string_map" + label_key = "settings.launch_commands.label" + description_key = "settings.launch_commands.description" + default = {} + advanced = true + + [[widget.setting]] + key = "icon_overrides" + type = "string_map" + label_key = "settings.icon_overrides.label" + description_key = "settings.icon_overrides.description" + default = {} + advanced = true + + [[widget.setting]] + key = "label_overrides" + type = "string_map" + label_key = "settings.label_overrides.label" + description_key = "settings.label_overrides.description" + default = {} + advanced = true + + [[widget.setting]] + key = "current_output_only" + type = "bool" + label_key = "settings.current_output_only.label" + description_key = "settings.current_output_only.description" + default = false + + [[widget.setting]] + key = "order" + type = "select" + label_key = "settings.order.label" + default = "pinned_first" + options = [ + { value = "pinned_first", label_key = "settings.order.pinned_first" }, + { value = "running_first", label_key = "settings.order.running_first" }, + { value = "alphabetical", label_key = "settings.order.alphabetical" }, + ] + + [[widget.setting]] + key = "icon_size" + type = "int" + label_key = "settings.icon_size.label" + default = 24 + min = 12 + max = 48 + + [[widget.setting]] + key = "icon_radius" + type = "int" + label_key = "settings.icon_radius.label" + default = 5 + min = 0 + max = 24 + + [[widget.setting]] + key = "item_width" + type = "int" + label_key = "settings.item_width.label" + description_key = "settings.item_width.description" + default = 34 + min = 20 + max = 80 + + [[widget.setting]] + key = "item_gap" + type = "int" + label_key = "settings.item_gap.label" + default = 3 + min = 0 + max = 20 + + [[widget.setting]] + key = "item_padding" + type = "int" + label_key = "settings.item_padding.label" + default = 3 + min = 0 + max = 16 + + [[widget.setting]] + key = "item_radius" + type = "int" + label_key = "settings.item_radius.label" + default = 8 + min = 0 + max = 30 + + [[widget.setting]] + key = "item_border_width" + type = "double" + label_key = "settings.item_border_width.label" + default = 0.0 + min = 0.0 + max = 4.0 + + [[widget.setting]] + key = "item_border_color" + type = "color" + label_key = "settings.item_border_color.label" + default = "#00000000" + visible_when = { key = "item_border_width", values = ["1", "2", "3", "4"] } + + [[widget.setting]] + key = "background_style" + type = "select" + label_key = "settings.background_style.label" + default = "active" + options = [ + { value = "none", label_key = "settings.background_style.none" }, + { value = "running", label_key = "settings.background_style.running" }, + { value = "active", label_key = "settings.background_style.active" }, + { value = "always", label_key = "settings.background_style.always" }, + ] + + [[widget.setting]] + key = "running_background_color" + type = "color" + label_key = "settings.running_background_color.label" + default = "#ffffff12" + + [[widget.setting]] + key = "active_background_color" + type = "color" + label_key = "settings.active_background_color.label" + default = "#7aa2f726" + + [[widget.setting]] + key = "hover_background_color" + type = "color" + label_key = "settings.hover_background_color.label" + default = "#00000000" + + [[widget.setting]] + key = "active_opacity" + type = "double" + label_key = "settings.active_opacity.label" + default = 1.0 + min = 0.1 + max = 1.0 + + [[widget.setting]] + key = "inactive_opacity" + type = "double" + label_key = "settings.inactive_opacity.label" + default = 1.0 + min = 0.1 + max = 1.0 + + [[widget.setting]] + key = "stopped_opacity" + type = "double" + label_key = "settings.stopped_opacity.label" + default = 1.0 + min = 0.1 + max = 1.0 + + [[widget.setting]] + key = "show_labels" + type = "bool" + label_key = "settings.show_labels.label" + default = false + + [[widget.setting]] + key = "label_size" + type = "int" + label_key = "settings.label_size.label" + default = 12 + min = 8 + max = 24 + visible_when = { key = "show_labels", values = ["true"] } + + [[widget.setting]] + key = "label_max_width" + type = "int" + label_key = "settings.label_max_width.label" + default = 90 + min = 30 + max = 240 + visible_when = { key = "show_labels", values = ["true"] } + + [[widget.setting]] + key = "show_window_dots" + type = "bool" + label_key = "settings.show_window_dots.label" + default = true + + [[widget.setting]] + key = "dot_size" + type = "int" + label_key = "settings.dot_size.label" + default = 3 + min = 1 + max = 10 + visible_when = { key = "show_window_dots", values = ["true"] } + + [[widget.setting]] + key = "dot_gap" + type = "int" + label_key = "settings.dot_gap.label" + default = 2 + min = 0 + max = 10 + visible_when = { key = "show_window_dots", values = ["true"] } + + [[widget.setting]] + key = "max_dots" + type = "int" + label_key = "settings.max_dots.label" + description_key = "settings.max_dots.description" + default = 8 + min = 1 + max = 20 + visible_when = { key = "show_window_dots", values = ["true"] } + + [[widget.setting]] + key = "dot_color" + type = "color" + label_key = "settings.dot_color.label" + default = "#a9b1d6" + visible_when = { key = "show_window_dots", values = ["true"] } + + [[widget.setting]] + key = "focused_dot_color" + type = "color" + label_key = "settings.focused_dot_color.label" + default = "#7aa2f7" + visible_when = { key = "show_window_dots", values = ["true"] } + + [[widget.setting]] + key = "show_active_underline" + type = "bool" + label_key = "settings.show_active_underline.label" + default = true + + [[widget.setting]] + key = "underline_width" + type = "int" + label_key = "settings.underline_width.label" + default = 18 + min = 3 + max = 60 + visible_when = { key = "show_active_underline", values = ["true"] } + + [[widget.setting]] + key = "underline_thickness" + type = "int" + label_key = "settings.underline_thickness.label" + default = 2 + min = 1 + max = 8 + visible_when = { key = "show_active_underline", values = ["true"] } + + [[widget.setting]] + key = "underline_radius" + type = "int" + label_key = "settings.underline_radius.label" + default = 2 + min = 0 + max = 8 + visible_when = { key = "show_active_underline", values = ["true"] } + + [[widget.setting]] + key = "underline_color" + type = "color" + label_key = "settings.underline_color.label" + default = "#7aa2f7" + visible_when = { key = "show_active_underline", values = ["true"] } + + [[widget.setting]] + key = "indicator_gap" + type = "int" + label_key = "settings.indicator_gap.label" + default = 1 + min = 0 + max = 8 + + [[widget.setting]] + key = "fallback_glyph" + type = "glyph" + label_key = "settings.fallback_glyph.label" + default = "app-window" + advanced = true + + [[widget.setting]] + key = "poll_interval" + type = "int" + label_key = "settings.poll_interval.label" + description_key = "settings.poll_interval.description" + default = 350 + min = 100 + max = 5000 + advanced = true + +[[panel]] +id = "previews" +entry = "previews.luau" +width = 720 +height = 210 +placement = "attached" +position = "auto" +open_near_click = true +dismiss_on_outside_click = false +keyboard_focus = "none" diff --git a/flex-taskbar/previews.luau b/flex-taskbar/previews.luau new file mode 100644 index 00000000..17d0c30d --- /dev/null +++ b/flex-taskbar/previews.luau @@ -0,0 +1,127 @@ +--!nonstrict +-- Hover preview panel for Flex Taskbar. + +local opened = false + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function setPanelHovered(state) + noctalia.state.set("preview_panel_hovered", state == "true") +end + +local function focusWindow(windowId) + local id = tonumber(windowId) + if id == nil then return end + noctalia.runAsync("niri msg action focus-window --id " .. tostring(math.floor(id))) + panel.close() +end + +local function fallbackIcon(appId) + return noctalia.appIconPath(tostring(appId or ""), tonumber(cfg("preview_width")) or 190) +end + +local function makeCard(window) + local width = tonumber(cfg("preview_width")) or 190 + local height = tonumber(cfg("preview_height")) or 115 + local radius = tonumber(cfg("preview_radius")) or 8 + local content = {} + + if window.ready == true and noctalia.fileExists(tostring(window.path or "")) then + table.insert(content, ui.image({ + key = "thumb-" .. tostring(window.id), + path = tostring(window.path), + width = width, + height = height, + radius = radius, + fit = "cover", + })) + else + local icon = fallbackIcon(window.app_id) + if icon ~= nil then + table.insert(content, ui.image({ + key = "fallback-" .. tostring(window.id), + path = icon, + width = math.min(64, width), + height = math.min(64, height), + radius = radius, + fit = "contain", + opacity = 0.65, + })) + else + table.insert(content, ui.glyph({ name = "app-window", size = 46, opacity = 0.55 })) + end + end + + if cfg("preview_show_titles") == true then + table.insert(content, ui.label({ + key = "title-" .. tostring(window.id), + text = tostring(window.title or "Window"), + fontSize = tonumber(cfg("preview_title_size")) or 11, + maxWidth = width, + maxLines = 1, + textAlign = "center", + color = window.focused == true and "primary" or "on_surface", + })) + end + + return ui.column({ + key = "window-" .. tostring(window.id), + width = width + 12, + padding = 6, + gap = 6, + align = "center", + justify = "center", + fill = cfg("preview_card_color"), + radius = radius + 3, + border = window.focused == true and "primary" or cfg("preview_border_color"), + borderWidth = tonumber(cfg("preview_border_width")) or 1.0, + onClick = function() focusWindow(window.id) end, + onHover = setPanelHovered, + }, content) +end + +local function render() + if not opened then return end + local data = noctalia.state.get("preview_data") or {} + local cards = {} + for _, window in ipairs(data) do table.insert(cards, makeCard(window)) end + + if #cards == 0 then + table.insert(cards, ui.column({ align = "center", justify = "center", flexGrow = 1 }, { + ui.glyph({ name = "photo-off", size = 34, opacity = 0.5 }), + ui.label({ text = "No window previews", opacity = 0.6 }), + })) + end + + panel.render(ui.row({ + padding = 12, + gap = tonumber(cfg("preview_gap")) or 10, + align = "center", + justify = "center", + onHover = setPanelHovered, + }, cards)) +end + +noctalia.state.watch("preview_data", function(_value) + render() +end) + +function onOpen(_context) + opened = true + noctalia.state.set("preview_open", true) + noctalia.state.set("preview_panel_hovered", false) + render() +end + +function onClose() + opened = false + noctalia.state.set("preview_open", false) + noctalia.state.set("preview_panel_hovered", false) +end + +function onExit() + noctalia.state.set("preview_open", false) + noctalia.state.set("preview_panel_hovered", false) +end diff --git a/flex-taskbar/screenshot.webp b/flex-taskbar/screenshot.webp new file mode 100644 index 00000000..0ee00cac Binary files /dev/null and b/flex-taskbar/screenshot.webp differ diff --git a/flex-taskbar/taskbar.luau b/flex-taskbar/taskbar.luau new file mode 100644 index 00000000..3d521b94 --- /dev/null +++ b/flex-taskbar/taskbar.luau @@ -0,0 +1,581 @@ +--!nonstrict +-- Flex Taskbar for Noctalia v5 + Niri. +-- Niri is queried asynchronously so compositor IPC never blocks the shell UI. + +local groups = {} +local hoveredApp = nil +local refreshInFlight = false +local workspaceOutputs = {} +local iconCache = {} +local lastCycle = {} +local warned = false +local previewRequestedApp = nil +local previewShowAt = nil +local previewCloseAt = nil +local previewOpening = false +local previewGeneration = 0 + +noctalia.state.watch("preview_open", function(value) + previewOpening = value == true +end) + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function lower(value) + return string.lower(tostring(value or "")) +end + +local function shellQuote(value) + local text = tostring(value or "") + return "'" .. string.gsub(text, "'", "'\\''") .. "'" +end + +local function tableValue(map, key) + if type(map) ~= "table" then return nil end + if map[key] ~= nil then return map[key] end + local wanted = lower(key) + for candidate, value in pairs(map) do + if lower(candidate) == wanted then return value end + end + return nil +end + +local function containsPinned(pins, appId) + local wanted = lower(appId) + for index, pinned in ipairs(pins) do + if lower(pinned) == wanted then return index, pinned end + end + return nil, nil +end + +local function canonicalAppId(appId, pins, aliases) + local alias = tableValue(aliases, appId) + if alias ~= nil and tostring(alias) ~= "" then return tostring(alias) end + local _, pinned = containsPinned(pins, appId) + return pinned or tostring(appId) +end + +local function appLabel(appId) + local override = tableValue(cfg("label_overrides"), appId) + if override ~= nil and tostring(override) ~= "" then return tostring(override) end + local label = tostring(appId) + label = string.gsub(label, "%.desktop$", "") + label = string.gsub(label, "^org%.gnome%.", "") + label = string.gsub(label, "^com%.[^%.]+%.", "") + label = string.gsub(label, "[-_%.]+", " ") + return label +end + +local function resolveIcon(appId) + local size = tonumber(cfg("icon_size")) or 24 + local override = tableValue(cfg("icon_overrides"), appId) + local source = (override ~= nil and tostring(override) ~= "") and tostring(override) or appId + local cacheKey = tostring(source) .. "|" .. tostring(size) + if iconCache[cacheKey] ~= nil then + return iconCache[cacheKey] ~= false and iconCache[cacheKey] or nil + end + local path = noctalia.appIconPath(source, size) + iconCache[cacheKey] = path or false + return path +end + +local function findGroup(appId) + for _, group in ipairs(groups) do + if group.id == appId then return group end + end + return nil +end + +local function focusWindow(windowId) + local id = tonumber(windowId) + if id == nil then return end + noctalia.runAsync("niri msg action focus-window --id " .. tostring(math.floor(id))) +end + +local function launchApp(appId) + local command = tableValue(cfg("launch_commands"), appId) + if command ~= nil and tostring(command) ~= "" then + noctalia.runAsync(tostring(command)) + else + noctalia.runAsync("gtk-launch " .. shellQuote(appId)) + end +end + +local function activateApp(appId) + local group = findGroup(appId) + if group == nil or #group.windows == 0 then + launchApp(appId) + return + end + + local focusedIndex = nil + for index, window in ipairs(group.windows) do + if window.is_focused == true then focusedIndex = index break end + end + + local nextIndex = 1 + if focusedIndex ~= nil then + nextIndex = (focusedIndex % #group.windows) + 1 + elseif lastCycle[appId] ~= nil then + nextIndex = (lastCycle[appId] % #group.windows) + 1 + end + lastCycle[appId] = nextIndex + focusWindow(group.windows[nextIndex].id) +end + +local function windowFocusTime(window) + local timestamp = window.focus_timestamp + if type(timestamp) ~= "table" then return 0 end + return (tonumber(timestamp.secs) or 0) * 1000000000 + (tonumber(timestamp.nanos) or 0) +end + +local function previewWindows(group) + local windows = {} + for _, window in ipairs(group.windows) do table.insert(windows, window) end + table.sort(windows, function(a, b) + if a.is_focused ~= b.is_focused then return a.is_focused == true end + return windowFocusTime(a) > windowFocusTime(b) + end) + return windows +end + +local function thumbnailIsFresh(path) + local info = noctalia.fileInfo(path) + if info == nil or info.isDir == true then return false end + local age = (noctalia.nowMs() / 1000) - (tonumber(info.mtime) or 0) + return age <= (tonumber(cfg("preview_refresh_seconds")) or 12) +end + +local function ensurePreviewPanel() + if noctalia.state.get("preview_open") == true or previewOpening then return end + previewOpening = true + noctalia.togglePanel("sudiptacmd/flex-taskbar:previews") +end + +local function closePreviewPanel() + previewRequestedApp = nil + previewShowAt = nil + previewCloseAt = nil + if noctalia.state.get("preview_open") == true or previewOpening then + noctalia.togglePanel("sudiptacmd/flex-taskbar:previews") + end + previewOpening = false +end + +local function publishPreview(appId, entries) + noctalia.state.set("preview_app", appId) + noctalia.state.set("preview_data", entries) + ensurePreviewPanel() +end + +local function capturePreviews(appId, entries, stale) + if #stale == 0 then return end + previewGeneration = previewGeneration + 1 + local generation = previewGeneration + local backup = "/tmp/noctalia-flex-taskbar-clipboard-" .. tostring(math.floor(noctalia.nowMs())) + local commands = { + "mime=$(wl-paste --list-types 2>/dev/null | head -n 1)", + "if [ -n \"$mime\" ]; then wl-paste --type \"$mime\" > " .. shellQuote(backup) .. " 2>/dev/null; fi", + } + for _, entry in ipairs(stale) do + table.insert(commands, + "niri msg action screenshot-window --id " .. tostring(entry.id) + .. " --write-to-disk true --show-pointer false --path " .. shellQuote(entry.path) .. " >/dev/null 2>&1" + ) + end + table.insert(commands, + "if [ -n \"$mime\" ] && [ -e " .. shellQuote(backup) + .. " ]; then wl-copy --type \"$mime\" < " .. shellQuote(backup) + .. "; else wl-copy --clear; fi" + ) + + noctalia.runAsync(table.concat(commands, "; "), function(_result) + noctalia.removeFile(backup) + if generation ~= previewGeneration or noctalia.state.get("preview_app") ~= appId then return end + for _, entry in ipairs(entries) do + entry.ready = noctalia.fileExists(entry.path) + end + noctalia.state.set("preview_data", entries) + end) +end + +local function showPreview(appId) + local group = findGroup(appId) + if cfg("preview_enabled") ~= true or group == nil or #group.windows == 0 then + closePreviewPanel() + return + end + + local dataDir = noctalia.pluginDataDir() + if dataDir == nil then return end + local previewDir = dataDir .. "/previews" + noctalia.mkdirAll(previewDir) + + local entries = {} + local stale = {} + local windows = previewWindows(group) + local limit = math.min(#windows, tonumber(cfg("preview_max_windows")) or 3) + for index = 1, limit do + local window = windows[index] + local id = math.floor(tonumber(window.id) or 0) + local path = previewDir .. "/window-" .. tostring(id) .. ".png" + local entry = { + id = id, + app_id = appId, + title = tostring(window.title or appLabel(appId)), + path = path, + focused = window.is_focused == true, + ready = thumbnailIsFresh(path), + } + table.insert(entries, entry) + if not entry.ready then table.insert(stale, entry) end + end + + publishPreview(appId, entries) + capturePreviews(appId, entries, stale) +end + +local function requestPreview(appId) + if cfg("preview_enabled") ~= true then return end + local group = findGroup(appId) + if group == nil or #group.windows == 0 then return end + previewRequestedApp = appId + previewShowAt = noctalia.nowMs() + (tonumber(cfg("preview_hover_delay")) or 220) + previewCloseAt = nil +end + +local function updatePreviewLifecycle() + if cfg("preview_enabled") ~= true then + if noctalia.state.get("preview_open") == true then closePreviewPanel() end + return + end + + local now = noctalia.nowMs() + if previewRequestedApp ~= nil and hoveredApp == previewRequestedApp and previewShowAt ~= nil and now >= previewShowAt then + local appId = previewRequestedApp + previewRequestedApp = nil + previewShowAt = nil + showPreview(appId) + end + + local panelHovered = noctalia.state.get("preview_panel_hovered") == true + if panelHovered or hoveredApp ~= nil then + previewCloseAt = nil + elseif noctalia.state.get("preview_open") == true or previewOpening then + if previewCloseAt == nil then + previewCloseAt = now + (tonumber(cfg("preview_hide_delay")) or 450) + elseif now >= previewCloseAt then + closePreviewPanel() + end + end +end + +local function backgroundFor(group) + if hoveredApp == group.id then return cfg("hover_background_color") end + local style = tostring(cfg("background_style") or "active") + if style == "always" then + return group.focused and cfg("active_background_color") or cfg("running_background_color") + elseif style == "running" and #group.windows > 0 then + return group.focused and cfg("active_background_color") or cfg("running_background_color") + elseif style == "active" and group.focused then + return cfg("active_background_color") + end + return "#00000000" +end + +local function iconOpacity(group) + if group.focused then return tonumber(cfg("active_opacity")) or 1.0 end + if #group.windows > 0 then return tonumber(cfg("inactive_opacity")) or 1.0 end + return tonumber(cfg("stopped_opacity")) or 1.0 +end + +local function makeDots(group) + local dotSize = tonumber(cfg("dot_size")) or 3 + local children = {} + if cfg("show_window_dots") == true then + local limit = math.min(#group.windows, tonumber(cfg("max_dots")) or 8) + for index = 1, limit do + local window = group.windows[index] + table.insert(children, ui.box({ + key = "dot-" .. tostring(window.id), + width = dotSize, + height = dotSize, + radius = dotSize / 2, + fill = window.is_focused == true and cfg("focused_dot_color") or cfg("dot_color"), + })) + end + end + if #children == 0 then + table.insert(children, ui.box({ width = 1, height = dotSize, fill = "#00000000" })) + end + return ui.row({ + key = "dots-" .. group.id, + height = dotSize, + gap = tonumber(cfg("dot_gap")) or 2, + align = "center", + justify = "center", + visible = cfg("show_window_dots") == true, + }, children) +end + +local function makeUnderline(group) + local shown = cfg("show_active_underline") == true + return ui.box({ + key = "underline-" .. group.id, + width = tonumber(cfg("underline_width")) or 18, + height = tonumber(cfg("underline_thickness")) or 2, + radius = tonumber(cfg("underline_radius")) or 2, + fill = cfg("underline_color"), + opacity = group.focused and 1.0 or 0.0, + visible = shown, + }) +end + +local function makeAppTile(group) + local appId = group.id + local iconSize = tonumber(cfg("icon_size")) or 24 + local content = {} + local iconPath = resolveIcon(appId) + if iconPath ~= nil then + table.insert(content, ui.image({ + key = "icon-" .. appId, + path = iconPath, + width = iconSize, + height = iconSize, + radius = tonumber(cfg("icon_radius")) or 5, + fit = "contain", + opacity = iconOpacity(group), + })) + else + table.insert(content, ui.glyph({ + key = "glyph-" .. appId, + name = tostring(cfg("fallback_glyph") or "app-window"), + size = iconSize, + opacity = iconOpacity(group), + })) + end + + if cfg("show_labels") == true then + table.insert(content, ui.label({ + key = "label-" .. appId, + text = appLabel(appId), + fontSize = tonumber(cfg("label_size")) or 12, + maxWidth = tonumber(cfg("label_max_width")) or 90, + maxLines = 1, + opacity = iconOpacity(group), + })) + end + + local itemWidth = math.max( + tonumber(cfg("item_width")) or 34, + iconSize + (tonumber(cfg("item_padding")) or 3) * 2, + (tonumber(cfg("underline_width")) or 18) + 4 + ) + + return ui.column({ + key = "app-" .. appId, + minWidth = itemWidth, + paddingH = tonumber(cfg("item_padding")) or 3, + paddingV = 1, + gap = tonumber(cfg("indicator_gap")) or 1, + align = "center", + justify = "center", + fill = backgroundFor(group), + radius = tonumber(cfg("item_radius")) or 8, + border = cfg("item_border_color"), + borderWidth = tonumber(cfg("item_border_width")) or 0, + onClick = function() activateApp(appId) end, + onHover = function(state) + if state == "true" then + hoveredApp = appId + requestPreview(appId) + elseif hoveredApp == appId then + hoveredApp = nil + previewRequestedApp = nil + previewShowAt = nil + previewCloseAt = noctalia.nowMs() + (tonumber(cfg("preview_hide_delay")) or 450) + end + if #groups > 0 then renderTaskbar() end + end, + }, { + ui.row({ gap = 6, align = "center", justify = "center" }, content), + makeDots(group), + makeUnderline(group), + }) +end + +function renderTaskbar() + local items = {} + for _, group in ipairs(groups) do + table.insert(items, makeAppTile(group)) + end + + if #items == 0 then + table.insert(items, ui.glyph({ name = "apps", size = tonumber(cfg("icon_size")) or 24, opacity = 0.35 })) + end + + local root = ui.row({ + gap = tonumber(cfg("item_gap")) or 3, + align = "center", + justify = "center", + }, items) + barWidget.render(root) +end + +local function groupWindows(windows) + local pins = cfg("pinned_apps") or {} + local aliases = cfg("app_aliases") or {} + local byId = {} + local result = {} + + for index, appId in ipairs(pins) do + if tostring(appId) ~= "" then + local group = { id = tostring(appId), windows = {}, focused = false, pinnedIndex = index, firstWindowId = math.huge } + byId[lower(appId)] = group + table.insert(result, group) + end + end + + local outputName = barWidget.outputName() + local onlyThisOutput = cfg("current_output_only") == true and outputName ~= nil + for _, window in ipairs(windows) do + local appId = tostring(window.app_id or "") + local allowed = appId ~= "" + if allowed and onlyThisOutput then + allowed = workspaceOutputs[tonumber(window.workspace_id)] == outputName + end + if allowed then + local canonical = canonicalAppId(appId, pins, aliases) + local key = lower(canonical) + local group = byId[key] + if group == nil and cfg("show_unpinned_running") == true then + group = { id = canonical, windows = {}, focused = false, pinnedIndex = nil, firstWindowId = math.huge } + byId[key] = group + table.insert(result, group) + end + if group ~= nil then + table.insert(group.windows, window) + group.focused = group.focused or window.is_focused == true + group.firstWindowId = math.min(group.firstWindowId, tonumber(window.id) or math.huge) + end + end + end + + if cfg("hide_stopped_pins") == true then + local running = {} + for _, group in ipairs(result) do + if #group.windows > 0 then table.insert(running, group) end + end + result = running + end + + local order = tostring(cfg("order") or "pinned_first") + table.sort(result, function(a, b) + if order == "alphabetical" then return lower(appLabel(a.id)) < lower(appLabel(b.id)) end + if order == "running_first" then + local ar, br = #a.windows > 0, #b.windows > 0 + if ar ~= br then return ar end + end + if a.pinnedIndex ~= nil and b.pinnedIndex ~= nil then return a.pinnedIndex < b.pinnedIndex end + if a.pinnedIndex ~= nil then return true end + if b.pinnedIndex ~= nil then return false end + return a.firstWindowId < b.firstWindowId + end) + return result +end + +local function acceptWindows(result) + refreshInFlight = false + if result.exitCode ~= 0 then + if not warned then + noctalia.log("Could not query Niri windows: " .. tostring(result.stderr or "unknown error")) + warned = true + end + if #groups == 0 then + groups = groupWindows({}) + renderTaskbar() + end + return + end + local windows, err = noctalia.json.decode(result.stdout or "[]") + if windows == nil or type(windows) ~= "table" then + noctalia.log("Invalid JSON from 'niri msg -j windows': " .. tostring(err)) + return + end + warned = false + groups = groupWindows(windows) + renderTaskbar() +end + +local function queryWindows() + noctalia.runAsync("niri msg -j windows", acceptWindows) +end + +local function acceptWorkspaces(result) + workspaceOutputs = {} + if result.exitCode == 0 then + local workspaces = noctalia.json.decode(result.stdout or "[]") + if type(workspaces) == "table" then + for _, workspace in ipairs(workspaces) do + workspaceOutputs[tonumber(workspace.id)] = tostring(workspace.output or "") + end + end + end + queryWindows() +end + +local function refresh() + if refreshInFlight then return end + refreshInFlight = true + if cfg("current_output_only") == true then + if not noctalia.runAsync("niri msg -j workspaces", acceptWorkspaces) then + workspaceOutputs = {} + queryWindows() + end + elseif not noctalia.runAsync("niri msg -j windows", acceptWindows) then + refreshInFlight = false + end +end + +function update() + noctalia.setUpdateInterval(tonumber(cfg("poll_interval")) or 350) + updatePreviewLifecycle() + refresh() +end + +function onRightClick() + noctalia.openSettings() +end + +function onScroll(axis, steps, startsGesture) + if axis ~= "vertical" or not startsGesture or #groups == 0 then return end + local focusedIndex = nil + for index, group in ipairs(groups) do + if group.focused and #group.windows > 0 then focusedIndex = index break end + end + local direction = steps > 0 and 1 or -1 + local index = focusedIndex or (direction > 0 and 0 or 1) + for _ = 1, #groups do + index = ((index - 1 + direction) % #groups) + 1 + if #groups[index].windows > 0 then + focusWindow(groups[index].windows[1].id) + return + end + end +end + +function onIpc(event, payload) + if event == "refresh" then + refresh() + elseif event == "settings" then + noctalia.openSettings() + elseif event == "close-previews" then + closePreviewPanel() + elseif event == "preview" and payload ~= nil and tostring(payload) ~= "" then + showPreview(tostring(payload)) + else + noctalia.log("Unknown IPC event: " .. tostring(event)) + end +end diff --git a/flex-taskbar/thumbnail.webp b/flex-taskbar/thumbnail.webp new file mode 100644 index 00000000..ebd5dbd8 Binary files /dev/null and b/flex-taskbar/thumbnail.webp differ diff --git a/flex-taskbar/translations/en.json b/flex-taskbar/translations/en.json new file mode 100644 index 00000000..b10f2851 --- /dev/null +++ b/flex-taskbar/translations/en.json @@ -0,0 +1,192 @@ +{ + "settings": { + "pinned_apps": { + "label": "Pinned applications", + "description": "Desktop-file IDs to keep on the taskbar, in order. Example: firefox or org.gnome.Nautilus." + }, + "show_unpinned_running": { + "label": "Show unpinned running apps", + "description": "Automatically add applications that have windows but are not pinned." + }, + "hide_stopped_pins": { + "label": "Hide stopped pinned apps", + "description": "Only show a pinned launcher while it has at least one window." + }, + "app_aliases": { + "label": "Window app aliases", + "description": "Map a Niri window app_id to its pinned desktop-file ID." + }, + "launch_commands": { + "label": "Custom launch commands", + "description": "Map an application ID to a shell command. Apps without an override use gtk-launch." + }, + "icon_overrides": { + "label": "Icon overrides", + "description": "Map an application ID to an icon name or local image path." + }, + "label_overrides": { + "label": "Label overrides", + "description": "Map an application ID to the label shown when labels are enabled." + }, + "current_output_only": { + "label": "Current monitor only", + "description": "Only show windows on the monitor containing this bar. Pinned launchers remain visible." + }, + "order": { + "label": "Application order", + "pinned_first": "Pinned first", + "running_first": "Running first", + "alphabetical": "Alphabetical" + }, + "icon_size": { + "label": "Icon size" + }, + "icon_radius": { + "label": "Icon corner radius" + }, + "item_width": { + "label": "Minimum item width", + "description": "The minimum width of each application tile." + }, + "item_gap": { + "label": "Space between apps" + }, + "item_padding": { + "label": "Item padding" + }, + "item_radius": { + "label": "Item corner radius" + }, + "item_border_width": { + "label": "Item border width" + }, + "item_border_color": { + "label": "Item border color" + }, + "background_style": { + "label": "Item backgrounds", + "none": "None", + "running": "Running apps", + "active": "Focused app only", + "always": "Always" + }, + "running_background_color": { + "label": "Running background" + }, + "active_background_color": { + "label": "Focused background" + }, + "hover_background_color": { + "label": "Hover background" + }, + "active_opacity": { + "label": "Focused icon opacity" + }, + "inactive_opacity": { + "label": "Running icon opacity" + }, + "stopped_opacity": { + "label": "Stopped launcher opacity" + }, + "show_labels": { + "label": "Show app labels" + }, + "label_size": { + "label": "Label font size" + }, + "label_max_width": { + "label": "Maximum label width" + }, + "show_window_dots": { + "label": "Show one dot per window" + }, + "dot_size": { + "label": "Window dot size" + }, + "dot_gap": { + "label": "Space between dots" + }, + "max_dots": { + "label": "Maximum dots per app", + "description": "Limits indicator width for apps with many windows." + }, + "dot_color": { + "label": "Window dot color" + }, + "focused_dot_color": { + "label": "Focused window dot color" + }, + "show_active_underline": { + "label": "Show focused underline" + }, + "underline_width": { + "label": "Underline width" + }, + "underline_thickness": { + "label": "Underline thickness" + }, + "underline_radius": { + "label": "Underline corner radius" + }, + "underline_color": { + "label": "Underline color" + }, + "indicator_gap": { + "label": "Indicator spacing" + }, + "fallback_glyph": { + "label": "Missing-icon glyph" + }, + "poll_interval": { + "label": "Window refresh interval", + "description": "How often to query Niri, in milliseconds. Lower is more responsive but uses more resources." + }, + "preview_enabled": { + "label": "Window previews on hover", + "description": "Show clickable Niri window thumbnails below a running app when it is hovered." + }, + "preview_hover_delay": { + "label": "Preview hover delay", + "description": "Milliseconds to wait before opening a preview." + }, + "preview_hide_delay": { + "label": "Preview close delay", + "description": "Milliseconds allowed for moving the pointer from the taskbar into the preview." + }, + "preview_max_windows": { + "label": "Maximum preview windows", + "description": "Maximum number of window cards shown for one application." + }, + "preview_width": { + "label": "Thumbnail width" + }, + "preview_height": { + "label": "Thumbnail height" + }, + "preview_gap": { + "label": "Space between previews" + }, + "preview_radius": { + "label": "Preview corner radius" + }, + "preview_show_titles": { + "label": "Show window titles" + }, + "preview_title_size": { + "label": "Window title size" + }, + "preview_refresh_seconds": { + "label": "Thumbnail refresh interval", + "description": "Seconds a cached thumbnail remains fresh before Niri captures it again." + }, + "preview_card_color": { + "label": "Preview card color" + }, + "preview_border_color": { + "label": "Preview border color" + }, + "preview_border_width": { + "label": "Preview border width" + } + } +}