diff --git a/bin/omarchy-keyboard-layout b/bin/omarchy-keyboard-layout new file mode 100755 index 0000000000..4847048da8 --- /dev/null +++ b/bin/omarchy-keyboard-layout @@ -0,0 +1,265 @@ +#!/bin/bash + +# omarchy:summary=Get, switch, or configure keyboard layouts +# omarchy:group=keyboard +# omarchy:args=[status|available|next|set|add|remove|switcher] [value] +# omarchy:examples=omarchy keyboard layout add de + +set -e + +STATE_DIR="$HOME/.local/state/omarchy/settings" +STATE_FILE="$STATE_DIR/keyboard.json" +XKB_LIST="/usr/share/X11/xkb/rules/base.lst" + +usage() { + echo "Usage: omarchy-keyboard-layout [status|available|next|set |add |remove |switcher |open|bar-icon ]" >&2 + exit 1 +} + +# Looks up the human-readable label xkb ships for a given layout code. +# This is the same file base.lst that `localectl list-x11-keymap-layouts` reads. +layout_label() { + local code=$1 + awk -v code="$code" ' + /^! layout/ { insection=1; next } + /^!/ { insection=0 } + insection && $1 == code { + line = $0 + sub(/^[ \t]+/, "", line) + sub(/^[^ \t]+[ \t]+/, "", line) + print line + exit + } + ' "$XKB_LIST" 2>/dev/null || true +} + +# Every layout code + label xkb knows about, as a JSON array, sorted by label. +# This feeds the panel's "Add language" search list. +available_layouts_json() { + awk ' + /^! layout/ { insection=1; next } + /^!/ { insection=0 } + insection && NF && $1 != "custom" { + rest = $0 + sub(/^[ \t]+/, "", rest) + code = $1 + sub(/^[^ \t]+[ \t]+/, "", rest) + printf "%s\t%s\n", code, rest + } + ' "$XKB_LIST" 2>/dev/null | sort -t $'\t' -k2 | jq -R -s ' + split("\n") | map(select(length > 0) | split("\t") | {code: .[0], label: .[1]}) + ' 2>/dev/null || echo "[]" +} + +# Creates the state file on first run, seeded from the system's boot-time +# keyboard layout so a fresh install starts with something sensible instead +# of an empty list. Runs once; leaves an existing state file untouched. +ensure_state() { + mkdir -p "$STATE_DIR" + [[ -f $STATE_FILE ]] && return + + local primary label + primary=$(awk -F= '/^XKBLAYOUT/{gsub(/"/, "", $2); print $2}' /etc/vconsole.conf 2>/dev/null || true) + primary=${primary:-us} + label=$(layout_label "$primary") + + jq -n --arg code "$primary" --arg label "${label:-$primary}" \ + '{layouts: [{code: $code, label: $label}], switcher: "alt_shift"}' > "$STATE_FILE" +} + +# Picks the physical keyboard device to act on, ignoring virtual/software +# devices (input-method frameworks, on-screen keyboards, media buttons) +# that would otherwise be mistaken for a real keyboard. +main_keyboard() { + hyprctl devices -j 2>/dev/null | jq -r ' + [.keyboards[] | select(.name | test("virtual|fcitx|ibus|video-bus|buttons"; "i") | not)] as $real + | (($real[] | select(.main == true) | .name) // ($real[0].name // empty)) + ' 2>/dev/null || true +} + +# hyprctl reports the active layout as a human-readable description rather +# than the xkb code we store internally, so match it against the labels we +# already saved for the configured layouts rather than re-deriving a code +# from free text. +active_index() { + local device keymap + device=$(main_keyboard) + [[ -n $device ]] || { echo 0; return; } + + keymap=$(hyprctl devices -j 2>/dev/null | jq -r --arg dev "$device" \ + '.keyboards[] | select(.name == $dev) | .active_keymap // ""' 2>/dev/null || true) + + jq -r --arg keymap "$keymap" ' + ([.layouts[].label] | map(. as $l | (($keymap|ascii_downcase) | contains($l|ascii_downcase))) + | index(true)) // 0 + ' "$STATE_FILE" +} + +# The full state, plus which configured layout is currently active and +# whether the bar icon is visible -- the single source of truth the panel +# and CLI both read after every action. +status_json() { + local idx + idx=$(active_index) + jq --argjson idx "$idx" '. + {active: (.layouts[$idx].code // .layouts[0].code), show_bar_icon: (if .show_bar_icon == null then true else .show_bar_icon end)}' "$STATE_FILE" +} + +# Applies config changes (new layout, new switcher preset) without a full +# Hyprland restart. Failure is non-fatal -- the state file is already +# correct, the running session just won't reflect it until the next +# natural reload. +reload_hyprland() { + hyprctl reload >/dev/null 2>&1 || true +} + +# Applies a jq filter to the state file and writes the result back +# atomically (write to a temp file, then rename over the original) so a +# crash or a concurrent read never sees a half-written file. +write_state() { + local filter=$1; shift + local tmp + ( + flock -x 200 + tmp=$(mktemp) + jq "$@" "$filter" "$STATE_FILE" > "$tmp" + mv "$tmp" "$STATE_FILE" + ) 200>"$STATE_FILE.lock" +} + +# Feedback for bar-icon show/hide/toggle (not the plain `status` query), +# since there's no checkmark in the menu to reflect state instead. Uses the +# same on-screen-display helper as the other hardware toggles (touchpad, +# etc.) instead of notify-send, for a consistent look/timing. +notify_bar_icon() { + if [[ $1 == "true" ]]; then + omarchy-osd -i keyboard -m "Keyboard layout enabled" + else + omarchy-osd -i keyboard -m "Keyboard layout disabled" + fi +} + +# Entry point: dispatches to the requested action. Every branch ends by +# printing the current status_json so callers (the panel, the CLI, or a +# person in a terminal) always get a fresh, consistent snapshot back. +action=${1:-status} +ensure_state + +case "$action" in + status) + status_json + ;; + + available) + available_layouts_json + ;; + + next) + device=$(main_keyboard) + [[ -n $device ]] || { echo "No keyboard device found" >&2; exit 1; } + hyprctl switchxkblayout "$device" next >/dev/null + status_json + ;; + + set) + code=${2:-} + [[ -n $code ]] || usage + device=$(main_keyboard) + [[ -n $device ]] || { echo "No keyboard device found" >&2; exit 1; } + index=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE") + [[ -n $index ]] || { echo "Layout '$code' is not configured" >&2; exit 1; } + hyprctl switchxkblayout "$device" "$index" >/dev/null + status_json + ;; + + add) + code=${2:-} + [[ -n $code ]] || usage + already=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE") + if [[ -z $already ]]; then + label=$(layout_label "$code") + [[ -n $label ]] || { echo "Unknown layout '$code'" >&2; exit 1; } + write_state '.layouts += [{code: $code, label: $label}]' --arg code "$code" --arg label "$label" + reload_hyprland + fi + status_json + ;; + + remove) + code=${2:-} + [[ -n $code ]] || usage + exists=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE") + [[ -n $exists ]] || { echo "Layout '$code' is not configured" >&2; exit 1; } + count=$(jq '.layouts | length' "$STATE_FILE") + primary_code=$(jq -r '.layouts[0].code // empty' "$STATE_FILE") + if [[ $count -gt 1 && $code != "$primary_code" ]]; then + write_state '.layouts |= map(select(.code != $code))' --arg code "$code" + reload_hyprland + fi + status_json + ;; + + switcher) + preset=${2:-} + case "$preset" in + alt_shift|ctrl_shift|right_alt) ;; + *) usage ;; + esac + write_state '.switcher = $s' --arg s "$preset" + reload_hyprland + status_json + ;; + + # Meant to be called directly by the keyboard-shortcut keybinding, e.g.: + # o.bind("SUPER + CTRL + K", "Keyboard", "omarchy-keyboard-layout open") + # Deliberately a single plain subcommand with no shell operators (&&, ;) + # or quoting -- o.bind's dispatcher goes through hl.dsp.exec_cmd, which + # adds its own layer of escaping, and chained/quoted commands passed + # through that layer got mangled in practice. Checking show_bar_icon here + # keeps the visibility check entirely inside our own script instead. + open) + visible=$(jq -r 'if .show_bar_icon == null then true else .show_bar_icon end' "$STATE_FILE") + if [[ $visible == "true" ]]; then + omarchy-shell shell toggle omarchy.keyboard + fi + ;; + + # `status` is a plain exit-code query (0 = shown, 1 = hidden) meant for + # use as a `checked:` guard in omarchy-menu.jsonc (Trigger > Toggle > + # Keyboard Layout). toggle/show/hide are for the corresponding + # `action:`, or for manual use. The bar widget picks up the new value on + # its next `status` poll, so no Hyprland reload is needed here. A + # notification is fired on show/hide/toggle (not on the plain `status` + # query) since there's no longer a checkmark in the menu to reflect state. + bar-icon) + sub=${2:-status} + case "$sub" in + status) + visible=$(jq -r 'if .show_bar_icon == null then true else .show_bar_icon end' "$STATE_FILE") + [[ $visible == "true" ]] + ;; + toggle) + write_state '.show_bar_icon = ((if .show_bar_icon == null then true else .show_bar_icon end) | not)' + notify_bar_icon "$(jq -r '.show_bar_icon' "$STATE_FILE")" + status_json + ;; + show) + write_state '.show_bar_icon = true' + notify_bar_icon true + status_json + ;; + hide) + write_state '.show_bar_icon = false' + notify_bar_icon false + status_json + ;; + *) + usage + ;; + esac + ;; + + *) + usage + ;; +esac + diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 1bf194dc7e..4ba1eecff1 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -79,6 +79,7 @@ o.bind("SUPER + CTRL + D", "Display", "omarchy-shell shell toggle omarchy.monito o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock") o.bind("SUPER + CTRL + W", "Network", "omarchy-shell shell toggle omarchy.network") o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power") +o.bind("SUPER + CTRL + K", "Keyboard layout while enabled", "omarchy-keyboard-layout open") -- When the keyboard layout indicator is enabled in the bar o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" }) o.bind("SUPER + CTRL + Z", "Zoom in", function() diff --git a/default/hypr/input.lua b/default/hypr/input.lua index c3c7d43fae..305f613055 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -1,5 +1,8 @@ -- https://wiki.hypr.land/Configuring/Basics/Variables/#input +-- Reads the boot-time console/keyboard settings written by the installer, +-- used only as a fallback for a system that hasn't configured a layout +-- through the panel yet. local function read_vconsole() local values = {} local file = io.open("/etc/vconsole.conf", "r") @@ -21,32 +24,68 @@ local function read_vconsole() return values end --- Layouts that can't type Latin letters. Keep in sync with the list in --- etc/mkinitcpio.conf.d/omarchy_hooks.conf. -local non_latin_layouts = - " af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua " +-- The keyboard panel (bin/omarchy-keyboard-layout) owns this file: it's the +-- list of layouts the user picked via "Add language" plus their chosen +-- switch shortcut. jq is already a hard dependency of the Omarchy CLI +-- scripts, so shelling out to it here is simpler and less error-prone than +-- hand-rolling JSON parsing in Lua. +local STATE_FILE = os.getenv("HOME") .. "/.local/state/omarchy/settings/keyboard.json" -local vconsole = read_vconsole() +-- Runs a shell command and returns its trimmed stdout, or an empty string +-- if the command couldn't even be started. +local function popen_trim(cmd) + local handle = io.popen(cmd) + if not handle then + return "" + end + local result = handle:read("*a") or "" + handle:close() + return (result:gsub("^%s+", ""):gsub("%s+$", "")) +end -local kb_layout = vconsole.XKBLAYOUT or "us" -local kb_variant = vconsole.XKBVARIANT or "" -local kb_options = "compose:caps,shift:both_capslock" - --- Hyprland resolves keybindings against the first entry in kb_layout, not the --- layout that's currently active, so Omarchy's Latin-keysym bindings (SUPER + W --- and friends) only fire when a Latin layout leads. Installing with a non-Latin --- one would otherwise leave the desktop unusable. -if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then - kb_layout = "us," .. kb_layout - kb_variant = "," .. kb_variant - -- Reach the original layout with Left Alt + Right Alt. - kb_options = kb_options .. ",grp:alts_toggle" +-- Reads one field out of the keyboard state file via jq. Falls back +-- cleanly if the file is missing, unreadable, or the field isn't set -- +-- Hyprland should never fail to start just because this file is stale +-- or hasn't been created yet. +local function state_field(jq_filter, fallback) + local cmd = string.format("jq -r %s %q 2>/dev/null", jq_filter, STATE_FILE) + local value = popen_trim(cmd) + if value == "" or value == "null" then + return fallback + end + return value end +local vconsole = read_vconsole() + +local kb_layout = state_field("'[.layouts[].code] | join(\",\")'", vconsole.XKBLAYOUT or "us") +local switcher = state_field("'.switcher // \"alt_shift\"'", "alt_shift") + +-- The three switch-shortcut presets offered by the keyboard panel's pill +-- row, mapped to the matching xkb grp option. +local SWITCHER_OPTIONS = { + alt_shift = "grp:alt_shift_toggle", + ctrl_shift = "grp:ctrl_shift_toggle", + right_alt = "grp:toggle", +} + +local grp_option = SWITCHER_OPTIONS[switcher] or SWITCHER_OPTIONS.alt_shift + +-- Compose is bound to the Menu key, not Caps Lock. "compose:caps" plus +-- "shift:both_capslock" was tried here -- on real hardware it broke every +-- switcher preset outright (Alt+Shift, Ctrl+Shift, Right Alt, Right Shift +-- all stopped switching), not just single-Shift capitalization as originally +-- suspected. Whatever is going on, that combination isn't safe to ship +-- alongside grp switching, so it's reverted to the working baseline. +local kb_options = "compose:menu," .. grp_option + +-- Applies everything computed above as Hyprland's live input configuration. hl.config({ input = { kb_layout = kb_layout, - kb_variant = kb_variant, + -- Falls back to "" (xkb's default variant) if the system's vconsole.conf + -- never set one, or the user hasn't customized their keyboard variant. + kb_variant = vconsole.XKBVARIANT or "", kb_model = "", kb_options = kb_options, kb_rules = "", @@ -73,3 +112,4 @@ hl.config({ -- Scroll nicely in the terminal. o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) + diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 488f848921..742ca67d5e 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -85,6 +85,7 @@ "trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"}, "trigger.toggle.one-window-ratio": {"icon":"","label":"1-Window Ratio","action":"omarchy-hyprland-window-single-square-aspect-toggle"}, + // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"}, @@ -121,6 +122,7 @@ "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, + "setup.keyboard-layout-icon": {"icon":"󰌌","label":"Keyboard Layout","action":"omarchy-keyboard-layout bar-icon toggle"}, "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default.browser": {"icon":"","label":"Browser"}, diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 39f32e2fc9..4823d8a4fc 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -32,7 +32,7 @@ Item { // it; the progress bar's hard edge keeps the full gap. readonly property int messageGap: Math.round(root.gap * 2 / 3) readonly property int barWidth: Style.space(142) - readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : Style.space(190) + readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : (root.hasProgress ? Style.space(190) : Style.space(320)) // Nerd Font glyphs draw well outside their monospace cell, so the icon // column is measured by ink rather than by advance width. Progress OSDs pin diff --git a/shell/plugins/panels/keyboard/Model.js b/shell/plugins/panels/keyboard/Model.js new file mode 100644 index 0000000000..03c0f9254a --- /dev/null +++ b/shell/plugins/panels/keyboard/Model.js @@ -0,0 +1,180 @@ +// The four switch-shortcut presets shown as pills in the panel. Keep this in +// sync with SWITCHER_OPTIONS in config/hypr/input.lua. +var SWITCHERS = [ + { id: "alt_shift", label: "Alt+Shift" }, + { id: "ctrl_shift", label: "Ctrl+Shift" }, + { id: "right_alt", label: "Right Alt" } +] + +// xkb layout code -> ISO 639-1 language code. GNOME's input-source indicator +// shows the language a layout belongs to, not the xkb country/variant code, +// since several xkb codes can map to the same language and should read the +// same way in the bar. Codes not listed here fall back to the xkb code +// itself (still upper-cased) so unusual or rare layouts don't break. +var LANGUAGE_CODES = { + us: "en", gb: "en", au: "en", ca: "en", nz: "en", ie: "en", za: "en", + ir: "fa", de: "de", at: "de", ch: "de", + fr: "fr", be: "nl", nl: "nl", + es: "es", mx: "es", latam: "es", it: "it", pt: "pt", br: "pt", + ru: "ru", ua: "uk", by: "be", pl: "pl", cz: "cs", sk: "sk", hu: "hu", + ro: "ro", bg: "bg", rs: "sr", hr: "hr", si: "sl", ee: "et", lv: "lv", lt: "lt", + se: "sv", no: "no", dk: "da", fi: "fi", is: "is", + gr: "el", tr: "tr", il: "he", sa: "ar", ara: "ar", + in: "hi", pk: "ur", bd: "bn", th: "th", vn: "vi", + cn: "zh", tw: "zh", hk: "zh", jp: "ja", kr: "ko" +} + +// The code shown in the bar for a configured layout, e.g. "US" -> "EN". +function languageCode(code) { + var key = String(code || "").toLowerCase() + return (LANGUAGE_CODES[key] || key).toUpperCase() +} + +// Normalizes any array-like value (a real array, a QML JS list model, null, +// or undefined) into a plain JS array, so the rest of this file never has +// to special-case where the data came from. +function toArray(values) { + if (!values) return [] + if (Array.isArray(values)) return values.slice() + + var length = Number(values.length || 0) + if (!isFinite(length) || length <= 0) return [] + + var list = [] + for (var i = 0; i < length; i++) list.push(values[i]) + return list +} + +// Safe JSON.parse: empty input or a parse error returns the given fallback +// instead of throwing, so a CLI hiccup never crashes the panel. +function parseJson(text, fallback) { + var trimmed = String(text || "").trim() + if (trimmed === "") return fallback + try { + return JSON.parse(trimmed) + } catch (e) { + return fallback + } +} + +// `omarchy-keyboard-layout status` output -> { layouts, switcher, active, show_bar_icon } +function parseStatus(text) { + var parsed = parseJson(text, null) + if (!parsed || typeof parsed !== "object") { + return { layouts: [], switcher: "alt_shift", active: "", show_bar_icon: true } + } + + return { + layouts: Array.isArray(parsed.layouts) ? parsed.layouts : [], + switcher: parsed.switcher || "alt_shift", + active: parsed.active || "", + show_bar_icon: parsed.show_bar_icon !== false + } +} + +// `omarchy-keyboard-layout available` output -> array of {code, label} +function parseAvailable(text) { + var parsed = parseJson(text, []) + return Array.isArray(parsed) ? parsed : [] +} + +// Plain list of xkb codes already configured, used to exclude them from +// the "Add language" search results. +function configuredCodes(status) { + var codes = [] + var layouts = toArray(status && status.layouts) + for (var i = 0; i < layouts.length; i++) { + if (layouts[i] && layouts[i].code) codes.push(layouts[i].code) + } + return codes +} + +// Lower relevance rank = better match. Exact code match beats a label/code +// that merely starts with the query, which in turn beats a match buried +// elsewhere in the string. +function matchRank(item, needle) { + var label = String(item.label || "").toLowerCase() + var code = String(item.code || "").toLowerCase() + if (code === needle) return 0 + if (label.indexOf(needle) === 0) return 1 + if (code.indexOf(needle) === 0) return 2 + return 3 +} + +// Available languages not already configured, filtered by a free-text +// query against the label or xkb code (case-insensitive substring), and +// ranked so the closest match (exact code, then prefix match) rises to the +// top instead of relying on plain alphabetical order. +function filterAvailable(available, status, query) { + var configured = configuredCodes(status) + var needle = String(query || "").trim().toLowerCase() + + var list = toArray(available).filter(function(item) { + if (!item || !item.code) return false + if (configured.indexOf(item.code) !== -1) return false + if (needle === "") return true + return String(item.label || "").toLowerCase().indexOf(needle) !== -1 + || String(item.code || "").toLowerCase().indexOf(needle) !== -1 + }) + + if (needle === "") return list + + return list.sort(function(a, b) { + var ra = matchRank(a, needle) + var rb = matchRank(b, needle) + if (ra !== rb) return ra - rb + var la = String(a && a.label || "") + var lb = String(b && b.label || "") + var cmp = la.localeCompare(lb) + if (cmp !== 0) return cmp + return String(a && a.code || "").localeCompare(String(b && b.code || "")) + }) +} + +// The four presets, for the pill row -- a fresh copy each time so callers +// can't accidentally mutate the shared list. +function switcherPresets() { + return SWITCHERS.slice() +} + +// Display label for a preset id (falls back to the raw id if it's ever +// unrecognized, rather than showing nothing). +function switcherLabel(id) { + for (var i = 0; i < SWITCHERS.length; i++) { + if (SWITCHERS[i].id === id) return SWITCHERS[i].label + } + return id || "" +} + +// The configured layout at a given position, or null if the index is out +// of range (e.g. the list just got shorter after a remove). +function layoutAt(status, index) { + var layouts = toArray(status && status.layouts) + return index >= 0 && index < layouts.length ? layouts[index] : null +} + +// The layout currently in use. Falls back to the first configured layout +// if the reported active code doesn't match any of them (e.g. right after +// a fresh add, before the next status refresh confirms it). +function activeLayout(status) { + var layouts = toArray(status && status.layouts) + for (var i = 0; i < layouts.length; i++) { + if (layouts[i] && layouts[i].code === (status && status.active)) return layouts[i] + } + return layouts.length > 0 ? layouts[0] : null +} + +if (typeof module !== "undefined") { + module.exports = { + toArray: toArray, + parseStatus: parseStatus, + parseAvailable: parseAvailable, + configuredCodes: configuredCodes, + filterAvailable: filterAvailable, + switcherPresets: switcherPresets, + switcherLabel: switcherLabel, + layoutAt: layoutAt, + activeLayout: activeLayout, + languageCode: languageCode + } +} diff --git a/shell/plugins/panels/keyboard/Panel.qml b/shell/plugins/panels/keyboard/Panel.qml new file mode 100644 index 0000000000..defee04519 --- /dev/null +++ b/shell/plugins/panels/keyboard/Panel.qml @@ -0,0 +1,951 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.Ui +import qs.Commons +import "Model.js" as Model + +Panel { + id: root + moduleName: "omarchy.monitor" + ipcTarget: "omarchy.monitor" + manageIpc: false + + // manageIpc: false so this panel can own the single IpcHandler the target + // permits — needed for the brightness + state methods below. + property int brightnessPercent: 0 + property int pendingBrightnessPercent: 0 + property bool brightnessSetQueued: false + property bool brightnessAvailable: false + property string internalMonitor: "" + property string externalMonitor: "" + property string focusedMonitor: "" + property bool internalEnabled: false + property bool mirrorEnabled: false + property string monitorScale: "" + property var displays: [] + property int enabledDisplayCount: 0 + + // Raw wheel delta accumulated between discrete brightness steps. A mouse + // notch (delta = ±120) crosses the threshold and fires one step + // immediately; a touchpad's stream of small deltas piles up here until it + // crosses that same threshold, so both devices move brightness in + // identical 5% steps. Mirrors the audio panel's wheelAccumulator. + property real wheelAccumulator: 0 + + // Cursor model shared by keyboard and mouse. Sections: + // "brightness" - single slider row, selectedIndex = -1 sentinel + // (mirrors Audio's slider rows). Only present if a + // controllable backlight was detected. + // "scale" - 6 Button scale presets; treated as a single + // horizontal row from j/k's perspective. h/l moves + // between presets, identical to bluetooth's header. + // "monitors" - vertical display row list for enabling/disabling displays; + // j/k walks each row. + // Mouse hover on a target updates root state via the components' `hovered` + // signal so keyboard cursor and pointer share one highlight. + readonly property var scalePresets: ["1", "1.25", "1.6", "2", "3", "4"] + readonly property var scaleValues: { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.availableScales(scalePresets, display.width, display.height) + } + return scalePresets + } + property string focusSection: "scale" + property int selectedIndex: 0 + property bool cursorActive: false + + // Text size slider — curated macOS-style notches (px). The panel snaps to + // these stops; the CLI (omarchy-display-text-size) accepts any integer in range. + readonly property var textSizeStops: [9, 10, 11, 12, 14, 16, 20] + // While a change is in flight, the chosen stop index overrides the live + // base-size so the knob doesn't snap back during the file round-trip. -1 = + // no pending change; follow Style.font.baseSize. + property int textSizePreviewIndex: -1 + + // A text-size change reflows the whole panel (both font and spacing scale), + // which slides rows under a stationary pointer and fires synthetic hover. + // While true, hover is not allowed to hijack the keyboard focus section — + // otherwise h/l on the text-size slider can jump focus to another row. + property bool reflowingText: false + function markReflowing() { + root.reflowingText = true + reflowSettle.restart() + } + + readonly property var visibleSections: { + var list = [] + if (brightnessAvailable) list.push("brightness") + list.push("textsize") + list.push("scale") + if (displays.length > 1) list.push("monitors") + return list + } + + function sectionCount(section) { + if (section === "brightness") return 0 // only the slider sentinel at -1 + if (section === "textsize") return 0 // slider sentinel at -1, like brightness + if (section === "scale") return scaleValues.length + if (section === "monitors") return displays.length + return 0 + } + + function sectionIsSingleRow(section) { + // brightness and text size are lone sliders; scale presets sit horizontally. + return section === "brightness" || section === "textsize" || section === "scale" + } + + function sectionFirstIndex(section) { + if (section === "brightness" || section === "textsize") return -1 + return 0 + } + + function moveCursor(delta) { + var sections = visibleSections + if (!sections || sections.length === 0) return + var sIdx = sections.indexOf(focusSection) + if (sIdx < 0) { + focusSection = sections[0] + selectedIndex = sectionFirstIndex(focusSection) + return + } + var inSingleRow = sectionIsSingleRow(focusSection) + var max = inSingleRow ? 0 : sectionCount(focusSection) - 1 + + if (delta > 0) { + if (!inSingleRow && selectedIndex < max) { selectedIndex = selectedIndex + 1; return } + if (sIdx < sections.length - 1) { + focusSection = sections[sIdx + 1] + selectedIndex = sectionFirstIndex(focusSection) + } + } else { + if (!inSingleRow && selectedIndex > 0) { selectedIndex = selectedIndex - 1; return } + if (sIdx > 0) { + var prev = sections[sIdx - 1] + focusSection = prev + // Coming up from below — land on the last navigable row of the prev + // section, or its sentinel for single-row sections. + selectedIndex = sectionIsSingleRow(prev) ? sectionFirstIndex(prev) : sectionCount(prev) - 1 + } + } + } + + // h/l: in scale section, walks the preset row; everywhere else, no-op + // because adjustBrightness handles horizontal motion on the brightness + // slider. + function moveCursorH(delta) { + if (focusSection !== "scale") return + var next = selectedIndex + delta + if (next < 0) next = 0 + if (next > scaleValues.length - 1) next = scaleValues.length - 1 + selectedIndex = next + } + + function adjustBrightness(delta) { + if (focusSection !== "brightness") return + if (!brightnessAvailable) return + setBrightness(root.brightnessPercent + delta) + } + + function activateCursor() { + if (focusSection === "scale" && selectedIndex >= 0 && selectedIndex < scaleValues.length) { + setScale(scaleValues[selectedIndex]) + return + } + if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) { + var d = displays[selectedIndex] + if (d) toggleDisplay(d.name, d.enabled) + } + // brightness: no separate action; the slider value is the action. + } + + function clampCursor() { + var sections = visibleSections + if (!sections || !sections.length) return + if (sections.indexOf(focusSection) < 0) { + focusSection = sections[0] + selectedIndex = sectionFirstIndex(focusSection) + return + } + var count = sectionCount(focusSection) + if (sectionIsSingleRow(focusSection)) { + // brightness/text size use the -1 sentinel; scale clamps into the presets. + if (focusSection === "brightness" || focusSection === "textsize") selectedIndex = -1 + else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0 + return + } + if (count === 0) { + var sIdx = sections.indexOf(focusSection) + focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0] + selectedIndex = sectionFirstIndex(focusSection) + return + } + if (selectedIndex > count - 1) selectedIndex = count - 1 + if (selectedIndex < 0) selectedIndex = 0 + } + + // Keep the keyboard-focused row inside the viewport when the panel grows + // taller than its allotted height (lots of displays). Mirrors audio's + // ensureCursorVisible helper. + function ensureCursorVisible(item) { + if (!item || !scrollArea) return + var flick = scrollArea.contentItem + if (!flick || flick.contentY === undefined) return + var pt = item.mapToItem(flick.contentItem || flick, 0, 0) + var top = pt.y + var bottom = top + (item.height || 0) + var viewTop = flick.contentY + var viewBottom = viewTop + flick.height + var margin = 6 + if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin) + else if (bottom > viewBottom - margin) + flick.contentY = bottom + margin - flick.height + } + + function brightnessIpc(percent) { + var value = Number(percent) + root.setBrightness(value) + return "got " + root.pendingBrightnessPercent + } + + function stateIpc() { + return JSON.stringify({ + brightness: root.brightnessPercent, + brightnessAvailable: root.brightnessAvailable, + focusedMonitor: root.focusedMonitor, + scale: root.monitorScale, + displays: root.displays + }) + } + + IpcHandler { + target: "omarchy.monitor" + + function brightness(percent: string): string { return root.brightnessIpc(percent) } + function state(): string { return root.stateIpc() } + function open() { root.open() } + function close() { root.close() } + function toggle() { root.toggle() } + function show() { root.open() } + function hide() { root.close() } + } + + function refresh() { + if (!stateProc.running) stateProc.running = true + } + + function setBrightness(value) { + var percent = Math.max(5, Model.clampBrightness(value)) + root.brightnessPercent = percent + root.pendingBrightnessPercent = percent + + if (setBrightnessProc.running) { + root.brightnessSetQueued = true + return + } + + root.brightnessSetQueued = false + setBrightnessProc.command = ["bash", "-c", "omarchy-brightness-display --no-osd " + percent + "%"] + setBrightnessProc.running = true + } + + function previewBrightness(value) { + // Floor at 5%: never let the live drag preview show (or briefly send) + // a lower value while the slider is being dragged. + root.brightnessPercent = Math.max(5, Model.clampBrightness(value)) + brightnessDebounce.restart() + } + + // Mirrors what omarchy-brightness-display shows after a keyboard raise/ + // lower, so scrolling the bar icon gets the same OSD feedback instead of + // only the hardware brightness keys. "brightness" is the exact icon name + // the CLI itself passes to omarchy-osd. + function showBrightnessOsd() { + osdProc.command = ["omarchy-osd", "-i", "brightness", "-p", String(root.brightnessPercent)] + osdProc.running = true + } + + function normalizeScale(scale) { + return Model.normalizeScale(scale) + } + + function activeScaleIndex() { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height) + } + return -1 + } + + function effectiveScale(scale) { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.cleanScale(scale, display.width, display.height) + } + return normalizeScale(scale) + } + + // Playful mood-name for a given brightness percent. Bands intentionally + // span ~10–20 points so casual tweaks change the label, while small + // nudges within one band don't. + function brightnessName(percent) { + return Model.brightnessName(percent) + } + + function updateDisplays(displaysJson) { + var parsed = Model.parseDisplays(displaysJson) + root.displays = parsed.displays + root.enabledDisplayCount = parsed.enabledDisplayCount + } + + function toggleDisplay(name, enabled) { + if (!name) return + if (enabled && root.enabledDisplayCount <= 1) return + + actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")] + if (!actionProc.running) actionProc.running = true + } + + function setScale(scale) { + actionProc.command = ["bash", "-c", "omarchy-hyprland-monitor-scaling " + scale] + if (!actionProc.running) actionProc.running = true + } + + // ---- Text size (shell base font + GTK text-scaling, via one CLI) ---- + function nearestTextStop(px) { + var best = 0 + var bestDist = 1e9 + for (var i = 0; i < textSizeStops.length; i++) { + var d = Math.abs(textSizeStops[i] - px) + if (d < bestDist) { bestDist = d; best = i } + } + return best + } + + // Effective stop index: the pending choice while a change is in flight, + // otherwise whatever Style's live base-size rounds to. + function currentTextIndex() { + return textSizePreviewIndex >= 0 ? textSizePreviewIndex : nearestTextStop(Style.font.baseSize) + } + + // px shown in the header: the pending stop if any, else the true base-size + // (which may be an off-notch value set from the CLI). + function displayedTextPx() { + return textSizePreviewIndex >= 0 ? textSizeStops[textSizePreviewIndex] : Style.font.baseSize + } + + function setTextSize(px) { + textScaleProc.command = ["omarchy-display-text-size", String(px)] + if (!textScaleProc.running) textScaleProc.running = true + } + + function adjustTextSize(deltaSteps) { + var idx = currentTextIndex() + deltaSteps + if (idx < 0) idx = 0 + if (idx > textSizeStops.length - 1) idx = textSizeStops.length - 1 + markReflowing() + textSizePreviewIndex = idx + setTextSize(textSizeStops[idx]) + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Component.onCompleted: refresh() + + // KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC + // summons land with j/k ready to navigate. Keep a default landing point, + // but don't paint the cursor until hover or the first navigation key. + onOpenedChanged: { + if (opened) { + refresh() + if (brightnessAvailable) { + focusSection = "brightness" + selectedIndex = -1 + } else { + focusSection = "scale" + selectedIndex = 0 + } + cursorActive = false + } + } + + onBrightnessAvailableChanged: clampCursor() + onDisplaysChanged: clampCursor() + onScaleValuesChanged: clampCursor() + onVisibleSectionsChanged: clampCursor() + + // Only poll while the panel is open; the bar glyph tracks monitor count via + // Quickshell.screens, and open-time refresh + Component.onCompleted cover the + // rest. External brightness changes are reflected whenever the panel is open. + Timer { + interval: 5000 + running: root.opened + repeat: true + onTriggered: root.refresh() + } + + Process { + id: stateProc + command: ["omarchy-monitor-state"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var lines = String(text || "").split("\n") + var brightness = String(lines[0] || "").trim() + root.brightnessAvailable = brightness !== "unavailable" && brightness !== "" + root.brightnessPercent = root.brightnessAvailable ? Math.max(0, Math.min(100, parseInt(brightness, 10))) : 0 + root.internalMonitor = String(lines[1] || "").trim() + root.externalMonitor = String(lines[2] || "").trim() + root.internalEnabled = String(lines[3] || "").trim() !== "" + root.mirrorEnabled = String(lines[4] || "").trim() === root.externalMonitor && root.externalMonitor !== "" + root.focusedMonitor = String(lines[5] || "").trim() + root.monitorScale = root.normalizeScale(String(lines[6] || "").trim()) + root.updateDisplays(String(lines[7] || "[]").trim()) + } + } + } + + Timer { + id: brightnessDebounce + interval: 180 + repeat: false + onTriggered: root.setBrightness(root.brightnessPercent) + } + + Process { + id: setBrightnessProc + stdout: StdioCollector { waitForEnd: true } + // Do NOT call refresh() after a brightness set completes. The local + // brightnessPercent we just wrote is authoritative; re-reading via + // `omarchy-brightness-display` races the hardware/driver and can + // return an empty string, which the parser then coerces to 0 — + // visible as a "bounce to zero" after h/l keypresses. External + // brightness changes are still picked up by the 5s periodic refresh, + // the open-time refresh, and Component.onCompleted. + onRunningChanged: { + if (running) return + if (root.brightnessSetQueued) { + root.setBrightness(root.pendingBrightnessPercent) + } + } + } + + Process { + id: actionProc + stdout: StdioCollector { waitForEnd: true } + onRunningChanged: if (!running) root.refresh() + } + + Process { + id: osdProc + } + + // Applies text size via the CLI, which rewrites the shell override file; + // Style picks the new base-size up through its own file watch, so there's + // nothing to refresh here. + Process { + id: textScaleProc + stdout: StdioCollector { waitForEnd: true } + } + + // Clears the hover-suppression flag once the reflow triggered by a text-size + // change has settled. + Timer { + id: reflowSettle + interval: 300 + repeat: false + onTriggered: root.reflowingText = false + } + + // Once Style's base-size catches up to the pending choice, drop the preview + // so the slider tracks the live value again. The change itself reflows the + // panel, so suppress hover for a beat while it lands. + Connections { + target: Style + function onFontBaseSizeChanged() { + root.markReflowing() + if (root.textSizePreviewIndex >= 0 + && root.nearestTextStop(Style.font.baseSize) === root.textSizePreviewIndex) + root.textSizePreviewIndex = -1 + } + } + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: Quickshell.screens.length > 1 ? "󰍺" : "󰍹" + onPressed: function(b) { root.toggle() } + onWheelMoved: function(delta) { + if (!root.brightnessAvailable) return + // One step = 120 units, matching a single mouse notch, so a mouse + // click still moves exactly 5%. A touchpad's small deltas pile up in + // wheelAccumulator until they cross that same threshold, then fire + // the identical 5% step. The OSD is only pinged when a step actually + // lands (not on a fixed timer), so it stays open and updates exactly + // in step with the real scroll rhythm -- no tick separate from your + // finger's motion -- and only starts counting down to hide once you + // stop scrolling (each ping restarts its own hide timer). + var stepUnits = 120 + var stepSize = 5 + var stepped = false + root.wheelAccumulator += delta + while (root.wheelAccumulator >= stepUnits) { + root.setBrightness(root.brightnessPercent + stepSize) + root.wheelAccumulator -= stepUnits + stepped = true + } + while (root.wheelAccumulator <= -stepUnits) { + root.setBrightness(root.brightnessPercent - stepSize) + root.wheelAccumulator += stepUnits + stepped = true + } + if (stepped) root.showBrightnessOsd() + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } + if (dy !== 0) root.moveCursor(dy) + else if (dx !== 0) { + if (root.focusSection === "brightness") root.adjustBrightness(dx * 5) + else if (root.focusSection === "textsize") root.adjustTextSize(dx) + else if (root.focusSection === "scale") root.moveCursorH(dx) + } + } + onActivateRequested: if (root.cursorActive) root.activateCursor() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + + ScrollView { + id: scrollArea + anchors.fill: parent + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: panelColumn.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + Binding { + target: scrollArea.contentItem + property: "interactive" + value: panelColumn.implicitHeight > scrollArea.height + } + + Column { + id: panelColumn + width: scrollArea.availableWidth + spacing: Style.space(14) + + // ---------- Hero: display icon · title/status ---------- + Item { + width: parent.width + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) + + Text { + id: heroIcon + text: root.displays.length > 1 ? "󰍺" : "󰍹" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.display + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Column { + id: heroLabels + anchors.left: heroIcon.right + anchors.leftMargin: Style.space(14) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(2) + + Text { + text: "Display" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight + width: parent.width + } + + Text { + id: heroLabel + text: { + if (root.brightnessAvailable) { + return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() + } + return "FIXED BRIGHTNESS" + } + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.2 + elide: Text.ElideRight + width: parent.width + } + } + } + + // ---------- Brightness ---------- + PanelSeparator { + visible: root.brightnessAvailable + foreground: root.bar.foreground + } + + Column { + visible: root.brightnessAvailable + width: parent.width + spacing: Style.space(6) + + Item { + width: parent.width + implicitHeight: Math.max(brightnessHeader.implicitHeight, brightnessPercent.implicitHeight) + + PanelSectionHeader { + id: brightnessHeader + text: "BRIGHTNESS" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: brightnessPercent + text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } + } + + CursorSurface { + id: brightnessRow + width: parent.width + height: brightnessSlider.implicitHeight + Style.spacing.controlGap + hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) + foreground: root.bar.foreground + outline: true + + PanelSlider { + id: brightnessSlider + bar: root.bar + anchors.fill: parent + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + minimum: 5 + maximum: 100 + step: 1 + value: root.brightnessPercent + integer: true + onMoved: function(v) { root.previewBrightness(v) } + onReleased: function(v) { + brightnessDebounce.stop() + root.setBrightness(v) + } + } + + HoverHandler { + onHoveredChanged: if (hovered && !root.reflowingText) { + root.cursorActive = true + root.focusSection = "brightness" + root.selectedIndex = -1 + } + } + } + } + + // ---------- Text size ---------- + PanelSeparator { + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(6) + + Item { + width: parent.width + implicitHeight: Math.max(textSizeHeader.implicitHeight, textSizePx.implicitHeight) + + PanelSectionHeader { + id: textSizeHeader + text: "TEXT SIZE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: textSizePx + text: (textSizeSlider.dragging + ? root.textSizeStops[Math.round(textSizeSlider.liveValue)] + : root.displayedTextPx()) + "px" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } + } + + CursorSurface { + id: textSizeRow + width: parent.width + height: textSizeSlider.implicitHeight + Style.spacing.controlGap + hasCursor: root.cursorActive && root.focusSection === "textsize" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(textSizeRow) + foreground: root.bar.foreground + outline: true + + PanelSlider { + id: textSizeSlider + bar: root.bar + anchors.fill: parent + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + minimum: 0 + maximum: root.textSizeStops.length - 1 + step: 1 + integer: true + tickCount: root.textSizeStops.length + value: root.currentTextIndex() + onReleased: function(v) { root.setTextSize(root.textSizeStops[Math.round(v)]) } + } + + HoverHandler { + onHoveredChanged: if (hovered && !root.reflowingText) { + root.cursorActive = true + root.focusSection = "textsize" + root.selectedIndex = -1 + } + } + } + } + + // ---------- Scale ---------- + PanelSeparator { + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(10) + + Item { + width: parent.width + implicitHeight: Math.max(scaleHeader.implicitHeight, scaleMonitor.implicitHeight) + + PanelSectionHeader { + id: scaleHeader + text: "SCALE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + // Name the monitor SCALE targets, since it only applies to the + // focused one. + Text { + id: scaleMonitor + text: root.focusedMonitor + // Only worth naming when more than one display is in play. + visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } + } + + Grid { + id: scaleRow + width: parent.width + columns: root.scaleValues.length + spacing: Style.spacing.xs + + readonly property real cellWidth: root.scaleValues.length > 0 + ? (width - spacing * (columns - 1)) / columns + : 0 + + Repeater { + model: root.scaleValues + + ScalePill { + required property string modelData + required property int index + + scaleValue: modelData + scaleIndex: index + width: scaleRow.cellWidth + } + } + } + } + + // ---------- Monitors ---------- + PanelSeparator { + visible: root.displays.length > 1 + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(10) + visible: root.displays.length > 1 + + PanelSectionHeader { + text: "DISPLAYS" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + Repeater { + model: root.displays + + MonitorRow { + required property var modelData + required property int index + + width: panelColumn.width + display: modelData + rowIndex: index + } + } + } + + Item { + width: parent.width + height: Style.space(4) + } + } + } + } + } + + component ScalePill: Button { + id: pill + required property string scaleValue + required property int scaleIndex + + text: root.effectiveScale(scaleValue) + "x" + fontSize: Style.font.caption + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: Style.spacing.sm + verticalPadding: Style.spacing.controlPaddingY + bordered: true + + active: root.activeScaleIndex() === scaleIndex + hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex + + onClicked: root.setScale(scaleValue) + onHovered: function(isHovered) { + if (!isHovered || root.reflowingText) return + root.cursorActive = true + root.focusSection = "scale" + root.selectedIndex = pill.scaleIndex + } + } + + component MonitorRow: CursorSurface { + id: monitorRow + required property var display + required property int rowIndex + + readonly property bool isFocused: display && display.focused + readonly property bool canToggle: display && (!display.enabled || root.enabledDisplayCount > 1) + + hasCursor: root.cursorActive && root.focusSection === "monitors" && root.selectedIndex === rowIndex + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(monitorRow) + current: isFocused + foreground: root.bar.foreground + fill: Style.hoverFillFor(root.bar.foreground, Color.accent) + currentFill: Style.selectedFillFor(root.bar.foreground, Color.accent) + implicitHeight: monitorInner.implicitHeight + Style.spacing.xl + opacity: canToggle ? 1.0 : 0.45 + + Row { + id: monitorInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + text: "󰍹" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.title + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + width: parent.width - Style.space(22) - Style.space(14) - Style.space(16) + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: monitorRow.display.enabled ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.subtitle + width: Style.space(14) + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: monitorRow.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse && !root.reflowingText) { + root.cursorActive = true + root.focusSection = "monitors" + root.selectedIndex = monitorRow.rowIndex + } + onClicked: if (monitorRow.canToggle) root.toggleDisplay(monitorRow.display.name, monitorRow.display.enabled) + } + } +} diff --git a/shell/plugins/panels/keyboard/manifest.json b/shell/plugins/panels/keyboard/manifest.json new file mode 100644 index 0000000000..8276cc00cd --- /dev/null +++ b/shell/plugins/panels/keyboard/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "omarchy.keyboard", + "name": "Keyboard Layout", + "version": "1.0.0", + "author": "omarchy", + "description": "Active keyboard language with add/remove and switch-shortcut picker", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "Keyboard Layout", + "description": "Active keyboard language with add/remove and switch-shortcut picker", + "category": "System", + "allowMultiple": false + } +}