From 5835ad1450c56c01a74d62c514d2fe6b21bd169b Mon Sep 17 00:00:00 2001 From: Sudipta Goswami <56980003+sudiptacmd@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:28:17 +0600 Subject: [PATCH 1/2] Add Flex Taskbar plugin --- flex-taskbar/README.md | 138 +++++++ flex-taskbar/plugin.toml | 473 ++++++++++++++++++++++++ flex-taskbar/previews.luau | 127 +++++++ flex-taskbar/taskbar.luau | 581 ++++++++++++++++++++++++++++++ flex-taskbar/thumbnail.webp | Bin 0 -> 21834 bytes flex-taskbar/translations/en.json | 192 ++++++++++ 6 files changed, 1511 insertions(+) create mode 100644 flex-taskbar/README.md create mode 100644 flex-taskbar/plugin.toml create mode 100644 flex-taskbar/previews.luau create mode 100644 flex-taskbar/taskbar.luau create mode 100644 flex-taskbar/thumbnail.webp create mode 100644 flex-taskbar/translations/en.json diff --git a/flex-taskbar/README.md b/flex-taskbar/README.md new file mode 100644 index 00000000..13678e42 --- /dev/null +++ b/flex-taskbar/README.md @@ -0,0 +1,138 @@ +# 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` | + +## 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/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 0000000000000000000000000000000000000000..ebd5dbd8b617c6f88999fc0212c0586000235dd8 GIT binary patch literal 21834 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 JjZ7i{KmcQ0p>Y5J literal 0 HcmV?d00001 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" + } + } +} From a345b07949fd4e43e96dd49f228bab4a7718f1ab Mon Sep 17 00:00:00 2001 From: Sudipta Goswami <56980003+sudiptacmd@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:58:24 +0600 Subject: [PATCH 2/2] Document Flex Taskbar screenshot --- flex-taskbar/README.md | 4 ++++ flex-taskbar/screenshot.webp | Bin 0 -> 4540 bytes 2 files changed, 4 insertions(+) create mode 100644 flex-taskbar/screenshot.webp diff --git a/flex-taskbar/README.md b/flex-taskbar/README.md index 13678e42..d82f56cf 100644 --- a/flex-taskbar/README.md +++ b/flex-taskbar/README.md @@ -9,6 +9,10 @@ Flex Taskbar is a configurable Niri taskbar for the Noctalia bar. It groups wind | 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. diff --git a/flex-taskbar/screenshot.webp b/flex-taskbar/screenshot.webp new file mode 100644 index 0000000000000000000000000000000000000000..0ee00cac5ce4c501805a2c9d6ce6428d83b8e2f0 GIT binary patch literal 4540 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 aQCoDb3=myofR04~7an3z4v#)<6aWBw#3efb literal 0 HcmV?d00001