Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ for finer detail.

## Unreleased

### Added
- QS Bar adds per-monitor workspace isolation (`isolateMonitors`), allowing
multi-monitor setups to show only the workspaces assigned to each screen on its
respective bar. The inactive screen dims its active workspace indicator to 72%
opacity to convey keyboard focus, and the right-click WorkspacePanel popup also
filters by screen. Accessible via `shell.json` (`qsbar.isolateMonitors`),
`ryoku-shell bar set workspaces isolateMonitors on/off`, and QS Bar Settings >
Identity > Workspaces (#767).

### Removed
- The mango compositor is gone: its provider, config tree, packages,
installer choice, deploy steps, tests and docs. Ryoku ships Hyprland and
Expand Down
2 changes: 2 additions & 0 deletions ryoku/i18n/catalog/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3465,6 +3465,7 @@
"EXTRACTING": "EXTRACTING",
"Each add-on defines its own; applied live": "Each add-on defines its own; applied live",
"Each app's level under the sound card.": "Each app's level under the sound card.",
"Each bar shows only the workspaces on its own screen.": "Each bar shows only the workspaces on its own screen.",
"Each combo your config wants that Ryoku already uses. Keep Ryoku's shortcut, use yours (Ryoku unbinds its own so yours actually wins), or remap yours to a free chord. Untouched rows keep Ryoku's.": "Each combo your config wants that Ryoku already uses. Keep Ryoku's shortcut, use yours (Ryoku unbinds its own so yours actually wins), or remap yours to a free chord. Untouched rows keep Ryoku's.",
"Each device keeps its own bindings; unplug it and nothing else changes.": "Each device keeps its own bindings; unplug it and nothing else changes.",
"Each display keeps its own placement and audio settings.": "Each display keeps its own placement and audio settings.",
Expand Down Expand Up @@ -7288,6 +7289,7 @@
"Per surface": "Per surface",
"Per-animation control, for when a feel isn't enough. Each row is one desktop animation: turn it off, change its speed, its curve, or its style.": "Per-animation control, for when a feel isn't enough. Each row is one desktop animation: turn it off, change its speed, its curve, or its style.",
"Per-app look on top of the global Appearance": "Per-app look on top of the global Appearance",
"Per-monitor": "Per-monitor",
"Per-widget colour": "Per-widget colour",
"Percentage from the system battery. Unknown battery state fails closed.": "Percentage from the system battery. Unknown battery state fails closed.",
"Percentage meaning": "Percentage meaning",
Expand Down
27 changes: 25 additions & 2 deletions ryoku/shell/ipc/bar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ const catalogueFixture = `[
{ "key": "launcherLogoIcon", "type": "choice", "label": "Glyph", "options": ["ryoku", "arch", "dragon"] } ] },
{ "id": "workspaces", "gid": "G2", "label": "Workspaces", "gloss": "間", "category": "Bar",
"settings": [
{ "key": "workspaceMode", "type": "choice", "label": "Shown", "options": ["active", "5", "10"] },
{ "key": "workspaceStyle", "type": "choice", "label": "Marker", "options": ["default", "numbers", "magic", "kanji", "rings", "aurora", "pacman"] } ] },
{ "key": "workspaceMode", "type": "choice", "label": "Shown", "options": ["active", "5", "10"] },
{ "key": "workspaceStyle", "type": "choice", "label": "Marker", "options": ["default", "numbers", "magic", "kanji", "rings", "aurora", "pacman"] },
{ "key": "isolateMonitors", "type": "toggle", "label": "Isolate monitors" } ] },
{ "id": "status", "gid": "G3", "label": "Status", "visKey": "status", "desc": "Arch update, tray, notifications" },
{ "id": "memory", "gid": "G4", "label": "Memory", "visKey": "memory" },
{ "id": "cpu", "gid": "G5", "label": "CPU", "visKey": "cpu" },
Expand Down Expand Up @@ -447,6 +448,28 @@ func TestBarVerbsThroughStore(t *testing.T) {
}
}

// TestBarWorkspacesIsolateMonitors verifies that the isolateMonitors toggle
// is accepted by the bar-set verb and round-trips through the settings store.
func TestBarWorkspacesIsolateMonitors(t *testing.T) {
Comment on lines +451 to +453

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor · Tests · fix: medium effort

The new test only checks persistence through the CLI, leaving the workspace filtering and per-monitor active marker untested. Add coverage for multiple outputs, including uncreated fixed-mode workspaces and focus changes.

d := barTestDaemon(t)

// Turn the toggle on.
if got := d.dispatch("bar set workspaces isolateMonitors on"); got != "ok" {
t.Fatalf("bar set isolateMonitors on: %s", got)
}
if qs, _ := d.frame(t)["qsbar"].(map[string]any); qs["isolateMonitors"] != true {
t.Fatalf("qsbar.isolateMonitors = %v, want true", qs["isolateMonitors"])
}

// Turn it back off.
if got := d.dispatch("bar set workspaces isolateMonitors off"); got != "ok" {
t.Fatalf("bar set isolateMonitors off: %s", got)
}
if qs, _ := d.frame(t)["qsbar"].(map[string]any); qs["isolateMonitors"] != false {
t.Fatalf("qsbar.isolateMonitors = %v, want false", qs["isolateMonitors"])
}
}

// TestDockVerbsThroughStore drives the dock verbs and checks the top-level dock
// object round-trips, keeping the shipped fields the CLI does not touch.
func TestDockVerbsThroughStore(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ PanelWindow {
id: compWorkspace
WorkspaceWidget {
root: barSlot.root
screenName: barSlot.screenName
readonly property real barContentLeftInset: 0
readonly property real barContentRightInset: 0
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2342,6 +2342,8 @@ Item {
// ── workspace display style (orthogonal to mode; persisted) ──
// "rings" is the stable cache token for the user-facing Frame style.
property string workspaceStyle: "default" // default, numbers, magic, kanji, rings, aurora
// Workspace monitor isolation (persisted)
property bool isolateMonitors: false
// The marker styles this variant offers (continuous V2 adds Kanji, Frame,
// Aurora; "rings" is the persisted cache token for the Frame style).
readonly property var workspaceStyleOptions: [
Expand Down Expand Up @@ -2713,6 +2715,7 @@ Item {
onLauncherLogoIconChanged: if (_widgetsLoaded) saveWidgets()
onWeatherImperialChanged: if (_widgetsLoaded) saveWidgets()
onWorkspaceStyleChanged: if (_widgetsLoaded) saveWidgets()
onIsolateMonitorsChanged: if (_widgetsLoaded) persistWidgetsToConfig()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor · Maintainability · fix: low effort

The latest commit subject, Update Theme.qml, does not follow the required [area] scope: summary format. Rename it before merging.

💡 Related suggestion (possible bug)

Save isolation through the widget persistence path

Suggested change

Suggested change
onIsolateMonitorsChanged: if (_widgetsLoaded) persistWidgetsToConfig()
onLauncherLogoIconChanged: if (_widgetsLoaded) saveWidgets()
onWeatherImperialChanged: if (_widgetsLoaded) saveWidgets()
onWorkspaceStyleChanged: if (_widgetsLoaded) saveWidgets()
onIsolateMonitorsChanged: if (_widgetsLoaded) saveWidgets()
onBarPositionChanged: if (_widgetsLoaded) saveWidgets()
onBarScaleChanged: if (_widgetsLoaded) saveWidgets()
onBarShellStyleChanged: if (_widgetsLoaded) saveWidgets()

Reply here to discuss, say it's fixed, or explain why it's intended — no mention needed.

onBarPositionChanged: if (_widgetsLoaded) saveWidgets()
onBarScaleChanged: if (_widgetsLoaded) saveWidgets()
onBarShellStyleChanged: if (_widgetsLoaded) saveWidgets()
Expand Down Expand Up @@ -3010,6 +3013,7 @@ Item {
q.barPosition = barPosition
q.workspaceMode = workspaceMode
q.workspaceStyle = workspaceStyle
q.isolateMonitors = isolateMonitors
q.pickerStyle = pickerStyle
q.launcherLogoMode = launcherLogoMode
q.aiTools = serializeAiTools()
Expand Down Expand Up @@ -3079,6 +3083,7 @@ Item {
if (q.barPosition === "top" || q.barPosition === "bottom") barPosition = q.barPosition
if (q.workspaceMode !== undefined) workspaceMode = q.workspaceMode
if (q.workspaceStyle !== undefined) workspaceStyle = q.workspaceStyle
if (q.isolateMonitors !== undefined) isolateMonitors = q.isolateMonitors === true
if (q.pickerStyle !== undefined) pickerStyle = q.pickerStyle
if (q.launcherLogoMode !== undefined) launcherLogoMode = q.launcherLogoMode
if (q.aiTools !== undefined) parseAiTools(q.aiTools)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,37 @@ Item {
}


// ── PREVIEW ──
// ── workspaces: ISOLATE ──
Comment thread
neur0map marked this conversation as resolved.
Resolved
Entrance {
width: page.colW
index: 4
SettingCard {
width: page.colW
title: I18n.tr("ISOLATE")
kana: "\u5206"

SettingRow {
anchors.left: parent.left
anchors.right: parent.right
label: I18n.tr("Per-monitor")
desc: I18n.tr("Each bar shows only the workspaces on its own screen.")
Comment on lines +313 to +320

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor · Data Integrity · fix: medium effort

The new translation keys are not added to the source and English catalogs in this diff. The new settings card therefore lacks catalog entries for its title, label, and description, breaking translated-UI integrity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added missing English catalog entries for "Per-monitor" and "Each bar shows only the workspaces on its own screen." in ryoku/i18n/catalog/en.json.

source: "shell.json"
controlWidth: Math.max(120, 62 * 2)
Seg {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
options: [I18n.tr("Off"), I18n.tr("On")]
current: page.root && page.root.isolateMonitors ? I18n.tr("On") : I18n.tr("Off")
onChose: (key) => { if (page.root) page.root.isolateMonitors = (key === I18n.tr("On")) }
}
}
}
}

// ── PREVIEW ──
Entrance {
width: page.colW
index: 5
SettingCard {
width: page.colW
title: I18n.tr("PREVIEW")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
{ "id": "launcher", "gid": "G1", "label": "Launcher", "gloss": "起動", "category": "Bar",
"desc": "The mark that opens the launcher and this panel. Choose it in Identity." },
{ "id": "workspaces", "gid": "G2", "label": "Workspaces", "gloss": "間", "category": "Bar",
"desc": "The workspace row. Its count and marker are set in Identity." },
"desc": "The workspace row. Its count and marker are set in Identity.",
"settings": [
{ "key": "workspaceMode", "type": "choice", "label": "Shown", "options": ["active", "5", "10"] },
{ "key": "workspaceStyle", "type": "choice", "label": "Marker", "options": ["default", "numbers", "magic", "kanji", "rings", "aurora", "pacman"] },
{ "key": "isolateMonitors", "type": "toggle", "label": "Isolate monitors" } ] },
{ "id": "status", "gid": "G3", "label": "Status", "visKey": "status", "desc": "Arch update, tray, notifications" },
{ "id": "memory", "gid": "G4", "label": "Memory", "visKey": "memory" },
{ "id": "cpu", "gid": "G5", "label": "CPU", "visKey": "cpu" },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.pragma library

function activeWorkspace(workspaces, screenName, isolateMonitors, focusedWorkspace) {
if (!isolateMonitors || !screenName) return focusedWorkspace
for (var i = 0; i < workspaces.length; i++) {
var workspace = workspaces[i]
if (!workspace.special && workspace.active && workspace.output === screenName)
return workspace
}
return null
}

function needsWorkspaceData(mode, dynamicModel, isolateMonitors) {
return dynamicModel || mode === "active" || isolateMonitors
}

function workspaceList(workspaces, mode, dynamicModel, screenName, isolateMonitors, active, extraWorkspace) {
if (dynamicModel) {
var live = []
for (var i = 0; i < workspaces.length; i++) {
var workspace = workspaces[i]
if (workspace.special) continue
if (isolateMonitors && screenName && workspace.output && workspace.output !== screenName)
continue
live.push(workspace.name)
}
return live
}

if (mode === "active") {
var ids = {}
for (var j = 0; j < workspaces.length; j++) {
var current = workspaces[j]
if (current.special) continue
if (isolateMonitors && screenName && current.output && current.output !== screenName)
continue
ids[current.name] = true
}
if (active && !active.special
&& (!isolateMonitors || !screenName || !active.output || active.output === screenName))
ids[active.name] = true
return Object.keys(ids).sort(function(a, b) { return Number(a) - Number(b) })
}

var count = mode === "5" ? 5 : 10
var fixed = []
for (var k = 1; k <= count; k++) {
var name = String(k)
var existing = workspaces.find(function(workspace) { return workspace.name === name })
if (isolateMonitors && screenName && existing && existing.output && existing.output !== screenName)
continue
fixed.push(name)
}
if (extraWorkspace) fixed.push(extraWorkspace)
return fixed
}

function extraWorkspace(focused, mode) {
var count = mode === "5" ? 5 : 10
var number = focused ? Number(focused.name) : 0
return number > count ? focused.name : ""
}

function hasMonitorFocus(isolateMonitors, screenName, focusedOutput) {
return !isolateMonitors || !screenName || !focusedOutput || focusedOutput === screenName
}

function markerOpacity(hasFocus) {
return hasFocus ? 1.0 : 0.72
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";

const source = await readFile(new URL("./WorkspaceIsolation.js", import.meta.url), "utf8");
const module = { exports: {} };
vm.runInNewContext(source.replace(/^\.pragma library\s*/, "") +
"\nmodule.exports = { activeWorkspace, needsWorkspaceData, workspaceList, extraWorkspace, hasMonitorFocus, markerOpacity };",
{ module, Number, Object });
const { activeWorkspace, needsWorkspaceData, workspaceList, extraWorkspace, hasMonitorFocus, markerOpacity } = module.exports;

const workspaces = [
{ name: "1", output: "eDP-1", active: true, special: false },
{ name: "2", output: "DP-1", active: false, special: false },
{ name: "12", output: "DP-1", active: true, special: false },
{ name: "special:1", output: "eDP-1", active: false, special: true }
];

assert.equal(activeWorkspace(workspaces, "eDP-1", true, workspaces[2]), workspaces[0],
"each output selects its own active workspace");
assert.equal(activeWorkspace(workspaces, "DP-1", true, workspaces[0]), workspaces[2],
"the other output selects its own active workspace");
assert.equal(activeWorkspace(workspaces, "eDP-1", false, workspaces[2]), workspaces[2],
"disabling isolation keeps the globally focused workspace");

assert.equal(needsWorkspaceData("5", false, false), false,
"fixed mode without isolation does not track live workspace data");
assert.equal(needsWorkspaceData("5", false, true), true,
"fixed mode needs live workspace data to filter monitor assignments");
assert.equal(needsWorkspaceData("active", false, false), true,
"active mode needs live workspace data");
assert.equal(needsWorkspaceData("5", true, false), true,
"dynamic mode needs live workspace data");

assert.deepEqual(Array.from(workspaceList(workspaces, "5", false, "eDP-1", true, null, "")),
["1", "3", "4", "5"], "fixed mode filters assigned workspaces and keeps uncreated slots");
assert.deepEqual(Array.from(workspaceList(workspaces, "5", false, "DP-1", true, null, "12")),
["2", "3", "4", "5", "12"], "fixed mode includes the local active workspace beyond its range");
assert.equal(extraWorkspace({ name: "1" }, "5"), "");
assert.equal(extraWorkspace({ name: "3" }, "5"), "",
"in-range focus changes keep the extra workspace binding unchanged");
assert.equal(extraWorkspace({ name: "12" }, "5"), "12",
"fixed mode adds a workspace beyond its numbered range");

let focusedOutput = "DP-1";
assert.equal(markerOpacity(hasMonitorFocus(true, "eDP-1", focusedOutput)), 0.72,
"an active marker dims when another output has focus");
focusedOutput = "eDP-1";
assert.equal(markerOpacity(hasMonitorFocus(true, "eDP-1", focusedOutput)), 1.0,
"the active marker brightens when focus moves to its output");
Loading
Loading