diff --git a/niri-displays/README.md b/niri-displays/README.md index 569075dd..cd40980c 100644 --- a/niri-displays/README.md +++ b/niri-displays/README.md @@ -1,6 +1,6 @@ # Niri Displays -Inspect connected outputs and make temporary display changes directly from Noctalia. The plugin uses Niri's IPC to expose resolution, refresh-rate, scale, and focused-output state. +Inspect connected outputs and make temporary display changes directly from Noctalia. The plugin uses Niri's IPC to expose resolution, refresh-rate, scale, rotation (transform), position, and focused-output state. ## Plugin @@ -19,7 +19,7 @@ Inspect connected outputs and make temporary display changes directly from Nocta Enable `raycursive/niri-displays` in **Settings → Plugins**, then add the `bar` entry from **Settings → Bar → Widgets**. The `displays` service starts automatically, watches Noctalia's output-change hook, and uses slow polling as a recovery fallback. -Click the bar widget to open the `panel` entry. Each connected output shows its connector, make/model, focused state, current mode, refresh rate, and scale. Select a mode or refresh rate reported by Niri, or drag the scale slider, to apply a temporary change. +Click the bar widget to open the `panel` entry. Each connected output shows its connector, make/model, focused state, current mode, refresh rate, scale, rotation, and position. Select a mode, refresh rate, or rotation reported by Niri, adjust the position, or drag the scale slider, to apply a temporary change. Open the panel without the bar widget with: @@ -69,7 +69,7 @@ noctalia msg plugin raycursive/niri-displays:displays all dump ## Notes -- Discovery spawns `niri msg --json outputs` and `niri msg --json focused-output`. Changes spawn `niri msg output mode ...` or `niri msg output scale ...`. +- Discovery spawns `niri msg --json outputs` and `niri msg --json focused-output`. Changes spawn `niri msg output mode ...`, `niri msg output scale ...`, `niri msg output transform ...`, or `niri msg output position ...`. - Niri intentionally does not persist these IPC changes to `config.kdl`. A Niri restart or a later configuration reload can restore configured values; keep permanent defaults in your Niri configuration. - A matching scale preset can change output scale automatically when the service starts, reloads, detects a resolution change, or receives a settings change. - The plugin does not write files or make network requests. Command errors are shown without discarding the last successful in-memory output snapshot. diff --git a/niri-displays/panel.luau b/niri-displays/panel.luau index d5e9b997..6d50ba3e 100644 --- a/niri-displays/panel.luau +++ b/niri-displays/panel.luau @@ -5,6 +5,16 @@ local MAX_DISPLAYS = 12 local COMMAND_TIMEOUT_MS = 8000 local SCALE_MARKS = { 0.5, 1, 1.5, 2, 2.5, 3 } +local TRANSFORM_OPTIONS = { + { value = "normal", text = "Normal" }, + { value = "90", text = "90°" }, + { value = "180", text = "180°" }, + { value = "270", text = "270°" }, + { value = "flipped", text = "Flipped" }, + { value = "flipped-90", text = "Flipped 90°" }, + { value = "flipped-180", text = "Flipped 180°" }, + { value = "flipped-270", text = "Flipped 270°" }, +} local CENTERED_LAYOUT = { flexGrow = 1, align = "center", justify = "center", gap = 10, padding = 20 } local snapshot = noctalia.state.get("display_snapshot") or { @@ -18,6 +28,11 @@ local actionPending = false local actionError = "" local scaleDraft = {} local scaleEditing = {} +local positionDraft = {} +local layoutExpanded = false +local selectedPort = nil +local layoutDirection = nil +local layoutOffset = 0 local render @@ -35,11 +50,18 @@ local function syncFromState() if scaleDraft[display.port] == nil or (not actionPending and not scaleEditing[display.port]) then scaleDraft[display.port] = display.scale end + if positionDraft[display.port] == nil then + positionDraft[display.port] = { + x = display.x, + y = display.y, + } + end end for port, _ in pairs(scaleDraft) do if presentPorts[port] ~= true then scaleDraft[port] = nil scaleEditing[port] = nil + positionDraft[port] = nil end end end @@ -104,6 +126,15 @@ local function selectedRefreshIndex(display, choices) return 0 end +local function selectedTransformIndex(display) + for index, choice in ipairs(TRANSFORM_OPTIONS) do + if choice.value == display.transform then + return index - 1 + end + end + return 0 +end + local function optionsFromChoices(choices) local options = {} for _, choice in ipairs(choices) do @@ -219,6 +250,16 @@ local function changeRefresh(display, choices, index) applyMode(display, display.width, display.height, choice.refresh_rate, nil) end +local function changeTransform(display, index) + local choice = TRANSFORM_OPTIONS[index + 1] + if choice == nil then + return + end + local command = "niri msg output " .. quoteShellArgument(display.port) + .. " transform " .. quoteShellArgument(choice.value) + runNiri(command) +end + local function changeScaleDraft(display, value) scaleEditing[display.port] = true scaleDraft[display.port] = value @@ -233,13 +274,420 @@ local function commitScale(display) runNiri(command) end +local displayLogicalSize +local field + +local function logicalArea(display) + local size = displayLogicalSize(display) + return size.width * size.height +end + +local function defaultSelectedPort(displays) + if selectedPort ~= nil then + for _, display in ipairs(displays) do + if display.port == selectedPort and display.enabled then + return selectedPort + end + end + end + local best = nil + for _, display in ipairs(displays) do + if display.enabled and display.width > 0 and display.height > 0 then + if best == nil or logicalArea(display) < logicalArea(best) then + best = display + end + end + end + selectedPort = best and best.port or nil + return selectedPort +end + +local function referenceDisplay(displays, port) + local best = nil + for _, display in ipairs(displays) do + if display.enabled and display.port ~= port and display.width > 0 and display.height > 0 then + if best == nil or logicalArea(display) > logicalArea(best) then + best = display + end + end + end + return best +end + +local function snapPosition(display, ref, direction) + local size = displayLogicalSize(display) + local refSize = displayLogicalSize(ref) + if direction == "right" then + return ref.x + refSize.width, ref.y + (refSize.height - size.height) / 2 + elseif direction == "left" then + return ref.x - size.width, ref.y + (refSize.height - size.height) / 2 + elseif direction == "top" then + return ref.x + (refSize.width - size.width) / 2, ref.y - size.height + elseif direction == "bottom" then + return ref.x + (refSize.width - size.width) / 2, ref.y + refSize.height + end + return ref.x, ref.y +end + +local function setDirection(display, ref, direction) + layoutDirection = direction + layoutOffset = 0 + local x, y = snapPosition(display, ref, direction) + positionDraft[display.port] = { x = math.floor(x + 0.5), y = math.floor(y + 0.5) } + render() +end + +local function changeLayoutOffset(display, ref, value) + layoutOffset = value + local direction = layoutDirection or "right" + local x, y = snapPosition(display, ref, direction) + if direction == "right" or direction == "left" then + y = y + value + else + x = x + value + end + positionDraft[display.port] = { x = math.floor(x + 0.5), y = math.floor(y + 0.5) } + render() +end + +local function commitPosition(display) + local draft = positionDraft[display.port] + if draft == nil then + return + end + local command = "niri msg output " .. quoteShellArgument(display.port) + .. " position set -- " .. tostring(draft.x) .. " " .. tostring(draft.y) + runNiri(command) +end + +local MAP_WIDTH = 320 +local MAP_HEIGHT = 150 +local MAP_COLORS = { "primary", "tertiary", "secondary", "error" } +local POSITION_STEP = 10 + +local function isRotatedSideways(transform) + return transform == "90" or transform == "270" + or transform == "flipped-90" or transform == "flipped-270" +end + +displayLogicalSize = function(display) + local scale = display.scale > 0 and display.scale or 1 + local width = display.width / scale + local height = display.height / scale + if isRotatedSideways(display.transform) then + return { width = height, height = width } + end + return { width = width, height = height } +end + +local function effectivePosition(display) + local draft = positionDraft[display.port] + if draft ~= nil then + return draft.x, draft.y + end + return display.x, display.y +end + +local function layoutBounds(displays) + local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge + local any = false + for _, display in ipairs(displays) do + if display.enabled and display.width > 0 and display.height > 0 then + local size = displayLogicalSize(display) + minX = math.min(minX, display.x) + minY = math.min(minY, display.y) + maxX = math.max(maxX, display.x + size.width) + maxY = math.max(maxY, display.y + size.height) + any = true + end + end + if not any then + return nil + end + return { minX = minX, minY = minY, maxX = maxX, maxY = maxY } +end + +local function layoutMap(displays) + local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge + local any = false + for _, display in ipairs(displays) do + if display.enabled and display.width > 0 and display.height > 0 then + local size = displayLogicalSize(display) + local x, y = effectivePosition(display) + minX = math.min(minX, x) + minY = math.min(minY, y) + maxX = math.max(maxX, x + size.width) + maxY = math.max(maxY, y + size.height) + any = true + end + end + if not any then + return nil + end + local totalW = math.max(1, maxX - minX) + local totalH = math.max(1, maxY - minY) + local scale = math.min(MAP_WIDTH / totalW, MAP_HEIGHT / totalH) + + local items = {} + for index, display in ipairs(displays) do + if display.enabled and display.width > 0 and display.height > 0 then + local size = displayLogicalSize(display) + local x, y = effectivePosition(display) + table.insert(items, { + index = index, + label = display.port, + x = (x - minX) * scale, + y = (y - minY) * scale, + w = math.max(44, size.width * scale), + h = math.max(18, size.height * scale), + }) + end + end + + table.sort(items, function(a, b) return a.y < b.y end) + local rows = {} + for _, item in ipairs(items) do + local placed = false + for _, row in ipairs(rows) do + if item.y < row.bottom and row.top < item.y + item.h then + table.insert(row.items, item) + if item.y < row.top then row.top = item.y end + if item.y + item.h > row.bottom then row.bottom = item.y + item.h end + placed = true + break + end + end + if not placed then + table.insert(rows, { top = item.y, bottom = item.y + item.h, items = { item } }) + end + end + table.sort(rows, function(a, b) return a.top < b.top end) + for _, row in ipairs(rows) do + table.sort(row.items, function(a, b) return a.x < b.x end) + end + + local rowNodes = {} + local previousBottom = 0 + for rowIndex, row in ipairs(rows) do + if rowIndex > 1 then + local gap = row.top - previousBottom + if gap > 1 then + table.insert(rowNodes, ui.spacer({ height = math.floor(gap + 0.5) })) + end + end + local cols = {} + local cursorX = 0 + for _, item in ipairs(row.items) do + local offset = item.x - cursorX + if offset > 1 then + table.insert(cols, ui.spacer({ width = math.floor(offset + 0.5) })) + end + local color = MAP_COLORS[item.index % #MAP_COLORS + 1] or "primary" + local cell = {} + local topPad = item.y - row.top + if topPad > 1 then + table.insert(cell, ui.spacer({ height = math.floor(topPad + 0.5) })) + end + table.insert(cell, ui.column({ + width = math.floor(item.w + 0.5), + height = math.floor(item.h + 0.5), + fill = color .. "/0.35", + border = color .. "/0.6", + borderWidth = 1, + radius = 4, + align = "center", + justify = "center", + paddingH = 2, + }, { + ui.label({ + text = item.label, + fontSize = 8, + fontWeight = "bold", + color = "on_surface", + maxLines = 1, + }), + })) + table.insert(cols, ui.column({ gap = 0, align = "start" }, cell)) + cursorX = item.x + item.w + end + table.insert(rowNodes, ui.row({ gap = 0, align = "start" }, cols)) + previousBottom = row.bottom + end + + return ui.column({ gap = 0, align = "center" }, rowNodes) +end + +local function layoutSection(displays) + local bounds = layoutBounds(displays) + if bounds == nil then + return nil + end + + local header = ui.column({ + fill = "surface_variant/0.18", + border = "outline/0.42", + borderWidth = 1, + radius = 12, + align = "stretch", + }, { + ui.button({ + text = tr("panel.layout"), + glyph = layoutExpanded and "chevron-down" or "chevron-right", + variant = "ghost", + fontSize = 12, + flexGrow = 1, + onClick = function() + layoutExpanded = not layoutExpanded + render() + end, + }), + }) + + if not layoutExpanded then + return ui.column({ gap = 4, align = "stretch" }, { header }) + end + + local map = layoutMap(displays) + local children = {} + + if map ~= nil then + table.insert(children, ui.column({ + fill = "surface_variant/0.18", + border = "outline/0.34", + borderWidth = 1, + radius = 10, + padding = 10, + align = "center", + }, { map })) + end + + local targetPort = defaultSelectedPort(displays) + local selected = nil + if targetPort ~= nil then + for _, display in ipairs(displays) do + if display.port == targetPort and display.enabled then + selected = display + break + end + end + end + + if selected ~= nil then + local ref = referenceDisplay(displays, selected.port) + if ref ~= nil then + local canMove = not actionPending + + local options = {} + local optionPorts = {} + local selectedIndex = 0 + for _, display in ipairs(displays) do + if display.enabled then + table.insert(optionPorts, display.port) + table.insert(options, display.name .. " · " .. display.port) + if display.port == selected.port then + selectedIndex = #optionPorts - 1 + end + end + end + + local refSize = displayLogicalSize(ref) + local selectedSize = displayLogicalSize(selected) + local offsetMin = 0 + local offsetMax = 0 + local hasDirection = layoutDirection ~= nil + if hasDirection then + if layoutDirection == "right" or layoutDirection == "left" then + local half = (refSize.height - selectedSize.height) / 2 + offsetMin = -math.floor(math.max(0, half)) + offsetMax = math.floor(math.max(0, half)) + else + local half = (refSize.width - selectedSize.width) / 2 + offsetMin = -math.floor(math.max(0, half)) + offsetMax = math.floor(math.max(0, half)) + end + end + + table.insert(children, ui.column({ gap = 8, align = "stretch" }, { + ui.row({ gap = 10, align = "center" }, { + field(tr("panel.position"), ui.select({ + options = options, + selectedIndex = selectedIndex, + enabled = canMove, + onChange = function(index) + local port = optionPorts[index + 1] + if port ~= nil and port ~= selectedPort then + selectedPort = port + layoutDirection = nil + layoutOffset = 0 + render() + end + end, + })), + ui.column({ gap = 4, align = "center" }, { + ui.row({ gap = 4, align = "center" }, { + ui.spacer({ width = 32 }), + ui.button({ glyph = "chevron-up", width = 32, height = 32, enabled = canMove, onClick = function() setDirection(selected, ref, "top") end }), + ui.spacer({ width = 32 }), + }), + ui.row({ gap = 4, align = "center" }, { + ui.button({ glyph = "chevron-left", width = 32, height = 32, enabled = canMove, onClick = function() setDirection(selected, ref, "left") end }), + ui.spacer({ width = 32 }), + ui.button({ glyph = "chevron-right", width = 32, height = 32, enabled = canMove, onClick = function() setDirection(selected, ref, "right") end }), + }), + ui.row({ gap = 4, align = "center" }, { + ui.spacer({ width = 32 }), + ui.button({ glyph = "chevron-down", width = 32, height = 32, enabled = canMove, onClick = function() setDirection(selected, ref, "bottom") end }), + ui.spacer({ width = 32 }), + }), + }), + ui.column({ gap = 6, align = "stretch", width = 120 }, { + ui.label({ text = tr("panel.position_offset"), fontSize = 11, color = "on_surface_variant" }), + ui.slider({ + min = offsetMin, + max = offsetMax, + step = POSITION_STEP, + value = layoutOffset, + enabled = canMove and hasDirection, + onChange = function(value) + changeLayoutOffset(selected, ref, value) + end, + }), + }), + }), + ui.button({ + text = tr("panel.apply"), + variant = "primary", + glyph = "check", + enabled = canMove and hasDirection, + onClick = function() + commitPosition(selected) + end, + }), + })) + end + end + + return ui.column({ gap = 8, align = "stretch" }, { + header, + ui.column({ + gap = 10, + align = "stretch", + fill = "surface_variant/0.18", + border = "outline/0.34", + borderWidth = 1, + radius = 10, + padding = 10, + }, children), + }) +end + local function badge(text, color) return ui.row({ fill = color .. "/0.16", radius = 8, paddingH = 7, paddingV = 2, align = "center" }, { ui.label({ text = text, fontSize = 11, fontWeight = "bold", color = color }), }) end -local function field(label, control) +field = function(label, control) return ui.column({ gap = 5, flexGrow = 1, align = "stretch" }, { ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }), control, @@ -279,6 +727,7 @@ local function displayCard(display, position) local resolutionOptions = optionsFromChoices(resolutions) local refreshOptions = optionsFromChoices(refreshes) + local transformOptions = optionsFromChoices(TRANSFORM_OPTIONS) local enabled = display.enabled and not actionPending and #resolutions > 0 local presetScale = presetFor(display.port, display.width, display.height) local scale = scaleDraft[display.port] or display.scale @@ -287,10 +736,10 @@ local function displayCard(display, position) local headerChildren = { badge(tostring(position), display.focused and "primary" or "on_surface_variant"), - ui.glyph({ name = "device-desktop", size = 20, color = display.focused and "primary" or "on_surface_variant" }), - ui.column({ gap = 1, flexGrow = 1 }, { - ui.label({ text = display.name, fontWeight = "bold", color = "on_surface", maxLines = 1 }), - ui.label({ text = display.port, fontSize = 11, color = "on_surface_variant" }), + ui.glyph({ name = "device-desktop", size = 18, color = display.focused and "primary" or "on_surface_variant" }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = display.name, fontSize = 12, fontWeight = "bold", color = "on_surface", maxLines = 1 }), + ui.label({ text = display.port, fontSize = 10, color = "on_surface_variant" }), }), } if display.focused then @@ -301,24 +750,30 @@ local function displayCard(display, position) end local body = { - ui.row({ gap = 14, align = "end" }, { - field(tr("panel.resolution"), ui.select({ - options = resolutionOptions, - selectedIndex = selectedResolutionIndex(display, resolutions), - enabled = enabled, - onChange = function(index) - changeResolution(display, resolutions, index) - end, - })), - field(tr("panel.refresh_rate"), ui.select({ - options = refreshOptions, - selectedIndex = selectedRefreshIndex(display, refreshes), - enabled = enabled and #refreshes > 0, - onChange = function(index) - changeRefresh(display, refreshes, index) - end, - })), - }), + field(tr("panel.resolution"), ui.select({ + options = resolutionOptions, + selectedIndex = selectedResolutionIndex(display, resolutions), + enabled = enabled, + onChange = function(index) + changeResolution(display, resolutions, index) + end, + })), + field(tr("panel.refresh_rate"), ui.select({ + options = refreshOptions, + selectedIndex = selectedRefreshIndex(display, refreshes), + enabled = enabled and #refreshes > 0, + onChange = function(index) + changeRefresh(display, refreshes, index) + end, + })), + field(tr("panel.rotation"), ui.select({ + options = transformOptions, + selectedIndex = selectedTransformIndex(display), + enabled = enabled, + onChange = function(index) + changeTransform(display, index) + end, + })), ui.column({ gap = 4, align = "stretch" }, { ui.row({ align = "center", justify = "space_between" }, { ui.label({ text = tr("panel.scale"), fontSize = 11, color = "on_surface_variant" }), @@ -343,13 +798,13 @@ local function displayCard(display, position) if presetScale ~= nil then table.insert(body, ui.row({ gap = 6, align = "center" }, { - ui.glyph({ name = "wand", size = 14, color = "primary" }), + ui.glyph({ name = "wand", size = 13, color = "primary" }), ui.label({ text = tr("panel.preset_match", { resolution = tostring(display.width) .. "x" .. tostring(display.height), scale = formatScale(presetScale), }), - fontSize = 11, + fontSize = 10, color = "primary", }), })) @@ -360,13 +815,15 @@ local function displayCard(display, position) fill = display.focused and "primary/0.055" or "surface_variant/0.18", border = display.focused and "primary/0.75" or "outline/0.42", borderWidth = display.focused and 2 or 1, - radius = 13, - padding = 14, - gap = 13, + radius = 12, + padding = 10, + gap = 9, align = "stretch", + flexGrow = 1, + minWidth = 190, }, { - ui.row({ gap = 10, align = "center" }, headerChildren), - ui.column({ gap = 11, align = "stretch" }, body), + ui.row({ gap = 8, align = "center" }, headerChildren), + ui.column({ gap = 8, align = "stretch" }, body), }) end @@ -412,7 +869,27 @@ local function body() textAlign = "center", })) end - return ui.scroll({ flexGrow = 1, gap = 10, align = "stretch" }, cards) + + local scrollChildren = {} + + local layout = layoutSection(displays) + if layout ~= nil then + table.insert(scrollChildren, layout) + end + + local currentRow = {} + for _, card in ipairs(cards) do + table.insert(currentRow, card) + if #currentRow == 2 then + table.insert(scrollChildren, ui.row({ gap = 10, align = "start" }, currentRow)) + currentRow = {} + end + end + if #currentRow > 0 then + table.insert(scrollChildren, ui.row({ gap = 10, align = "start" }, currentRow)) + end + + return ui.scroll({ flexGrow = 1, gap = 10, align = "stretch" }, scrollChildren) end render = function() @@ -446,7 +923,7 @@ render = function() gap = 8, align = "center", paddingH = 10, - paddingV = 8, + paddingV = 4, fill = "surface_variant/0.18", border = "outline/0.34", borderWidth = 1, @@ -456,7 +933,7 @@ render = function() ui.label({ text = tr("panel.temporary_note"), fontSize = 10, color = "on_surface_variant", flexGrow = 1, maxLines = 2 }), })) - panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, children)) + panel.render(ui.column({ flexGrow = 1, gap = 5, align = "stretch" }, children)) end noctalia.state.watch("display_snapshot", function() @@ -475,6 +952,7 @@ end function onClose() isOpen = false scaleEditing = {} + layoutExpanded = false end function onConfigChanged() diff --git a/niri-displays/plugin.toml b/niri-displays/plugin.toml index 0f87652d..170ff18d 100644 --- a/niri-displays/plugin.toml +++ b/niri-displays/plugin.toml @@ -7,7 +7,7 @@ license = "MIT" dependencies = ["niri"] tags = ["bar", "niri", "panel", "service", "system", "utility"] icon = "device-desktop" -description = "Inspect connected Niri outputs and temporarily adjust resolution, refresh rate, and scale." +description = "Inspect connected Niri outputs and temporarily adjust resolution, refresh rate, scale, rotation, and position." [[setting]] key = "scale_presets" diff --git a/niri-displays/service.luau b/niri-displays/service.luau index f4f0095f..5db5d280 100644 --- a/niri-displays/service.luau +++ b/niri-displays/service.luau @@ -122,6 +122,25 @@ local function normalizeModes(rawModes) return modes end +-- Map Niri's JSON transform names to the values accepted by +-- `niri msg output transform `. +local function normalizeTransform(rawTransform) + local mapping = { + Normal = "normal", + ["90"] = "90", + ["180"] = "180", + ["270"] = "270", + Flipped = "flipped", + Flipped90 = "flipped-90", + Flipped180 = "flipped-180", + Flipped270 = "flipped-270", + } + if type(rawTransform) == "string" then + return mapping[rawTransform] or "normal" + end + return "normal" +end + local function normalizeOutputs(rawOutputs, focusedName, revision) local displays = {} for connector, raw in pairs(rawOutputs) do @@ -144,6 +163,7 @@ local function normalizeOutputs(rawOutputs, focusedName, revision) x = logical and (tonumber(logical.x) or 0) or 0, y = logical and (tonumber(logical.y) or 0) or 0, scale = logical and (tonumber(logical.scale) or 1) or 1, + transform = logical and normalizeTransform(logical.transform) or "normal", width = currentMode and currentMode.width or 0, height = currentMode and currentMode.height or 0, refresh_rate = currentMode and currentMode.refresh_rate or 0, diff --git a/niri-displays/translations/en.json b/niri-displays/translations/en.json index 4c6b2ce4..de8341aa 100644 --- a/niri-displays/translations/en.json +++ b/niri-displays/translations/en.json @@ -9,15 +9,21 @@ "title": "Couldn't read Niri displays" }, "panel": { + "apply": "Apply", "disabled": "Disabled", "empty": "Niri reports no connected displays.", "focused": "Focused", + "layout": "Display layout", "loading": "Reading displays from Niri…", "more_displays": "…and {count} more displays", + "position": "Position", + "position_auto": "Auto", + "position_offset": "Offset", "preset_match": "Auto scale for {resolution}: {scale}×", "refresh_rate": "Refresh rate", "resolution": "Resolution", "retry": "Retry", + "rotation": "Rotation", "scale": "Scale", "subtitle": "Configure your display outputs in Niri", "temporary_note": "Changes use Niri's temporary output IPC and are not written to your Niri config.",