diff --git a/common/configs/keybind_catalog.json b/common/configs/keybind_catalog.json index ff827413aa4..d74a2fc4236 100644 --- a/common/configs/keybind_catalog.json +++ b/common/configs/keybind_catalog.json @@ -23,7 +23,6 @@ "edit_prev_word", "edit_return", "losradar", - "luaui selector", "movereset", "moverotate", "moveslow", @@ -277,11 +276,13 @@ }, { "action": "repeat 0", - "label": "actions.unitStates.repeatOff" + "label": "actions.unitStates.repeatOff", + "description": "commands.repeat_tooltip" }, { "action": "repeat 1", - "label": "actions.unitStates.repeatOn" + "label": "actions.unitStates.repeatOn", + "description": "commands.repeat_tooltip" }, { "prefix": "trajectory_toggle ", @@ -294,27 +295,33 @@ }, { "action": "firestate 0", - "label": "actions.unitStates.fireHold" + "label": "actions.unitStates.fireHold", + "description": "commands.firestate_hold_fire_descr" }, { "action": "firestate 1", - "label": "actions.unitStates.fireReturn" + "label": "actions.unitStates.fireReturn", + "description": "commands.firestate_return_fire_descr" }, { "action": "firestate 2", - "label": "actions.unitStates.fireAtWill" + "label": "actions.unitStates.fireAtWill", + "description": "commands.firestate_fire_at_will_descr" }, { "action": "movestate 0", - "label": "actions.unitStates.moveHold" + "label": "actions.unitStates.moveHold", + "description": "commands.movestate_tooltip" }, { "action": "movestate 1", - "label": "actions.unitStates.moveManeuver" + "label": "actions.unitStates.moveManeuver", + "description": "commands.movestate_tooltip" }, { "action": "movestate 2", - "label": "actions.unitStates.moveRoam" + "label": "actions.unitStates.moveRoam", + "description": "commands.movestate_tooltip" } ] }, @@ -707,6 +714,10 @@ "action": "options", "label": "actions.interfaceDisplay.settings" }, + { + "action": "luaui selector", + "label": "actions.interfaceDisplay.widgetSelector" + }, { "action": "hideinterface", "label": "actions.cameraModes.interface" diff --git a/common/configs/keybind_catalog.schema.json b/common/configs/keybind_catalog.schema.json index db1c86f1480..05c2bc56fc6 100644 --- a/common/configs/keybind_catalog.schema.json +++ b/common/configs/keybind_catalog.schema.json @@ -49,6 +49,7 @@ "properties": { "action": { "type": "string", "description": "Bind command (command + space-separated args), exactly as passed to /bind and reported by GetKeyBindings." }, "label": { "type": "string", "description": "i18n key for the display label." }, + "description": { "type": "string", "description": "i18n key for a sentence saying what the action does, for a tooltip. Optional: without one a surface may fall back to the command card's tooltip (commands._tooltip) or the engine's command description (cmd.)." }, "alwaysModifier": { "type": "string", "enum": ["any", "shift"], "description": "Modifier this action always tolerates, so a surface neither shows it nor lets the player pick it. \"any\" binds with the engine's Any+ qualifier and fires whatever is held. \"shift\" has no engine equivalent, so the binding is written twice, bare and Shift+, and both halves move together. Fixed per action rather than chosen." } } }, diff --git a/common/configs/keybind_defaults.json b/common/configs/keybind_defaults.json index 2d7e7ba2943..ed9da7b5792 100644 --- a/common/configs/keybind_defaults.json +++ b/common/configs/keybind_defaults.json @@ -4,6 +4,7 @@ "profiles": [ { "name": "Grid", + "description": "ui.keybinds.presets.grid", "binds": [ { "keyset": "esc", @@ -1337,6 +1338,7 @@ }, { "name": "Grid (60% Keyboard)", + "description": "ui.keybinds.presets.grid60", "binds": [ { "keyset": "esc", @@ -2582,6 +2584,7 @@ }, { "name": "Legacy", + "description": "ui.keybinds.presets.legacy", "binds": [ { "keyset": "esc", @@ -4367,6 +4370,7 @@ }, { "name": "Legacy (60% Keyboard)", + "description": "ui.keybinds.presets.legacy60", "binds": [ { "keyset": "esc", diff --git a/common/configs/keybind_defaults.schema.json b/common/configs/keybind_defaults.schema.json index c5b8e72158a..d35707ee9d5 100644 --- a/common/configs/keybind_defaults.schema.json +++ b/common/configs/keybind_defaults.schema.json @@ -17,6 +17,11 @@ "minLength": 1, "description": "Display name, and the id a surface stores as the active selection. Unique across both lists." }, + "description": { + "type": "string", + "minLength": 1, + "description": "i18n key for a sentence or two saying what the profile is for, shown where a surface lets the player pick one. A surface without that translation shows the key's own text." + }, "fakeMeta": { "type": "string", "description": "Key to treat as the Meta modifier, if the profile wants one." diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md index ff9e8ac9860..243777b17a2 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -111,6 +111,20 @@ shape as the shipped ones plus an `active` field naming the selected profile. Th is per-install rather than shared, but its format is the contract - a surface that can read one can read the other. +A player's profile carries `basedOn`, the name of the shipped profile it descends from: +recorded when it was forked or duplicated, and otherwise (imported, or made before the +field existed, or naming a profile that no longer ships) inferred on load as the shipped +profile it differs from on the fewest actions, and written back. That is what lets a +surface say which keys the player changed and what the default was. + +A shipped profile may carry `description`, an i18n key for a sentence saying what the +profile is for, shown wherever a surface lets the player pick one. + +A profile travels as text in the bind-file form the engine loads, headed by a +`// keybind editor profile: ` comment: that is what the in-game Export copies to +the clipboard and what Import reads back, and the same text a player would put in +`uikeys.txt` by hand. + - **Which profile are we on?** Read `active` from the player's profile store. If it names nothing that exists in either file, fall back to the first shipped profile. - **Apply a profile.** Write its binds out as `bind ` lines (plus a @@ -153,9 +167,10 @@ three namespaces above and none of that. - A widget/mod action-declaration API, so widgets register their own bindable actions (with label + category + description) into the catalog at runtime instead of only being editable when already bound. -- Command descriptions / tooltips. The engine ships per-command descriptions in the shared - `cmd.*` i18n namespace (in `interface.json`, localized like everything else), so a future - iteration can show them by resolving `cmd.` (falling back to `cmd.._description` - for the few structured commands, and `cmd.luarules.` for gadget commands) at display - time - no catalog change needed, since the catalog already carries the command per row. Widget/mod - actions have no `cmd.*` entry, so their descriptions depend on the declaration API above. +- Command descriptions for every action. A catalog item may carry `description`, an i18n + key for a tooltip sentence; without one the in-game editor falls back to the command + card's tooltip (`commands._tooltip`, for a row labelled `commands.`) and then + to the engine's command description (`cmd.`, `cmd.._description` for the + structured ones, `cmd.luarules.` for gadget commands). Roughly a third of the + catalog still has none of those. Widget/mod actions have no `cmd.*` entry, so their + descriptions depend on the declaration API above. diff --git a/language/en/commands.json b/language/en/commands.json index eafd3c19a8c..dfbd6b235cf 100644 --- a/language/en/commands.json +++ b/language/en/commands.json @@ -231,7 +231,8 @@ "unitStats": "Unit stats", "saveGame": "Save game", "screenshot": "Take screenshot", - "firingRange": "Display firing range" + "firingRange": "Display firing range", + "widgetSelector": "Widget selector" }, "gameControl": { "voteForcestart": "Vote forcestart", diff --git a/language/en/interface.json b/language/en/interface.json index 642b32548b8..4c59c22bc0e 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -240,26 +240,69 @@ "title": "Keybinds", "editor": { "allCategories": "All", - "search": "Search...", + "search": "Search actions or keys...", + "boundTo": "Bound to %{keys}", + "preset": "Preset", + "defaultTag": "Default", "pressKey": "Press a key or mouse button...", - "newProfile": "New Profile", + "newProfile": "New Preset", "duplicate": "Duplicate", + "duplicateTooltip": "Make a copy of this preset that you can change.", "edit": "Edit", + "editTooltip": "Rename or delete this preset.", + "editLockedTooltip": "Default presets cannot be renamed or deleted. Duplicate this one to get a copy you can change.", "delete": "Delete", "save": "Save", + "saveAsNew": "Save as New", + "noticeDefault": "Default presets cannot be changed. Your edits will be saved as a new preset.", + "noticeDefaultUnsaved": "Unsaved changes (Ctrl+Z undoes). Saving creates a new preset and leaves the default as it is.", + "noticeUnsaved": "Unsaved changes (Ctrl+Z undoes the last edit).", + "changed": "Changed", + "changedCount": "Changed (%{n})", + "changedTooltip": "Every action whose keys differ from %{name}. Restore default keybinds by clicking the button containing the defualt keybind on the right.", + "conflict": "%{keys} is also bound to: %{actions}", + "conflictFirst": "%{action} (tried first)", + "conflictOrder": "Actions sharing a key are tried in the order they were bound; the first to take the key wins.", + "conflictShipped": "The game ships them on one key; the one bound first is tried first.", + "conflictCapture": "Also bound to: %{actions}", + "defaultIn": "Default in %{name}: %{keys}", + "defaultNone": "Not bound in %{name}", + "revertHint": "Click to put the default keybind back.", + "revertNone": "none", + "revertChip": "default: %{keys}", + "presetDefault": "A default preset. It cannot be changed; editing it saves a new preset of your own.", + "presetBasedOn": "Your preset, based on %{name}.", + "presetOwn": "Your preset.", + "export": "Export", + "exportTooltip": "Copy this preset to the clipboard as text, to share or keep.", + "exportDone": "\"%{name}\" is on the clipboard. Paste it anywhere to share or keep it; Import reads it back in.", + "import": "Import", + "importTooltip": "Read a preset from text on the clipboard and add it as a new preset.", + "importTitle": "Import preset", + "importEmpty": "The clipboard is empty. Copy a preset's text first, then try again.", + "importSummary": "%{n} keybinds found", + "importErrors": "%{n} lines could not be read and will be dropped", + "importNone": "No keybinds found in the clipboard text", + "ok": "OK", "reset": "Discard Changes", "resetConfirm": "Discard your unsaved keybind changes?", - "saveTitle": "Save as new profile", + "saveTitle": "Save as new preset", "discard": "Discard", "unsavedTitle": "Unsaved changes", "unsavedMessage": "Your keybind changes have not been saved yet.", - "duplicateTitle": "Duplicate profile", - "editTitle": "Edit profile", + "duplicateTitle": "Duplicate preset", + "editTitle": "Edit preset", "deleteConfirm": "Delete \"%{name}\"? This cannot be undone.", - "applyFailedTitle": "Could not apply profile", + "applyFailedTitle": "Could not apply preset", "applyFailedMessage": "\"%{name}\" could not be written to disk, so your keybinds are unchanged. Check that the game folder is writable.", "accept": "Accept", "cancel": "Cancel" + }, + "presets": { + "grid": "Builds through the grid menu: QWER, ASDF and ZXCV pick a slot in the build grid, so the same key builds the same slot for every builder. Orders sit on the keys around them.", + "grid60": "The Grid preset for keyboards without a function row: map views, camera anchors and the other F-key actions move to Meta + number, and what sat on ` moves to Meta + Q.", + "legacy": "The classic layout: a key per building for build hotkeys, orders on their original keys, and the build menu rather than the grid.", + "legacy60": "The Legacy preset for keyboards without a function row: map views, camera views and the other F-key actions move to Meta + number." } }, "blueprint": { @@ -740,7 +783,6 @@ "factorydefaults": "Factory defaults", "cancel": "Cancel", "confirm": "Confirm", - "hint": "Click to toggle. Right-click sends it to the front of its layer, middle-click to the back.", "factorydefaultswarn": "This throws away every interface setting you have: which widgets are on, their positions, and anything you have configured in them. LuaUI reloads immediately. It cannot be undone.", "unloadallwarn": "Switches off every widget in the list at once. Your settings are kept, and you can switch them back on one at a time.", "disallowuserwarn": "Stops loading widgets from your own LuaUI folder, leaving only the ones the game ships. LuaUI reloads immediately.", diff --git a/luarules/gadgets/cmd_dev_helpers.lua b/luarules/gadgets/cmd_dev_helpers.lua index 63c46841cdd..0ba9bcd7718 100644 --- a/luarules/gadgets/cmd_dev_helpers.lua +++ b/luarules/gadgets/cmd_dev_helpers.lua @@ -543,8 +543,7 @@ if gadgetHandler:IsSyncedCode() then subPermission = "modmarker" end - local bypassSyncedAuthorization = cmd == "godmode" or cmd == "godmodeally" - if not bypassSyncedAuthorization and not isAuthorized(playerID, subPermission) then + if not isAuthorized(playerID, subPermission) then return end diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index 22727b3b604..c9dcb65e7c0 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -1,6 +1,11 @@ -- Select control for the keybind editor's preset picker. -- Uses FlowUI's Selector visuals to match the Settings look. Shows the current -- selection; onSelect(option, index) fires when a choice is picked. +-- +-- An option record may carry a `tag`, a short word drawn on a faint pill at the right of its +-- row, and of the closed control while it is the selection; and a `group`, where the open +-- list rules a line between two neighbours whose groups differ. Both are opt-in, so a list +-- of plain strings draws as it always has. local text = VFS.Include("luaui/Include/keybind_text.lua") @@ -10,6 +15,8 @@ Dropdown.__index = Dropdown local floor = math.floor local colorText = "\255\235\235\235" +-- Quieter than the name beside it: a tag qualifies the option rather than naming it. +local colorTag = "\255\175\175\175" -- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the -- overlay is opaque and swallows the option label under it. local hoverOpacity = 0.25 @@ -18,6 +25,11 @@ local hoverOpacity = 0.25 local controlHoverOpacity = 0.14 local white = { 1, 1, 1 } local listFill = { 0.09, 0.09, 0.09, 0.96 } +local tagFill = { 1, 1, 1, 0.08 } +-- Under the option the list was opened on. Fainter than the hover, so the two stay apart +-- when the cursor is on another row. +local selectedFill = { 1, 1, 1, 0.07 } +local ruleColor = { 1, 1, 1, 0.14 } -- Font is fetched per draw; it does not exist when this file is included. local function getFont() @@ -33,6 +45,14 @@ local function optionLabel(opt) return tostring(opt) end +local function optionTag(opt) + return type(opt) == "table" and opt.tag or nil +end + +local function optionGroup(opt) + return type(opt) == "table" and opt.group or nil +end + -- A select: closed it shows the selection, open it overlays its options. function Dropdown.new(opts) opts = opts or {} @@ -46,6 +66,9 @@ function Dropdown.new(opts) -- every other widget and keeps whatever outline was set on it last; without one this takes -- that, as it always has. self.outline = opts.outline + -- Shade the selected option in the open list: for a picker whose selection is always a + -- real choice, never a placeholder standing in for none. + self.markSelected = opts.markSelected self.open = false self.rect = { 0, 0, 0, 0 } self.optRects = {} @@ -58,6 +81,8 @@ end function Dropdown:setRect(x1, y1, x2, y2, fontSize) self.rect = { x1, y1, x2, y2 } self.fontSize = fontSize or (y2 - y1) * 0.5 + -- Tag pills are measured against the row height and the font size, both just set. + self.tagCache = nil local optH = floor(y2 - y1) self.optRects = {} @@ -96,6 +121,34 @@ local function fittedLabel(cache, key, font, label, w, fs) return fitted end +-- A tag's pill width, caption size, padding and coloured caption at this control's size. +-- Measured once per tag rather than per frame; setRect drops them when the size changes. +local function tagMetrics(self, font, tag) + local cache = self.tagCache + if not cache then + cache = {} + self.tagCache = cache + end + + local hit = cache[tag] + if not hit then + local fs = floor(self.fontSize * 0.8) + local pad = floor((self.rect[4] - self.rect[2]) * 0.25) + hit = { w = floor(font:GetTextWidth(tag) * fs) + pad * 2, fs = fs, pad = pad, text = colorTag .. tag } + cache[tag] = hit + end + + return hit +end + +-- Vertical span of a tag's pill in a row: a little over half the row's height, centred. +local function tagSpan(y1, y2) + local h = floor((y2 - y1) * 0.62) + local py1 = floor((y1 + y2 - h) * 0.5) + + return py1, py1 + h +end + -- Moves the selection without notifying the owner, for syncing from outside. function Dropdown:setSelected(i) if i and self.options[i] then @@ -107,6 +160,25 @@ function Dropdown:isOpen() return self.open end +-- What the cursor is over: an option's index in the open list, 0 for the control itself, +-- nil for neither. For an owner that shows something about the option under the cursor. +function Dropdown:optionAt(x, y) + if self.open then + for i, r in ipairs(self.optRects) do + if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then + return i + end + end + end + + local b = self.rect + if x >= b[1] and x <= b[3] and y >= b[2] and y <= b[4] then + return 0 + end + + return nil +end + function Dropdown:close() self.open = false end @@ -129,6 +201,11 @@ function Dropdown:draw() local mx, my = Spring.GetMouseState() local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] local inset = floor((y2 - y1) * 0.3) + local tagCs = math.max(1, floor(WG.FlowUI.elementCorner * 0.5)) + -- Where a tag's pill ends: clear of the square FlowUI's Selector draws for its button at the + -- right end, as wide as the control is tall. The open list's tags keep to the same column, + -- so a tag does not jump sideways between the closed control and the rows under it. + local tagRight = x2 - (y2 - y1) - inset Selector(x1, y1, x2, y2) -- A control with nothing to choose from does not light under the cursor. Lighting is @@ -148,6 +225,21 @@ function Dropdown:draw() gl.BeginEnd(GL.TRIANGLES, chevronVertices) gl.Color(1, 1, 1, 1) + -- The selection's tag, in the column worked out above. Its pill is geometry too, so it goes + -- down here and its caption waits for the font batch. A placeholder is not an option and + -- has no tag. + local current = self.options[self.selected] + local currentTag = not self.placeholder and optionTag(current) + local tag = currentTag and tagMetrics(self, font, currentTag) + local labelRight = (arrowX - arrowH) - inset * 2 + local tagX1, tagY1, tagY2 + if tag then + tagX1 = tagRight - tag.w + tagY1, tagY2 = tagSpan(y1, y2) + R(tagX1, tagY1, tagRight, tagY2, tagCs, 1, 1, 1, 1, tagFill) + labelRight = tagX1 - inset + end + local fitted = self.optFitted if not fitted then fitted = {} @@ -158,11 +250,10 @@ function Dropdown:draw() if self.outline then font:SetOutlineColor(self.outline) end - local current = self.options[self.selected] local label = self.placeholder or (current and optionLabel(current) or "") - -- A profile name is free text and can outrun the control, which is fixed width so the + -- A preset name is free text and can outrun the control, which is fixed width so the -- header does not reflow every time the selection changes. - local labelW = (arrowX - arrowH) - (x1 + inset) - inset * 2 + local labelW = labelRight - (x1 + inset) font:Print( fittedLabel(fitted, 0, font, label, labelW, self.fontSize), x1 + inset, @@ -170,6 +261,9 @@ function Dropdown:draw() self.fontSize, "o" ) + if tag then + font:Print(tag.text, tagX1 + tag.pad, text.baseline(font, tagY1, tagY2, tag.fs), tag.fs, "o") + end font:End() if self.open and #self.optRects > 0 then @@ -179,26 +273,51 @@ function Dropdown:draw() local cs = floor(WG.FlowUI.elementCorner * 0.66) R(x1, bottom, x2, top, cs, 1, 1, 1, 1, listFill) - for i in ipairs(self.options) do + local ruleH = math.max(1, floor((y2 - y1) * 0.04)) + for i, opt in ipairs(self.options) do ---@type table local r = self.optRects[i] + if self.markSelected and i == self.selected then + R(r.x1, r.y1, r.x2, r.y2, cs, 1, 1, 1, 1, selectedFill) + end if mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 then Highlight(r.x1, r.y1, r.x2, r.y2, cs, hoverOpacity, white) end + + local optTag = optionTag(opt) + if optTag then + local m = tagMetrics(self, font, optTag) + local py1, py2 = tagSpan(r.y1, r.y2) + R(tagRight - m.w, py1, tagRight, py2, tagCs, 1, 1, 1, 1, tagFill) + end + + -- A rule along the top of the row where one group of options gives way to the next. + if i > 1 and optionGroup(opt) ~= optionGroup(self.options[i - 1]) then + gl.Color(ruleColor[1], ruleColor[2], ruleColor[3], ruleColor[4]) + gl.Rect(r.x1 + inset, r.y2 - ruleH, r.x2 - inset, r.y2) + gl.Color(1, 1, 1, 1) + end end -- Still the caption's outline: it was set on this same font a moment ago, in this draw. font:Begin() for i, opt in ipairs(self.options) do local r = self.optRects[i] - local w = (r.x2 - inset) - (r.x1 + inset) + local optTag = optionTag(opt) + local m = optTag and tagMetrics(self, font, optTag) + -- The name stops short of its tag when it has one, as the closed control's does. + local right = m and (tagRight - m.w - inset) or (r.x2 - inset) font:Print( - fittedLabel(fitted, i, font, optionLabel(opt), w, self.fontSize), + fittedLabel(fitted, i, font, optionLabel(opt), right - (r.x1 + inset), self.fontSize), r.x1 + inset, text.baseline(font, r.y1, r.y2, self.fontSize), self.fontSize, "o" ) + if m then + local py1, py2 = tagSpan(r.y1, r.y2) + font:Print(m.text, tagRight - m.w + m.pad, text.baseline(font, py1, py2, m.fs), m.fs, "o") + end end font:End() end diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 5c0be65cb83..9ed88f6e59f 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -2,10 +2,11 @@ -- the Keybind/Mouse Info panel. Immediate-mode in shape, but the panel body is baked -- into a display list and replayed until something it was painted from changes. -- --- The picker lists the shipped profiles and the player's own. Edits are staged in the --- working model and touch neither the engine nor disk until Save, which is also where --- a shipped profile forks: saving over a read-only one creates a copy instead. Unsaved --- work is marked with a "*" on the profile name and guarded on the way out. +-- The picker lists the shipped presets, tagged as defaults, then the player's own. Edits +-- are staged in the working model and touch neither the engine nor disk until Save, which +-- is also where a default forks: it cannot take the edits, so saving makes a new preset of +-- them, and the footer says so before anything is saved. Unsaved work is marked with a "*" +-- on the preset's name and guarded on the way out. local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") @@ -51,6 +52,8 @@ local metrics = { catInset = 4, -- Chips sit inside their row by this much, top and bottom. chipInset = 3, + -- The cursor picture in front of an order's name, square. + cursorIcon = 19, -- A category heading stands taller than the bindings under it and is set larger, so it -- reads as a divider rather than another row. headerRowHeight = 32, @@ -72,6 +75,16 @@ local metrics = { -- The panel title: its baseline below the top edge, and its size. titleY = 17, titleFs = 20, + -- The caption in front of the preset picker, placed by layoutHeader: its left edge, its + -- baseline and its size. + presetLabelX = 0, + presetLabelY = 0, + presetLabelFs = 13, + -- The line in the footer saying where staged edits will go: its left edge, its baseline + -- and its size. + noticeX = 0, + noticeY = 0, + noticeFs = 12, -- How far the category column starts below the keybind rows beside it, to leave the -- title room to breathe. sidebarDrop = 8, @@ -245,6 +258,27 @@ local sheenTop = { 1, 1, 1, 0.05 } local look = { chipFill = { 0, 0, 0, 0.35 }, chipFillHover = { 0, 0, 0, 0.45 }, + -- A chip that answered a search by key, warmed in the gold its key is printed in, so it stands + -- out from the rest of the row without reading as hovered. + chipFillHit = { 0.92, 0.72, 0.27, 0.3 }, + -- A chip whose key another listed action also answers to: reddened, and its tooltip says which. + chipFillConflict = { 0.62, 0.16, 0.12, 0.4 }, + -- The key a row had in the preset it was forked from, against the row's right edge as a + -- hollow chip: an amber border - the hue the headings and the unsaved notice use - with the + -- row's own dark inside it and a "default:" caption, so it reads as a note about the row + -- rather than one more of its keys. Clicking it puts that key back. + ghostBorder = { 1, 0.78, 0.51, 0.3 }, + ghostBorderHover = { 1, 0.78, 0.51, 0.7 }, + ghostInner = { 0.09, 0.09, 0.09, 1 }, + ghostKeys = "\255\200\165\110", + -- Shared by every unchanged row that asks: no keys at all, and nothing to copy. + noRaws = {}, + -- The import preview: the box the clipboard's lines scroll in, the band under a line that + -- will be dropped, and each kind of line in its own colour. + previewFill = { 0, 0, 0, 0.35 }, + previewGutter = { 1, 1, 1, 0.05 }, + previewErrorFill = { 0.62, 0.16, 0.12, 0.28 }, + previewColours = { bind = colorText, directive = colorDim, comment = colorFaded, error = colorDanger }, addFill = { 0.2, 0.45, 0.25, 0.4 }, -- Lit rather than nudged: hovering used to lift the alpha alone, which on a green this -- soft was hard to tell from resting. A tinted element brightens its own fill instead @@ -272,6 +306,34 @@ local look = { -- The glyph goes to full white with it, the way a chip's key does under the cursor. plusTextHover = "\255\255\255\255" .. "+", arrow = colorKey .. string.char(226, 128, 186), + -- The cursor an order shows in game, by the command at the front of its action, so its row + -- carries the picture a player already knows the order by. File stems in anims/: the engine's + -- own pairing (MouseHandler.cpp), and the cursors BAR's custom commands declare, which borrow + -- the attack one. An order with no cursor of its own, like stop or cloak, has no entry. + cursors = { + move = "move", + attack = "attack", + areaattack = "attack", + manuallaunch = "attack", + manualfire = "dgun", + settarget = "settarget", + settargetnoground = "settarget", + fight = "fight", + patrol = "patrol", + guard = "defend", + repair = "repair", + reclaim = "reclamate", + resurrect = "revive", + restore = "restore", + capture = "capture", + loadunits = "pickup", + unloadunits = "unload", + wait = "wait", + gatherwait = "gather", + selfd = "selfd", + }, + -- How strongly those pictures draw. At full strength they outshout the names beside them. + cursorAlpha = 0.85, } -- FlowUI's Button gradients from a bottom stop to a top one. Left to its defaults it @@ -291,6 +353,21 @@ look.gradients = setmetatable({}, { end, }) +-- The first frame of a cursor, looked up once per cursor. The 48 px set is the nearest to a +-- row's height. Most cursors number their frames from 0 and a few from 1; false when there is +-- neither, and the row goes without a picture. +look.cursorTextures = setmetatable({}, { + __index = function(self, stem) + local base = "anims/icexuick_100/cursor" .. stem + local path = (VFS.FileExists(base .. "_0.png") and base .. "_0.png") + or (VFS.FileExists(base .. "_1.png") and base .. "_1.png") + or false + self[stem] = path + + return path + end, +}) + ---@type table local searchBox ---@type table @@ -304,9 +381,27 @@ local switchToPreset, scrollFromY ---@type table? local dialog +-- `tip` names the tooltip's text in L, and `tipLocked` the text shown instead while the +-- active preset is a default. That is when Edit is greyed out, and its tooltip is then the +-- one place saying why. +-- The two with icons act on the active preset; the two with captions carry presets in and out +-- through the clipboard. local headerButtons = { - { id = "duplicate", icon = "LuaUI/Images/keybinds/duplicate.png", tooltipId = "keybind_duplicate" }, - { id = "edit", icon = "LuaUI/Images/keybinds/edit.png", tooltipId = "keybind_edit" }, + { + id = "duplicate", + icon = "LuaUI/Images/keybinds/duplicate.png", + tooltipId = "keybind_duplicate", + tip = "duplicateTooltip", + }, + { + id = "edit", + icon = "LuaUI/Images/keybinds/edit.png", + tooltipId = "keybind_edit", + tip = "editTooltip", + tipLocked = "editLockedTooltip", + }, + { id = "export", tooltipId = "keybind_export", tip = "exportTooltip" }, + { id = "import", tooltipId = "keybind_import", tip = "importTooltip" }, } -- Discarding is destructive and saving is not, so the two footer buttons are coloured for @@ -318,19 +413,61 @@ local footerButtons = { local buttonSets = { headerButtons, footerButtons } --- Header and footer bands, shared by the layout and by every geometry derived from it. -local headerH = 0 -local footerH = 0 -local layoutPending = false +-- Panel state that is neither a size nor a colour, in one table: this chunk is at Lua's +-- ceiling of 200 locals, and the functions that only this state needs hang off it too, the +-- way `shade` does. +-- headerH/footerH: the header and footer bands, shared by the layout and by every +-- geometry derived from it. +-- layoutPending: the layout ran before the font existed and has to run again. +-- tooltipsRegistered: header tooltips are registered once per layout rather than per +-- frame, since registering with a value throws the tooltip's cached text away each time. +-- panelList/panelSig: the panel below the header controls, baked once and replayed until +-- something it was painted from changes. +-- hidden: the catalog's hidden actions. They share keys with listed ones on purpose, so +-- they are no conflict. +-- labels: each action's listed name, for naming it where another action's key clashes. +-- base: the shipped preset the active one is measured against, with its keysets by +-- action; nil when the active preset has no known origin. +-- changedKey/changedCount: the column entry listing the rows that differ from the base, +-- and how many there are, which its label says. +-- refit: the column's labels changed and have to be fitted again before they are drawn. +-- undo/snapshot: the staged keymap as it stood before each edit, and as it stands now, +-- which the next edit files. batching/batchEdited: one gesture making several edits +-- files one snapshot for the lot. +-- tipKey/tipTitle/tipText: the tooltip last built, kept until the cursor is on something +-- else, since building one wraps text. +local state = { + headerH = 0, + footerH = 0, + layoutPending = false, + tooltipsRegistered = false, + hidden = {}, + labels = {}, + changedKey = {}, + changedCount = -1, + refit = false, + undo = {}, + batching = false, + batchEdited = false, +} --- Tooltips are registered once per layout rather than per frame: registering with a --- value throws the tooltip's cached text away each time, so per frame it never settled. -local tooltipsRegistered = false +-- A copy of the staged keymap, for putting back. Binds and keysets are copied rather than +-- shared: the edits rewrite entries in place. +function state.snapshotOf() + local binds, byAction = {}, {} + for i, b in ipairs(working.binds) do + binds[i] = { keyset = b.keyset, action = b.action } + end + for action, ks in pairs(working.byAction) do + local copy = {} + for i, k in ipairs(ks) do + copy[i] = { raw = k.raw, display = k.display } + end + byAction[action] = copy + end --- The panel below the header controls, baked once and replayed until something it was --- painted from changes. -local panelList -local panelSig + return { binds = binds, byAction = byAction } +end ---------------------------------------------------------------- -- Profiles and the picker @@ -338,23 +475,21 @@ local panelSig local presetOptions = {} --- Picker contents: the shipped profiles, the player's own, and any unsaved fork. +-- Picker contents: the shipped presets, tagged as defaults, then the player's own, which the +-- open list sets apart with a rule. Staged edits mark whichever one is active with a "*", a +-- default included: the edits are real and unsaved either way, and where they will be saved +-- is for the footer to say, beside the button that saves them. local function buildPresetOptions() local active = profiles.activeName() - -- Editing a shipped profile does not change it: what is on screen is an unsaved new - -- profile, so the picker says that instead of marking the read-only one as modified. - local pending = dirty and profiles.isBuiltin(active) ~= nil presetOptions = {} for _, b in ipairs(profiles.builtins) do - presetOptions[#presetOptions + 1] = { label = b.name, name = b.name, builtin = true } + local label = (dirty and b.name == active) and (b.name .. " *") or b.name + presetOptions[#presetOptions + 1] = { label = label, name = b.name, tag = L.defaultTag, group = "default" } end for _, name in ipairs(profiles.list()) do - local marked = (dirty and name == active) and (name .. " *") or name - presetOptions[#presetOptions + 1] = { label = marked, name = name } - end - if pending then - presetOptions[#presetOptions + 1] = { label = L.newProfile .. " *", pending = true } + local label = (dirty and name == active) and (name .. " *") or name + presetOptions[#presetOptions + 1] = { label = label, name = name, group = "own" } end return presetOptions @@ -370,19 +505,14 @@ local function buttonEnabled(id) if id == "save" or id == "reset" then return dirty end - if id == "duplicate" then - return true + if id == "edit" then + return activeIsOwn() end - return activeIsOwn() + return true end local function currentPresetIndex() - local last = presetOptions[#presetOptions] - if last and last.pending then - return #presetOptions - end - local name = profiles.activeName() for i = 1, #presetOptions do if presetOptions[i].name == name then @@ -398,7 +528,7 @@ end ---------------------------------------------------------------- local function listBottom() - return area.y1 + footerH + metrics.footerGap + return area.y1 + state.footerH + metrics.footerGap end -- Whole rows the band can paint. @@ -517,9 +647,43 @@ local function buildResolvedCatalog() labelPlacesArg = {} resolvedCatalog = {} catalogAny, catalogAnyPrefixes, catalogShiftPair = {}, {}, {} + state.hidden, state.labels = {}, {} + + -- What an action does, for its tooltip: the catalog's own key when it names one, else the + -- command card's tooltip for a row labelled off the card, else the engine's description of + -- the command - a string of its own, the heading of a structured one, or a gadget's. Asked + -- for with an empty default, so a missing key is silent and reads as none. + local function describe(item) + local command = (item.action or item.prefix or ""):match("^%S+") + -- Appended one by one: a nil in a table constructor ends what ipairs walks. + local keys = {} + if item.description then + keys[#keys + 1] = item.description + end + if item.label and item.label:sub(1, 9) == "commands." then + keys[#keys + 1] = item.label .. "_tooltip" + end + if command then + keys[#keys + 1] = "cmd." .. command + keys[#keys + 1] = "cmd." .. command .. "._description" + keys[#keys + 1] = "cmd.luarules." .. command + end + for _, key in ipairs(keys) do + local found = BAR.I18N(key, { default = "" }) + if type(found) == "string" and found ~= "" and found ~= key then + return found + end + end + + return nil + end + for _, group in ipairs(catalog) do if group.hidden then resolvedCatalog[#resolvedCatalog + 1] = { hidden = group.hidden, title = "", titleLower = "", items = {} } + for _, h in ipairs(group.hidden) do + state.hidden[h] = true + end else local title = BAR.I18N(group.category) local g = { @@ -534,8 +698,13 @@ local function buildResolvedCatalog() if item.alwaysModifier == "any" then catalogAnyPrefixes[#catalogAnyPrefixes + 1] = item.prefix end - g.items[#g.items + 1] = - { prefix = item.prefix, label = item.label, unit = item.unit, members = item.members } + g.items[#g.items + 1] = { + prefix = item.prefix, + label = item.label, + unit = item.unit, + members = item.members, + description = describe(item), + } else if item.action then if item.alwaysModifier == "any" then @@ -545,12 +714,21 @@ local function buildResolvedCatalog() end end local label = BAR.I18N(item.label) + local stem = item.action and look.cursors[item.action:match("^%S+")] + local cursor = stem and look.cursorTextures[stem] or nil g.items[#g.items + 1] = { action = item.action, actionLower = item.action and item.action:lower(), label = label, labelLower = label:lower(), + cursor = cursor, + description = describe(item), } + if item.action then + state.labels[item.action] = label + end + -- One picture in a group gives every row in it the column, so the names line up. + g.hasCursors = g.hasCursors or cursor ~= nil end end if g.layout == "grid" then @@ -613,10 +791,40 @@ local function buildResolvedCatalog() if otherCategoryKey == generatedOtherKey then categories[#categories + 1] = { label = L.other, key = otherCategoryKey } end + -- A fresh column has no Changed entry, whatever the count was: forgotten here so the next + -- rebuild of the rows puts it back. Every keyreload comes through here, so without this + -- the entry went missing until a preset switch happened to move the count. + state.changedCount = -1 L.pressKey = BAR.I18N("ui.keybinds.editor.pressKey") + L.preset = BAR.I18N("ui.keybinds.editor.preset") + -- Dim, so the preset name in the picker beside it stays the thing that is read. + L.presetText = colorDim .. L.preset + L.defaultTag = BAR.I18N("ui.keybinds.editor.defaultTag") L.newProfile = BAR.I18N("ui.keybinds.editor.newProfile") L.duplicate = BAR.I18N("ui.keybinds.editor.duplicate") + L.duplicateTooltip = BAR.I18N("ui.keybinds.editor.duplicateTooltip") L.edit = BAR.I18N("ui.keybinds.editor.edit") + L.editTooltip = BAR.I18N("ui.keybinds.editor.editTooltip") + L.editLockedTooltip = BAR.I18N("ui.keybinds.editor.editLockedTooltip") + L.saveAsNew = BAR.I18N("ui.keybinds.editor.saveAsNew") + L.noticeDefault = BAR.I18N("ui.keybinds.editor.noticeDefault") + L.noticeDefaultUnsaved = BAR.I18N("ui.keybinds.editor.noticeDefaultUnsaved") + L.noticeUnsaved = BAR.I18N("ui.keybinds.editor.noticeUnsaved") + L.changed = BAR.I18N("ui.keybinds.editor.changed") + L.conflictOrder = BAR.I18N("ui.keybinds.editor.conflictOrder") + L.conflictShipped = BAR.I18N("ui.keybinds.editor.conflictShipped") + L.revertHint = BAR.I18N("ui.keybinds.editor.revertHint") + L.revertNone = BAR.I18N("ui.keybinds.editor.revertNone") + L.presetDefault = BAR.I18N("ui.keybinds.editor.presetDefault") + L.presetOwn = BAR.I18N("ui.keybinds.editor.presetOwn") + L.export = BAR.I18N("ui.keybinds.editor.export") + L.exportTooltip = BAR.I18N("ui.keybinds.editor.exportTooltip") + L.import = BAR.I18N("ui.keybinds.editor.import") + L.importTooltip = BAR.I18N("ui.keybinds.editor.importTooltip") + L.importTitle = BAR.I18N("ui.keybinds.editor.importTitle") + L.importEmpty = BAR.I18N("ui.keybinds.editor.importEmpty") + L.importNone = BAR.I18N("ui.keybinds.editor.importNone") + L.ok = BAR.I18N("ui.keybinds.editor.ok") L.editTitle = BAR.I18N("ui.keybinds.editor.editTitle") L.delete = BAR.I18N("ui.keybinds.editor.delete") L.duplicateTitle = BAR.I18N("ui.keybinds.editor.duplicateTitle") @@ -632,6 +840,88 @@ local function buildResolvedCatalog() L.cancel = BAR.I18N("ui.keybinds.editor.cancel") end +-- A keyset's canonical form, kept on the keyset record against the raw it came from: the +-- change and conflict checks below run for every row on every rebuild. +local function canonOf(k) + if k.canonFor ~= k.raw then + k.canon, k.canonFor = keybindModel.canonicalKeyset(k.raw), k.raw + end + + return k.canon +end + +-- How an action's keys differ from the preset the active one is measured against: nil when +-- they match, or there is nothing to measure against; else the base's raws for the action, +-- which may be none at all. Compared as sets of canonical keysets, so spelling and order do +-- not count as a change. +local function rowChange(action) + local base = state.base + if not base then + return nil + end + + local theirs = base.byAction[action] + local seen, n = {}, 0 + for _, k in ipairs(working.byAction[action] or look.noRaws) do + local c = canonOf(k) + if not seen[c] then + seen[c] = true + n = n + 1 + if not (theirs and theirs.set[c]) then + return theirs and theirs.raws or look.noRaws + end + end + end + if (theirs and theirs.n or 0) ~= n then + return theirs and theirs.raws or look.noRaws + end + + return nil +end + +-- The other listed actions these keysets drive, in bind order, each flagged when the engine +-- tries it before this action, and when the game itself ships the two on one key - sharing +-- by design, which is no clash of the player's making. Nil when there are none. Hidden +-- actions are left out: one sharing a key with a listed action is how the catalog says the +-- two belong together. +local function conflictsOf(action, raws) + local byKeyset = working.byKeyset + if not byKeyset then + return nil + end + + local out, seen + for _, raw in ipairs(raws) do + -- Any holder at all: for a key being captured this action is not among them yet. + local list = byKeyset[keybindModel.canonicalKeyset(raw)] + if list then + local mine + for i = 1, #list do + if list[i] == action then + mine = i + break + end + end + for i = 1, #list do + local other = list[i] + if other ~= action and not state.hidden[other] and not (seen and seen[other]) then + seen = seen or {} + seen[other] = true + out = out or {} + local pair = (action < other) and (action .. "\n" .. other) or (other .. "\n" .. action) + out[#out + 1] = { + action = other, + before = mine ~= nil and i < mine, + shipped = state.shippedPairs ~= nil and state.shippedPairs[pair] == true, + } + end + end + end + end + + return out +end + -- Rebuilds the display list from the catalog and the staged binds, honouring both the -- search box and the category column. local function rebuildRows() @@ -659,6 +949,69 @@ local function rebuildRows() return end local query = Search.query(searchBox and searchBox:getText()) + + -- Which actions share each keyset, in bind order, so a chip can say what else its key + -- drives and a capture can warn before a key is taken. Rebuilt with the rows, which every + -- edit rebuilds. + local byKeyset = {} + for _, b in ipairs(working.binds) do + local c = keybindModel.canonicalKeyset(b.keyset) + local list = byKeyset[c] + if not list then + list = {} + byKeyset[c] = list + end + local listed = false + for i = 1, #list do + if list[i] == b.action then + listed = true + break + end + end + if not listed then + list[#list + 1] = b.action + end + end + working.byKeyset = byKeyset + + -- The column's Changed entry keeps only rows that differ from the base preset. How many + -- there are is counted whatever is shown, since its label says so. + local changedOnly = selectedCategory == state.changedKey + local changedCount = 0 + + -- A query can name keys as well as words. An action matches by key when one of its chips holds + -- every key the query names, modifiers included and in any order, so "ctrl+q", "ctrl q" and + -- "q ctrl" all find what Ctrl+Q does. Whole keys only, as the chips print them: "f1" does not + -- find F11, and a paired action's hidden Shift half does not answer to "shift". + local wantKeys = {} + for key in query.text:gmatch("[^%s%+]+") do + wantKeys[#wantKeys + 1] = key + end + local function boundToQuery(action) + if not (wantKeys[1] and action) then + return false + end + local pair = catalogShiftPair[action] + for _, k in ipairs(working.byAction[action] or {}) do + -- The chip's text, which for a paired action is not the keyset's own. Kept on the keyset + -- against the raw it came from, since this runs for every row on every keystroke. + local shown = k.display + if pair then + if k.unshiftedFor ~= k.raw then + k.unshifted, k.unshiftedFor = keybindModel.displayWithoutShift(k.raw, working.layout), k.raw + end + shown = k.unshifted + end + if keybindModel.holdsKeys(shown, wantKeys) then + return true + end + end + + return false + end + -- Rows found by key are listed ahead of everything found by name, under a heading of their + -- own, and only there. Gathered as they are met, so they keep the catalog's order. + local keyRows = {} local catalogActions = {} local otherGroupEnd @@ -675,11 +1028,12 @@ local function rebuildRows() for _, group in ipairs(resolvedCatalog) do -- Non-selected groups are still walked: they have to claim their actions or the -- leftovers below would sweep them all into Other. - local inCategory = not selectedCategory or group.category == selectedCategory + local inCategory = not selectedCategory or changedOnly or group.category == selectedCategory -- A group whose own title matches keeps every row under it, so searching for a -- category's name shows the category rather than emptying it. local categoryMatch = Search.claims(query, group.titleLower) local groupRows = {} + local keyLink for _, item in ipairs(group.items) do -- An empty prefix would claim every bound action, so treat it as no prefix. if item.prefix and item.prefix ~= "" then @@ -728,12 +1082,35 @@ local function rebuildRows() end local row, col = arg:match("^%s*(%S+)%s+(%S+)") local label = item.label and prefixRowLabel(item.label, arg, row, col) or action + state.labels[action] = label + local change = rowChange(action) + if change then + changedCount = changedCount + 1 + end + local byKey = inCategory and boundToQuery(action) if - categoryMatch - or Search.matches(query, action:lower()) - or Search.matches(query, label:lower()) + (change or not changedOnly) + and ( + byKey + or categoryMatch + or Search.matches(query, action:lower()) + or Search.matches(query, label:lower()) + ) then - groupRows[#groupRows + 1] = { type = "editable", action = action, label = label } + local entry = { + type = "editable", + action = action, + label = label, + description = item.description, + change = change, + } + if not byKey then + groupRows[#groupRows + 1] = entry + elseif group.layout == "grid" then + keyLink = group + else + keyRows[#keyRows + 1] = entry + end end end -- Skip an action a hidden entry or an earlier prefix already claimed, so a @@ -742,16 +1119,45 @@ local function rebuildRows() if item.action then catalogActions[item.action] = true end + local change = rowChange(item.action) + if change then + changedCount = changedCount + 1 + end + local byKey = inCategory and boundToQuery(item.action) if - categoryMatch - or Search.matches(query, item.labelLower) - or Search.matches(query, item.actionLower) + (change or not changedOnly) + and ( + byKey + or categoryMatch + or Search.matches(query, item.labelLower) + or Search.matches(query, item.actionLower) + ) then - groupRows[#groupRows + 1] = { type = "editable", action = item.action, label = item.label } + local entry = { + type = "editable", + action = item.action, + label = item.label, + cursor = item.cursor, + cursorColumn = group.hasCursors, + description = item.description, + change = change, + } + if not byKey then + groupRows[#groupRows + 1] = entry + elseif group.layout == "grid" then + keyLink = group + else + keyRows[#keyRows + 1] = entry + end end end end + -- A grid category's keys only read laid out, so a key found among them points at that view. + if keyLink then + keyRows[#keyRows + 1] = { type = "link", label = group.title, category = group.category } + end + if inCategory and #groupRows > 0 then rows[#rows + 1] = { type = "header", text = group.title } if group.layout == "grid" then @@ -771,14 +1177,30 @@ local function rebuildRows() end local otherMatch = Search.claims(query, L.otherLower) - local others = {} + local others, otherKeyed = {}, {} + local inOther = not selectedCategory or changedOnly or selectedCategory == otherCategoryKey for action in pairs(working.byAction) do - if not catalogActions[action] and (otherMatch or Search.matches(query, action:lower())) then - others[#others + 1] = action + if not catalogActions[action] then + local change = rowChange(action) + if change then + changedCount = changedCount + 1 + end + if changedOnly and not change then + -- Not what the column entry asked for. + elseif inOther and boundToQuery(action) then + otherKeyed[#otherKeyed + 1] = action + elseif otherMatch or Search.matches(query, action:lower()) then + others[#others + 1] = action + end end end + -- Leftovers found by key join the other key rows, in a steady order. + table.sort(otherKeyed) + for _, action in ipairs(otherKeyed) do + keyRows[#keyRows + 1] = { type = "editable", action = action, label = action, change = rowChange(action) } + end - if #others > 0 and (not selectedCategory or selectedCategory == otherCategoryKey) then + if #others > 0 and inOther then table.sort(others) -- A catalog category can be titled the same as this generated one; when it is, @@ -794,13 +1216,56 @@ local function rebuildRows() end for _, action in ipairs(others) do - rows[#rows + 1] = { type = "editable", action = action, label = action } + rows[#rows + 1] = { type = "editable", action = action, label = action, change = rowChange(action) } end for i = 1, #tail do rows[#rows + 1] = tail[i] end end + -- The key rows go on top, under a heading that names the keys the way a chip would. One + -- cursor among them gives them all the column, as it does within a category. + if #keyRows > 0 then + -- Modifiers ahead of the key, as a chip prints them, whatever order they were typed in. + local modifierAt = { ctrl = 1, alt = 2, meta = 3, shift = 4 } + local keys, column = {}, false + for i = 1, #wantKeys do + keys[i] = { name = wantKeys[i]:upper(), at = (modifierAt[wantKeys[i]] or 5) * 100 + i } + end + table.sort(keys, function(a, b) + return a.at < b.at + end) + for i = 1, #keys do + keys[i] = keys[i].name + end + for i = 1, #keyRows do + column = column or keyRows[i].cursor ~= nil + end + local ordered = { + { type = "header", text = BAR.I18N("ui.keybinds.editor.boundTo", { keys = table.concat(keys, " + ") }) }, + } + for i = 1, #keyRows do + keyRows[i].cursorColumn = column + keyRows[i].hitKeys = wantKeys + ordered[#ordered + 1] = keyRows[i] + end + for i = 1, #rows do + ordered[#ordered + 1] = rows[i] + end + rows = ordered + end + + -- The column's Changed entry comes and goes with the count, and says it. Taking the entry + -- away from under the selection sends the column back to everything, which is a different + -- list from the one just built: built again, once, with the selection gone. + if state.changedCount ~= changedCount then + state.changedCount = changedCount + state.syncChangedEntry(state.base and changedCount or 0) + if changedOnly and selectedCategory ~= state.changedKey then + return rebuildRows() + end + end + clampScroll() end @@ -828,6 +1293,9 @@ local function seedWorkingFromEngine() end setDirty(false) + -- A fresh keymap has nothing to take back; what comes after is measured from here. + state.undo = {} + state.snapshot = state.snapshotOf() end -- Detached copy of the staged binds, for handing to the store. @@ -893,19 +1361,146 @@ local function applyActiveProfile(name, fromName) end end +-- The shipped preset the active one is measured against, and its keysets by action, redone +-- when the active preset's origin changes. The column's Changed entry comes and goes with it: +-- a preset with no known origin has nothing to have changed from. +function state.refreshBase() + -- Which pairs of actions the game itself puts on one key, in any shipped preset. Built + -- once, the shipped presets not changing. + if not state.shippedPairs then + local shipped = {} + for _, b in ipairs(profiles.builtins) do + local byKeyset = {} + for _, bind in ipairs(b.binds or {}) do + local c = keybindModel.canonicalKeyset(bind.keyset) + local list = byKeyset[c] + if not list then + list = {} + byKeyset[c] = list + end + local listed = false + for i = 1, #list do + if list[i] == bind.action then + listed = true + end + end + if not listed then + list[#list + 1] = bind.action + end + end + for _, list in pairs(byKeyset) do + for i = 1, #list do + for j = i + 1, #list do + local a, o = list[i], list[j] + shipped[(a < o) and (a .. "\n" .. o) or (o .. "\n" .. a)] = true + end + end + end + end + state.shippedPairs = shipped + end + + local builtin = profiles.baseOf(profiles.activeName()) + local wanted = builtin and builtin.name or nil + if (state.base and state.base.name) ~= wanted then + if builtin then + local byAction = {} + for _, b in ipairs(builtin.binds or {}) do + local entry = byAction[b.action] + if not entry then + entry = { set = {}, n = 0, raws = {} } + byAction[b.action] = entry + end + local c = keybindModel.canonicalKeyset(b.keyset) + if not entry.set[c] then + entry.set[c] = true + entry.n = entry.n + 1 + entry.raws[#entry.raws + 1] = b.keyset + end + end + state.base = { name = wanted, byAction = byAction } + else + state.base = nil + end + state.changedCount = -1 + end + if not state.base then + state.syncChangedEntry(0) + end +end + +-- The column's Changed entry, there only while there is something for it to list, with the +-- count in its label. With the entry gone from under the selection, the column falls back +-- to everything. +function state.syncChangedEntry(count) + local listed = categories[2] ~= nil and categories[2].key == state.changedKey + if count > 0 then + local label = BAR.I18N("ui.keybinds.editor.changedCount", { n = count }) + if not listed then + table.insert(categories, 2, { label = label, key = state.changedKey }) + state.refit = true + elseif categories[2].label ~= label then + categories[2].label = label + state.refit = true + end + elseif listed then + table.remove(categories, 2) + if selectedCategory == state.changedKey then + selectedCategory = nil + end + state.refit = true + end +end + local function refreshPicker() buildPresetOptions() presetDropdown:setOptions(presetOptions) presetDropdown:setSelected(currentPresetIndex()) + state.refreshBase() + -- Whether the active preset is a default settles the Save button's wording, and so its + -- width, and what the header tooltips say. Laid out again on the next draw, once, however + -- many times this runs before it. + state.layoutPending = true end --- Staging changes the picker too: the active profile picks up the unsaved marker. +-- Files the keymap as it stood before the edit just made, then takes the one it stands at +-- now, for the edit after. +function state.pushUndo() + state.undo[#state.undo + 1] = state.snapshot + state.snapshot = state.snapshotOf() +end + +-- Staging changes the picker too: the active profile picks up the unsaved marker. A gesture +-- that stages several edits files one snapshot for the lot, once it is done. local function markStaged() + if state.batching then + state.batchEdited = true + else + state.pushUndo() + end setDirty(true) refreshPicker() rebuildRows() end +-- Ctrl+Z: the last edit taken back. With none left the keymap is what was loaded, so there +-- is nothing unsaved either. Answers whether there was anything to take back. +function state.undoEdit() + local snap = table.remove(state.undo) + if not snap then + return false + end + + working.binds, working.byAction = snap.binds, snap.byAction + -- Copied again: the restored tables are live now, and the next edit rewrites them. + state.snapshot = state.snapshotOf() + setDirty(#state.undo > 0) + refreshPicker() + rebuildRows() + + return true +end + -- Staged edits live only in `working`, so throwing them away means re-reading the engine. -- Clearing the flag on its own would leave the edits on screen and still saveable. local function discardStaged() @@ -976,7 +1571,8 @@ local function dialogName() local name = nameBox:getText():gsub("^%s+", ""):gsub("%s+$", "") local taken = name ~= dialog.allow and (profiles.get(name) ~= nil or profiles.isBuiltin(name) ~= nil) - return name, name == "" or taken + -- A dialog can be blocked outright, like an import with nothing to import. + return name, name == "" or taken or dialog.blocked == true end -- Confirmation path; only a dialog with a name field has text to read. @@ -1014,6 +1610,9 @@ local function selectProfile(name, fromName) profiles.setActive(name) setDirty(false) + -- What was staged is now the preset's own, or gone with the switch: nothing to take back. + state.undo = {} + state.snapshot = state.snapshotOf() refreshPicker() applyActiveProfile(name, fromName) @@ -1061,7 +1660,8 @@ local function startSave(andThen, onCancel) title = L.saveTitle, initial = profiles.uniqueName(L.newProfile), accept = function(newName) - local created = profiles.create(newName, stagedBinds(), activeFakeMeta()) + -- Forked from the default on screen, which the new preset records as its origin. + local created = profiles.create(newName, stagedBinds(), activeFakeMeta(), name) if applyStaged(created, name) and andThen then andThen() end @@ -1082,7 +1682,8 @@ local function guardDirty(proceed, onCancel) openDialog({ title = L.unsavedTitle, message = L.unsavedMessage, - acceptLabel = L.save, + -- Worded like the footer's Save, which this stands in for. + acceptLabel = activeIsOwn() and L.save or L.saveAsNew, save = true, accept = function() startSave(proceed, onCancel) @@ -1102,8 +1703,9 @@ local function guardDirty(proceed, onCancel) end switchToPreset = function(opt) - -- The pending entry is already what is on screen; picking it is not a switch. - if opt.pending then + -- Already what is on screen, staged edits and all, so picking it again is not a switch: + -- it would only ask about edits the player has not tried to leave. + if opt.name == profiles.activeName() then return end @@ -1138,12 +1740,71 @@ local function startDuplicate() initial = profiles.uniqueName(from), accept = function(name) -- Copies what is on screen rather than what was last saved, so pending - -- edits come along instead of being silently dropped. - applyStaged(profiles.create(name, stagedBinds(), activeFakeMeta()), from) + -- edits come along instead of being silently dropped. The copy descends from + -- whatever the original did. + local base = profiles.baseOf(from) + applyStaged(profiles.create(name, stagedBinds(), activeFakeMeta(), base and base.name), from) end, }) end +-- Export copies the preset on screen, staged edits included, to the clipboard as the text the +-- engine loads; Import reads such text back as a new preset of the player's own. +local function startClipboard(exporting) + if exporting then + local name = profiles.activeName() + Spring.SetClipboard(profiles.exportText({ name = name, binds = stagedBinds(), fakeMeta = activeFakeMeta() })) + openDialog({ + title = L.export, + message = BAR.I18N("ui.keybinds.editor.exportDone", { name = name }), + info = true, + acceptLabel = L.ok, + accept = function() end, + }) + + return + end + + local clip = Spring.GetClipboard() + if type(clip) ~= "string" or clip:match("^%s*$") then + openDialog({ title = L.import, message = L.importEmpty, info = true, acceptLabel = L.ok, accept = function() end }) + + return + end + + -- What the reader will take, line by line, shown before it is taken: the lines it will + -- drop in red, and a count of each above them. With nothing readable the dialog still + -- opens, so the player can see why, but cannot be accepted. + local lines, count, errors = profiles.classifyBindFile(clip) + local binds, fakeMeta, stamped = profiles.parseBindFile(clip) + local summary = binds and (colorText .. BAR.I18N("ui.keybinds.editor.importSummary", { n = count })) + or (colorDanger .. L.importNone) + if errors > 0 then + summary = summary .. colorDim .. ", " .. colorHeader .. BAR.I18N("ui.keybinds.editor.importErrors", { n = errors }) + end + local function open() + openDialog({ + title = L.importTitle, + initial = profiles.uniqueName(stamped or L.newProfile), + preview = { lines = lines, summary = summary, scroll = 0 }, + blocked = binds == nil, + acceptLabel = L.import, + accept = function(newName) + -- Named like a copy is, then made live: importing is switching to it. + selectProfile(profiles.create(newName, binds, fakeMeta), profiles.activeName()) + end, + }) + end + + -- Importing replaces what is on screen, so staged edits are asked about first - but not + -- over a preview that cannot be accepted anyway. + if binds then + guardDirty(open) + else + open() + end +end + -- Renaming and deleting share one dialog: the name field commits a rename, the -- middle button deletes. Deleting asks again, since it cannot be undone. local function startEdit() @@ -1197,7 +1858,7 @@ local function ensureControls() clearable = true, onChange = rebuildRows, }) - presetDropdown = Dropdown.new({ options = presetOptions, onSelect = switchToPreset }) + presetDropdown = Dropdown.new({ options = presetOptions, onSelect = switchToPreset, markSelected = true }) nameBox = Editbox.new({ maxChars = 40 }) end @@ -1206,7 +1867,7 @@ end -- falls back to a width and asks draw to lay out again once the font is there. local function labelWidth(label, size, pad) if not font then - layoutPending = true + state.layoutPending = true return floor(110 * scale) end @@ -1221,43 +1882,63 @@ end -- Header and footer rects, placed right to left from the panel edge. local function layoutHeader() - headerH = floor(34 * scale) - footerH = floor(34 * scale) + state.headerH = floor(34 * scale) + state.footerH = floor(34 * scale) if not (searchBox and presetDropdown) then return end - layoutPending = false + state.layoutPending = false local gap = floor(8 * scale) local rowTop = area.y2 - floor(4 * scale) - local rowBottom = area.y2 - headerH + floor(4 * scale) - local presetW = floor(240 * scale) + local rowBottom = area.y2 - state.headerH + floor(4 * scale) + -- Room for the longest shipped name beside its Default tag. + local presetW = floor(280 * scale) local btnFs = floor((rowTop - rowBottom) * 0.5) + local bfs = floor(rowHeight * 0.55) - -- Right to left: the edit dialog opener, duplicate, then the picker they act on. + -- Right to left: the clipboard buttons, the edit dialog opener, duplicate, the picker they + -- act on, then the picker's caption. Icon buttons are square; captioned ones fit their word. local iconW = rowTop - rowBottom - local editW, dupW = iconW, iconW - local rightEdge = area.x2 - metrics.edgeInset - local editX1 = rightEdge - editW - local dupX1 = editX1 - gap - dupW - local pickerX1 = dupX1 - gap - presetW - - headerButtons[1].rect = { dupX1, rowBottom, dupX1 + dupW, rowTop } - headerButtons[2].rect = { editX1, rowBottom, rightEdge, rowTop } + local bx2 = area.x2 - metrics.edgeInset + for i = #headerButtons, 1, -1 do + local b = headerButtons[i] + local w = iconW + if not b.icon then + local label = L[b.id] or b.id + w = labelWidth(label, bfs, floor(10 * scale)) + if font then + b.textOn = colorText .. label + b.textOff = colorFaded .. label + end + end + b.rect = { bx2 - w, rowBottom, bx2, rowTop } + bx2 = bx2 - w - gap + end + local pickerX1 = bx2 - presetW + metrics.presetLabelX = pickerX1 - gap - labelWidth(L.preset or "", btnFs, 0) + metrics.presetLabelFs = btnFs + if font then + -- The baseline the picker and the search field print their own text on. + metrics.presetLabelY = text.baseline(font, rowBottom, rowTop, btnFs) + end + presetDropdown:setRect(pickerX1, rowBottom, pickerX1 + presetW, rowTop, btnFs) - searchBox:setRect(listX1, rowBottom, pickerX1 - gap, rowTop, btnFs) + -- Twice the gap on this side, so the caption reads as the picker's and not the field's. + searchBox:setRect(listX1, rowBottom, metrics.presetLabelX - gap * 2, rowTop, btnFs) - local fTop = area.y1 + footerH - floor(4 * scale) + local fTop = area.y1 + state.footerH - floor(4 * scale) local fBottom = area.y1 + floor(4 * scale) local fFs = floor((fTop - fBottom) * 0.5) local fPad = floor(14 * scale) - local bfs = floor(rowHeight * 0.55) local x2 = area.x2 - metrics.edgeInset + -- A default cannot take the edits, so its Save is worded for where they go instead. + local own = activeIsOwn() for i = #footerButtons, 1, -1 do local b = footerButtons[i] - local label = L[b.id] or b.id + local label = (b.id == "save" and not own and L.saveAsNew) or L[b.id] or b.id local w = labelWidth(label, fFs, fPad) b.rect = { x2 - w, fBottom, x2, fTop } x2 = x2 - w - gap @@ -1269,14 +1950,30 @@ local function layoutHeader() end end + -- The footer notice gets what the buttons leave: from the list's left edge to a double gap + -- short of the first button. Each wording is fitted here, so the bake only picks one. + metrics.noticeX = listX1 + metrics.noticeFs = floor(rowHeight * 0.5) + if font then + local nfs = metrics.noticeFs + local noticeW = x2 - gap - listX1 + metrics.noticeY = text.baseline(font, fBottom, fTop, nfs) + L.noticeDefaultText = colorDim .. text.fit(font, L.noticeDefault or "", noticeW, nfs) + L.noticeDefaultUnsavedText = colorHeader .. text.fit(font, L.noticeDefaultUnsaved or "", noticeW, nfs) + L.noticeUnsavedText = colorHeader .. text.fit(font, L.noticeUnsaved or "", noticeW, nfs) + end + -- New rects, so the tooltip areas have to be handed over again. - tooltipsRegistered = false + state.tooltipsRegistered = false end -- Profile-modal geometry, derived in one place so draw and mousePress agree. +-- A dialog with a preview is wider and taller, the preview taking the room above the name +-- field; an information dialog has one button, OK, in the middle, and no Cancel. local function dialogGeometry() - local w = floor(315 * scale) - local h = floor(150 * scale) + local preview = dialog and dialog.preview + local w = floor((preview and 620 or 315) * scale) + local h = floor((preview and 420 or 150) * scale) local messageLines, messageStep if dialog and dialog.message and font then messageStep = floor(rowHeight * 0.75) @@ -1293,11 +1990,15 @@ local function dialogGeometry() local bfs = floor(bh * 0.5) local bpad = floor(14 * scale) - local cancelW = labelWidth(L.cancel, bfs, bpad) - local cancel = { bx1 + pad, btnY1, bx1 + pad + cancelW, btnY1 + bh } - local okW = labelWidth(dialog and acceptLabelFor(dialog) or L.save, bfs, bpad) - local ok = { bx2 - pad - okW, btnY1, bx2 - pad, btnY1 + bh } + local ok, cancel + if dialog and dialog.info then + ok = { floor(cx - okW * 0.5), btnY1, floor(cx + okW * 0.5), btnY1 + bh } + else + local cancelW = labelWidth(L.cancel, bfs, bpad) + cancel = { bx1 + pad, btnY1, bx1 + pad + cancelW, btnY1 + bh } + ok = { bx2 - pad - okW, btnY1, bx2 - pad, btnY1 + bh } + end local midW = labelWidth(dialog and dialog.middle and dialog.middle.label or L.discard, bfs, bpad) local midX = (bx1 + bx2) * 0.5 @@ -1305,7 +2006,13 @@ local function dialogGeometry() local fieldY1 = btnY1 + bh + floor(20 * scale) local field = { bx1 + pad, fieldY1, bx2 - pad, fieldY1 + floor(26 * scale) } - return bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep + -- The preview box, from above the field to under the summary line beneath the title. + local box + if preview then + box = { bx1 + pad, field[4] + floor(14 * scale), bx2 - pad, by2 - floor(66 * scale) } + end + + return bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep, box end -- Capture-modal geometry, derived in one place so draw and mousePress agree. @@ -1397,6 +2104,7 @@ function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) metrics.sidePad = floor(12 * scale) metrics.catInset = floor(4 * scale) metrics.chipInset = floor(3 * scale) + metrics.cursorIcon = floor(rowHeight * 0.8) -- Set before layoutHeader below, which places the header and footer buttons against it. metrics.edgeInset = floor(4 * scale) metrics.footerGap = floor(8 * scale) @@ -1417,7 +2125,7 @@ function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) layoutHeader() - listTop = area.y2 - headerH - floor(4 * scale) + listTop = area.y2 - state.headerH - floor(4 * scale) -- The scrollbar owns a column of its own: its right edge lines up with the buttons -- above it, and the list stops a clear gap short of it rather than running up against -- it. That gap matches the one the bar keeps from the panel edge on its other side, so @@ -1442,11 +2150,12 @@ function view.blur() WG["tooltip"].RemoveTooltip(b.tooltipId) end end - tooltipsRegistered = false - if panelList then - gl.DeleteList(panelList) - panelList = nil - panelSig = nil + state.tooltipsRegistered = false + state.tipKey = nil + if state.panelList then + gl.DeleteList(state.panelList) + state.panelList = nil + state.panelSig = nil end if searchBox then searchBox:blur() @@ -1634,8 +2343,8 @@ end -- Edit entry point: move a binding, and mark the profile staged. local function rebindKeyset(action, oldRaw, newKeyset) - -- Accepting the capture unchanged is not an edit. Staging it would arm Save, grow a - -- pending entry in the picker, and raise the unsaved-changes guard over nothing. + -- Accepting the capture unchanged is not an edit. Staging it would arm Save, mark the + -- preset unsaved, and raise the unsaved-changes guard over nothing. if newKeyset == oldRaw then return end @@ -1669,6 +2378,22 @@ local function removeKeyset(action, raw) markStaged() end +-- Puts the base preset's keys back on an action: what clicking the ghost chip does. +function state.revert(action) + local change = rowChange(action) + if not change then + return + end + + local raws = {} + for i = 1, #change do + raws[i] = change[i] + end + if stageSetKeysets(action, raws) then + markStaged() + end +end + -- One key can drive several actions (e.g. backspace = mutesound + edit_backspace), -- so add the binding without disturbing others on the same keyset. local function commitCapture(keyset) @@ -1683,6 +2408,8 @@ local function commitCapture(keyset) capturing = nil + -- One gesture, however many edits it comes to below, files one snapshot to take back. + state.batching, state.batchEdited = true, false if catalogShiftPair[c.action] then if stageSetKeysets(c.action, shiftPairRaws(c.elems)) then markStaged() @@ -1703,6 +2430,10 @@ local function commitCapture(keyset) else addKeyset(c.action, keyset) end + state.batching = false + if state.batchEdited then + state.pushUndo() + end end ---------------------------------------------------------------- @@ -1976,12 +2707,7 @@ local function rowChipGroups(action) local pair = catalogShiftPair[action] local groups, byDisplay = {}, {} for _, k in ipairs(working.byAction[action] or {}) do - local shown = k.display - if pair then - local parts = keybindModel.splitChain(k.raw) - parts[1] = (parts[1]:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) - shown = keybindModel.displayKeyset(table.concat(parts, ","), working.layout) - end + local shown = pair and keybindModel.displayWithoutShift(k.raw, working.layout) or k.display local group = byDisplay[shown] if not group then @@ -2034,12 +2760,12 @@ end -- The chip band for a row: where each chip sits, where "+" starts after them, and the widths -- both callers need. Drawing and hit testing take it from here rather than each deriving the -- same eight constants, so the click zones cannot drift from what was painted. -local function rowChipBand(action, fs, pad) +local function rowChipBand(action, fs, pad, reserve) local gap = floor(6 * scale) local rightGap = pad + floor(fs * 0.9) local addW = floor(fs + pad * 2) - -- Room reserved on the right so "+" always fits. - local chipArea = listRight - addW - floor(8 * scale) - metrics.keyAreaX1 + -- Room reserved on the right so "+" always fits, and whatever the caller wants after it. + local chipArea = listRight - addW - floor(8 * scale) - metrics.keyAreaX1 - (reserve or 0) local mets, cx = layoutRowChips(action, fs, pad, rightGap, chipArea, gap) return mets, cx, addW, rightGap @@ -2066,14 +2792,62 @@ local function rowLayout(row) + floor(font:GetTextWidth(row.label) * metrics.rowFs) + metrics.rowPad * 2 else - local labelW = metrics.keyAreaX1 - (listX1 + metrics.rowPad) - metrics.rowPad + -- The cursor column, when the row's group has one, comes out of the name's room. + local indent = row.cursorColumn and (metrics.cursorIcon + metrics.rowPad) or 0 + lay.textX = listX1 + metrics.rowPad + indent + lay.icon = row.cursor + lay.iconX = listX1 + metrics.rowPad + local labelW = metrics.keyAreaX1 - lay.textX - metrics.rowPad lay.text = colorAction .. text.fit(font, row.label, labelW, metrics.rowFs) - local mets, cx, addW, rightGap = rowChipBand(row.action, metrics.rowFs, metrics.rowPad) + + -- The key the base preset had, when the row's differs: a hollow chip after the row's + -- own, which the chips make room for. Paired halves read as one key, as the chips do. + local change = row.change + if change then + local shown, seen = {}, {} + local pair = catalogShiftPair[row.action] + for _, raw in ipairs(change) do + local disp = pair and keybindModel.displayWithoutShift(raw, working.layout) + or keybindModel.displayKeyset(raw, working.layout) + if not seen[disp] then + seen[disp] = true + shown[#shown + 1] = disp + end + end + local keys = #shown > 0 and table.concat(shown, ", ") or L.revertNone + lay.ghostFs = floor(metrics.rowFs * 0.9) + keys = text.fit(font, keys, floor((listRight - metrics.keyAreaX1) * 0.3), lay.ghostFs) + lay.ghostKeys = keys + -- The caption is the same string twice with the keys marked off, so the colour split + -- lands on the keys wherever a translation puts them. + local caption = BAR.I18N("ui.keybinds.editor.revertChip", { keys = "\1" }) + local before, after = caption:match("^(.-)\1(.*)$") + before, after = before or caption, after or "" + lay.ghostText = colorFaded .. before .. look.ghostKeys .. keys .. colorFaded .. after + lay.ghostTextHover = colorDim .. before .. colorHeader .. keys .. colorDim .. after + lay.ghostW = floor(font:GetTextWidth(before .. keys .. after) * lay.ghostFs) + metrics.rowPad * 2 + -- Against the list's right edge, clear of the row's own keys and "+". + lay.ghostX = listRight - metrics.rowPad - lay.ghostW + end + + local reserve = lay.ghostW and (lay.ghostW + metrics.rowPad * 2) or 0 + local mets, cx, addW, rightGap = rowChipBand(row.action, metrics.rowFs, metrics.rowPad, reserve) for i = 1, #mets do local m = mets[i] m.textKey = colorKey .. m.disp m.textHover = colorText .. m.disp m.removeCx = floor(m.removeX1 + rightGap * 0.5) + -- On a row found by key, the chip that answered is lit, so it reads why the row is here. + m.hit = row.hitKeys ~= nil and keybindModel.holdsKeys(m.group.display, row.hitKeys) + -- What else the chip's key drives, for its tooltip; reddened only for sharing of the + -- player's own making. + m.others = conflictsOf(row.action, m.group.raws) + m.clash = false + for _, o in ipairs(m.others or look.noRaws) do + if not o.shipped then + m.clash = true + end + end end lay.mets = mets lay.cx = cx @@ -2110,6 +2884,10 @@ local function rowZone(lay, x, y, c1, c2) return "add" end + if lay.ghostW and x >= lay.ghostX and x <= lay.ghostX + lay.ghostW then + return "revert" + end + return nil end @@ -2339,8 +3117,10 @@ local function drawSidebar(hoverIdx) ) end - -- Laid out before the font existed, so the labels are still waiting to be fitted. - if categories[1] and not categories[1].textDim then + -- Laid out before the font existed, so the labels are still waiting to be fitted; or one of + -- them changed since, which is the Changed entry's count. + if state.refit or (categories[1] and not categories[1].textDim) then + state.refit = false fitCategories() end @@ -2615,7 +3395,20 @@ local function drawRow(row, top, bottom, hovered, zone, zoneIdx) return end - queueText(lay.text, listX1 + metrics.rowPad, cyc, fs, "ov") + -- The order's cursor: geometry, so it goes down ahead of the queued text. Blending is set + -- rather than assumed, as for the header icons, since whatever drew before can leave one + -- that shows the picture's transparent surround as a solid square. + if lay.icon then + local s = metrics.cursorIcon + local iy = floor((top + bottom - s) * 0.5) + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + glColor(1, 1, 1, look.cursorAlpha) + glTexture(lay.icon) + glTexRect(lay.iconX, iy, lay.iconX + s, iy + s) + glTexture(false) + glColor(1, 1, 1, 1) + end + queueText(lay.text, lay.textX, cyc, fs, "ov") local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset local mets = lay.mets @@ -2623,7 +3416,11 @@ local function drawRow(row, top, bottom, hovered, zone, zoneIdx) local m = mets[i] local overBody = zone == "rebind" and zoneIdx == i local overRemove = zone == "remove" and zoneIdx == i - RectRound(m.x, c1, m.x + m.w, c2, metrics.csSmall, 1, 1, 1, 1, overBody and look.chipFillHover or look.chipFill) + local chipFill = (overBody and look.chipFillHover) + or (m.hit and look.chipFillHit) + or (m.clash and look.chipFillConflict) + or look.chipFill + RectRound(m.x, c1, m.x + m.w, c2, metrics.csSmall, 1, 1, 1, 1, chipFill) queueText(overBody and m.textHover or m.textKey, m.x + metrics.rowPad, cyc, m.fs, "ov") queueText(overRemove and look.removeHot or look.removeCold, m.removeCx, cyc, fs, "cov") end @@ -2634,6 +3431,14 @@ local function drawRow(row, top, bottom, hovered, zone, zoneIdx) RectRound(cx, c1, cx + lay.addW, c2, metrics.csSmall, 1, 1, 1, 1, overAdd and look.addFillHover or look.addFill) queueText(overAdd and look.plusTextHover or look.plusText, floor(cx + lay.addW * 0.5), cyc, fs, "cov") end + + -- The base preset's key, as a hollow chip: a border with the row's own dark inside it. + if lay.ghostW then + local gx, over = lay.ghostX, zone == "revert" + RectRound(gx, c1, gx + lay.ghostW, c2, metrics.csSmall, 1, 1, 1, 1, over and look.ghostBorderHover or look.ghostBorder) + RectRound(gx + 1, c1 + 1, gx + lay.ghostW - 1, c2 - 1, metrics.csSmall, 1, 1, 1, 1, look.ghostInner) + queueText(over and lay.ghostTextHover or lay.ghostText, gx + metrics.rowPad, cyc, lay.ghostFs, "ov") + end end -- Split out of view.draw: each modal is self-contained, and one function holding every @@ -2750,7 +3555,25 @@ local function drawCaptureModal(mx, my) local chainCy = by1 + floor(122 * scale) local lineStep = floor(chainFs * 1.15) + -- Other actions already on the keyset being formed, named under it before it is accepted. + local clash + if canAccept then + local raws = capturing.pair and shiftPairRaws(capturing.elems) or { chainRaw() } + local others = conflictsOf(capturing.action, raws) + if others then + local names = {} + for i = 1, #others do + names[i] = state.labels[others[i].action] or others[i].action + end + local line = BAR.I18N("ui.keybinds.editor.conflictCapture", { actions = table.concat(names, ", ") }) + clash = colorHeader .. text.fit(font, line, chainMaxW, sfs) + end + end + font:Begin() + if clash then + font:Print(clash, cx, by1 + floor(64 * scale), sfs, "cov") + end font:Print( colorText .. text.fit(font, capturing.label or capturing.action, chainMaxW, tfs), cx, @@ -2775,8 +3598,113 @@ local function drawCaptureModal(mx, my) font:End() end +-- The import preview's sizes, shared by the drawing and the bar's hit test so a press lands on +-- what was painted: the line pitch, the inset, how many lines the box holds, how far it can +-- scroll, and the bar's rect - nil while every line fits. +function state.previewGeometry(pv, x1, y1, x2, y2) + local lineH = floor(rowHeight * 0.66) + local pad = floor(6 * scale) + local barW = floor(10 * scale) + local visible = math.max(1, floor((y2 - y1 - pad * 2) / lineH)) + local most = math.max(0, #pv.lines - visible) + local bar = most > 0 and { x2 - pad - barW, y1 + pad, x2 - pad, y2 - pad } or nil + + return lineH, pad, visible, most, bar +end + +-- Scrolls the preview so the thumb's top sits where the cursor has dragged it, the offset +-- taken at the grab keeping it relative - as the list's own bar does. +function state.previewScrollFromY(pv, bar, lineH, most, y) + local _, _, trackTop, travel = + WG.FlowUI.Draw.ScrollerGeometry(bar[1], bar[2], bar[3], bar[4], #pv.lines * lineH, pv.scroll * lineH) + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - pv.grab)) / travel + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + pv.scroll = floor(f * most + 0.5) +end + +-- The import preview: the clipboard's lines in a box, in the monospaced face source gets, +-- numbered down a gutter of their own, each in the colour of what the reader makes of it, the +-- ones it will drop on a red band. Scrolled by the wheel or by the bar, whose thumb can be +-- taken hold of. The lines are fitted to the box once per width and face. +function state.drawPreview(pv, x1, y1, x2, y2, mx, my) + local mono = WG.fonts.getFont(3) or font + local fs = floor(rowHeight * 0.5) + local lineH, pad, visible, most, bar = state.previewGeometry(pv, x1, y1, x2, y2) + pv.visible = visible + + -- A drag in progress follows the cursor and ends with the button. + if pv.drag then + local _, _, lmb = spGetMouseState() + if lmb and bar then + state.previewScrollFromY(pv, bar, lineH, most, my) + else + pv.drag = false + end + end + if pv.scroll > most then + pv.scroll = most + end + if pv.scroll < 0 then + pv.scroll = 0 + end + + RectRound(x1, y1, x2, y2, metrics.csSmall, 1, 1, 1, 1, look.previewFill) + -- The gutter: wide enough for the last line's number, set off from the lines by its own + -- shade, rounded with the box on its outer corners. + local gutterW = floor(mono:GetTextWidth(tostring(#pv.lines)) * fs) + pad * 2 + RectRound(x1, y1, x1 + gutterW, y2, metrics.csSmall, 1, 0, 0, 1, look.previewGutter) + + local textX1 = x1 + gutterW + pad + local textX2 = bar and (bar[1] - pad) or (x2 - pad) + if pv.fitW ~= textX2 - textX1 or pv.fitFont ~= mono then + pv.fitW, pv.fitFont = textX2 - textX1, mono + for i, line in ipairs(pv.lines) do + local fitted = text.fit(mono, line.text, pv.fitW, fs) + -- A binding reads as the chips do: its key in gold, its action in the row colour. + local keyset, action = fitted:match("^%s*bind%s+(%S+)%s+(.*)$") + if line.kind == "bind" and keyset then + line.shown = colorFaded .. "bind " .. colorKey .. keyset .. " " .. colorAction .. action + else + line.shown = look.previewColours[line.kind] .. fitted + end + line.num = (line.kind == "error" and colorDanger or colorFaded) .. i + end + end + + local last = math.min(#pv.lines, pv.scroll + visible) + for i = pv.scroll + 1, last do + if pv.lines[i].kind == "error" then + local top = y2 - pad - (i - pv.scroll - 1) * lineH + RectRound(x1 + gutterW, top - lineH, textX2 + pad, top, 0, 1, 1, 1, 1, look.previewErrorFill) + end + end + if bar then + local content, pos = #pv.lines * lineH, pv.scroll * lineH + local top, thumbH = WG.FlowUI.Draw.ScrollerGeometry(bar[1], bar[2], bar[3], bar[4], content, pos) + local onThumb = top ~= nil and isInRect(mx, my, bar[1], top - thumbH, bar[3], top) + Scroller(bar[1], bar[2], bar[3], bar[4], content, pos, onThumb, pv.drag) + end + + mono:Begin() + for i = pv.scroll + 1, last do + local line = pv.lines[i] + local cy = floor(y2 - pad - (i - pv.scroll - 0.5) * lineH) + mono:Print(line.num, x1 + gutterW - pad, cy, fs, "rov") + mono:Print(line.shown, textX1, cy, fs, "ov") + end + mono:End() +end + local function drawProfileDialog(mx, my) - local bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep = dialogGeometry() + local bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep, box = dialogGeometry() local cs = metrics.csButton local cx = floor((bx1 + bx2) * 0.5) local tfs = floor(rowHeight * 0.6) @@ -2803,9 +3731,11 @@ local function drawProfileDialog(mx, my) local _, blocked = dialogName() local acceptSaves = not blocked and (dialog.save or (not dialog.message and not dialog.danger)) local buttons = { - { r = cancel }, { r = ok, danger = not blocked and dialog.danger, confirm = acceptSaves, inert = blocked }, } + if cancel then + buttons[#buttons + 1] = { r = cancel } + end if dialog.middle then buttons[#buttons + 1] = { r = discard, danger = dialog.middle.danger } end @@ -2821,6 +3751,11 @@ local function drawProfileDialog(mx, my) end end + -- Its own geometry and text, ahead of the dialog's own batch of text. + if box then + state.drawPreview(dialog.preview, box[1], box[2], box[3], box[4], mx, my) + end + font:Begin() font:Print( colorText .. text.fit(font, dialog.title, bx2 - bx1 - floor(32 * scale), tfs), @@ -2829,6 +3764,15 @@ local function drawProfileDialog(mx, my) tfs, "cov" ) + if box then + font:Print( + text.fit(font, dialog.preview.summary, bx2 - bx1 - floor(32 * scale), sfs), + cx, + by2 - floor(48 * scale), + sfs, + "cov" + ) + end if dialog.middle then font:Print( colorText .. dialog.middle.label, @@ -2839,7 +3783,10 @@ local function drawProfileDialog(mx, my) ) end if messageLines then - local top = floor((field[2] + field[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5) + -- Centred between the title and the buttons: a message dialog has no field, and a + -- message sitting where the field would be reads as pushed down against the buttons. + local titleBottom = by2 - floor(26 * scale) - floor(tfs * 0.5) + local top = floor((titleBottom + ok[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5) for i = 1, #messageLines do font:Print( colorDim .. text.fit(font, messageLines[i], bx2 - bx1 - floor(32 * scale), sfs), @@ -2850,13 +3797,15 @@ local function drawProfileDialog(mx, my) ) end end - font:Print( - colorText .. L.cancel, - floor((cancel[1] + cancel[3]) * 0.5), - floor((cancel[2] + cancel[4]) * 0.5), - sfs, - "cov" - ) + if cancel then + font:Print( + colorText .. L.cancel, + floor((cancel[1] + cancel[3]) * 0.5), + floor((cancel[2] + cancel[4]) * 0.5), + sfs, + "cov" + ) + end font:Print( (blocked and colorDim or colorText) .. acceptLabelFor(dialog), floor((ok[1] + ok[3]) * 0.5), @@ -3050,6 +3999,19 @@ local function drawPanel() Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base, h.bar == 1, hover.drag) end + -- The picker's caption. Nothing about it changes between layouts, so it bakes with the + -- body rather than printing live beside the picker it names. + queueText(L.presetText, metrics.presetLabelX, metrics.presetLabelY, metrics.presetLabelFs, "o") + + -- Where staged edits go. On a default it shows before anything is staged, too: that is + -- when a player is working out whether editing it is safe. + local own = activeIsOwn() + local notice = (dirty and (own and L.noticeUnsavedText or L.noticeDefaultUnsavedText)) + or (not own and L.noticeDefaultText) + if notice then + queueText(notice, metrics.noticeX, metrics.noticeY, metrics.noticeFs, "o") + end + drawButtons(h.btn) flushText() end @@ -3057,12 +4019,113 @@ end -- The tooltip widget owns the hover delay and only draws once the cursor settles; it -- keeps the area table, so this is redone whenever layoutHeader makes new rects. local function registerTooltips() + local own = activeIsOwn() for _, b in ipairs(headerButtons) do if b.rect then - WG["tooltip"].AddTooltip(b.tooltipId, b.rect, L[b.id]) + WG["tooltip"].AddTooltip(b.tooltipId, b.rect, L[(not own and b.tipLocked) or b.tip], nil, L[b.id]) + end + end + state.tooltipsRegistered = true +end + +-- What the cursor is over, said in a tooltip: a preset's description in the picker, what the +-- column's Changed entry lists, and on a row the action's description, the other actions its +-- hovered key drives, and the key the base preset had. Built once per thing hovered and shown +-- every frame after, the tooltip widget showing only what it was told this frame. +function state.showTooltips(mx, my) + local tip = WG["tooltip"] + if not tip or dialog or capturing then + return + end + + local key, title, lines + local pick = presetDropdown:optionAt(mx, my) + if pick then + local opt = pick > 0 and presetOptions[pick] or presetOptions[presetDropdown.selected] + if opt then + key = "preset|" .. opt.name + title = opt.name + if key ~= state.tipKey then + lines = {} + local builtin = profiles.isBuiltin(opt.name) + if builtin then + if builtin.description then + lines[#lines + 1] = colorText .. BAR.I18N(builtin.description) + end + lines[#lines + 1] = colorDim .. L.presetDefault + else + local base = profiles.baseOf(opt.name) + lines[#lines + 1] = colorDim + .. (base and BAR.I18N("ui.keybinds.editor.presetBasedOn", { name = base.name }) or L.presetOwn) + end + end + end + elseif hover.sb > 0 and categories[hover.sb] and categories[hover.sb].key == state.changedKey then + key = "changed" + title = categories[hover.sb].label + if key ~= state.tipKey and state.base then + lines = { colorText .. BAR.I18N("ui.keybinds.editor.changedTooltip", { name = state.base.name }) } + end + elseif hover.row > 0 then + local row = rows[scroll + hover.row] + if row and row.type == "editable" then + key = "row|" .. row.action .. "|" .. hover.zone .. "|" .. hover.idx .. "|" .. rowsGen .. "|" .. layoutGen + title = row.label + if key ~= state.tipKey then + lines = {} + if row.description then + lines[#lines + 1] = colorText .. row.description + end + local lay = rowLayout(row) + local m = hover.idx > 0 and lay.mets[hover.idx] + if m and m.others then + local names = {} + for i, o in ipairs(m.others) do + local name = state.labels[o.action] or o.action + names[i] = o.before and BAR.I18N("ui.keybinds.editor.conflictFirst", { action = name }) or name + end + -- A warning when the sharing is the player's; a note when the game ships it so. + lines[#lines + 1] = (m.clash and colorDanger or colorDim) + .. BAR.I18N( + "ui.keybinds.editor.conflict", + { keys = m.group.display, actions = table.concat(names, ", ") } + ) + lines[#lines + 1] = colorDim .. (m.clash and L.conflictOrder or L.conflictShipped) + end + if row.change and state.base then + if #row.change > 0 then + lines[#lines + 1] = colorHeader + .. BAR.I18N("ui.keybinds.editor.defaultIn", { name = state.base.name, keys = lay.ghostKeys }) + else + lines[#lines + 1] = colorHeader + .. BAR.I18N("ui.keybinds.editor.defaultNone", { name = state.base.name }) + end + lines[#lines + 1] = colorDim .. L.revertHint + end + end end end - tooltipsRegistered = true + + if not key then + state.tipKey = nil + + return + end + if key ~= state.tipKey then + state.tipKey, state.tipTitle = key, title + if lines and #lines > 0 then + local body = table.concat(lines, "\n") + if font.WrapText then + body = font:WrapText(body, (tip.getFontsize and tip.getFontsize() or 12) * 90) + end + state.tipText = text.carryColors(body) + else + state.tipText = nil + end + end + if state.tipText then + tip.ShowTooltip("keybindeditor", state.tipText, nil, nil, state.tipTitle) + end end -- Paints the whole panel. The header controls and the modals draw live; the body is @@ -3099,7 +4162,7 @@ function view.draw() if not working then view.refresh() end - if layoutPending then + if state.layoutPending then layoutHeader() end @@ -3128,18 +4191,18 @@ function view.draw() end local sig = panelSignature(mx, my) - if sig ~= panelSig then - if panelList then - gl.DeleteList(panelList) + if sig ~= state.panelSig then + if state.panelList then + gl.DeleteList(state.panelList) end - panelList = gl.CreateList(drawPanel) - panelSig = sig + state.panelList = gl.CreateList(drawPanel) + state.panelSig = sig end - gl.CallList(panelList) + gl.CallList(state.panelList) searchBox:draw() - if not tooltipsRegistered and WG["tooltip"] then + if not state.tooltipsRegistered and WG["tooltip"] then registerTooltips() end @@ -3174,6 +4237,8 @@ function view.draw() -- After they have laid themselves out, so the blur behind one is the right size on -- the frame it appears rather than the one after. shade.update() + + state.showTooltips(rawMx, rawMy) end -- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at @@ -3199,9 +4264,24 @@ end -- Input ---------------------------------------------------------------- --- Scrolls the list; a modal swallows the wheel instead. +-- Scrolls the list; a modal swallows the wheel instead, the import preview scrolling its +-- own lines with it. function view.mouseWheel(up, value) - if dialog or capturing or gridGroup then + if dialog then + local pv = dialog.preview + if pv then + local mx, my = spGetMouseState() + local _, _, _, _, _, _, _, _, _, _, box = dialogGeometry() + if box and isInRect(mx, my, box[1], box[2], box[3], box[4]) then + -- Clamped at the top here and at the bottom by the draw, which knows how many + -- lines the box holds. + pv.scroll = math.max(0, pv.scroll + (up and -3 or 3)) + end + end + + return + end + if capturing or gridGroup then return end @@ -3211,7 +4291,25 @@ function view.mouseWheel(up, value) if mx <= area.x1 + sidebarW and my > listBottom() and my <= sidebarTop() then catScrolled(up and -1 or 1) elseif my >= listBottom() and my <= listTop then - scroll = scroll + (up and -3 or 3) + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole + -- page - the rows the band holds from where the list is now, since headings are taller + -- than the bindings under them. + local _, ctrl, _, shift = Spring.GetModKeyState() + local step = ctrl and 9 or 3 + if shift then + ensureRowMetrics() + local band, used = listTop - listBottom(), 0 + step = 0 + for i = scroll + 1, #rows do + used = used + rowHeightOf(rows[i]) + if used > band then + break + end + step = step + 1 + end + step = math.max(1, step) + end + scroll = scroll + (up and -step or step) clampScroll() end end @@ -3248,9 +4346,17 @@ end -- Routes a click on a keybind row to the edit it implies. local function handleZone(kind, action, label, raws) if kind == "remove" then + -- One chip, one snapshot to take back, however many binds it stood for. + state.batching, state.batchEdited = true, false for _, raw in ipairs(raws) do removeKeyset(action, raw) end + state.batching = false + if state.batchEdited then + state.pushUndo() + end + elseif kind == "revert" then + state.revert(action) elseif kind == "add" then startCapture(action, label) elseif kind == "rebind" then @@ -3266,19 +4372,43 @@ function view.mousePress(x, y, button) if dialog then if button == 1 then - local bx1, by1, bx2, by2, ok, cancel, field, discard = dialogGeometry() + local bx1, by1, bx2, by2, ok, cancel, field, discard, _, _, box = dialogGeometry() if isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then acceptDialog() elseif dialog.middle and isInRect(x, y, discard[1], discard[2], discard[3], discard[4]) then middleDialog() elseif - (isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) + (cancel ~= nil and isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) or x < bx1 or x > bx2 or y < by1 or y > by2 then cancelDialog() + elseif box and isInRect(x, y, box[1], box[2], box[3], box[4]) then + -- Taking hold of the preview's bar: on the thumb a grab that keeps the lines put, + -- on the track a jump to the cursor and then a drag from the thumb's middle. + local pv = dialog.preview + local lineH, _, _, most, bar = state.previewGeometry(pv, box[1], box[2], box[3], box[4]) + if bar and isInRect(x, y, bar[1], bar[2], bar[3], bar[4]) then + local top, thumbH = WG.FlowUI.Draw.ScrollerGeometry( + bar[1], + bar[2], + bar[3], + bar[4], + #pv.lines * lineH, + pv.scroll * lineH + ) + if top then + pv.drag = true + if y <= top and y >= top - thumbH then + pv.grab = y - top + else + pv.grab = -floor(thumbH * 0.5) + state.previewScrollFromY(pv, bar, lineH, most, y) + end + end + end elseif not dialog.message then nameBox:mousePress(x, y) end @@ -3342,6 +4472,10 @@ function view.mousePress(x, y, button) startDuplicate() elseif b.id == "edit" then startEdit() + elseif b.id == "export" then + startClipboard(true) + elseif b.id == "import" then + startClipboard(false) end end @@ -3462,6 +4596,17 @@ function view.keyPress(key, scanCode) return true end + -- Ctrl+Z takes the last edit back. Below the capture and the dropdown, which take every + -- key while they are up; above the search field, which has no use for it. + if key == KEYSYMS.Z then + local _, ctrl = Spring.GetModKeyState() + if ctrl then + state.undoEdit() + + return true + end + end + -- Escape empties the search before it closes the panel: the list being read is the one -- the search made, and the first Escape is asking for that back. With nothing left to -- clear it goes unclaimed, and the widget above closes the panel on it. diff --git a/luaui/Include/keybind_model.lua b/luaui/Include/keybind_model.lua index 1b4669632aa..ef7db9890ae 100644 --- a/luaui/Include/keybind_model.lua +++ b/luaui/Include/keybind_model.lua @@ -107,6 +107,41 @@ local function displayKeyset(raw, layout) return table.concat(parts, chainSep) end +-- A keyset as the editor's chip for it reads. A paired action holds one key as two binds, bare +-- and Shift+, and that Shift is the action's rather than the player's, so the Shift half reads +-- as the bare key and both halves share one chip. +local function displayWithoutShift(raw, layout) + local parts = splitChain(raw) + parts[1] = (parts[1]:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) + + return displayKeyset(table.concat(parts, ","), layout) +end + +-- Whether a displayed keyset holds every one of the keys given, in any order: whole keys as the +-- chip prints them, modifiers included, lowercased. Split once per display string, of which a +-- keymap has a few hundred at most. +local keySets = {} +local function holdsKeys(display, keys) + if #keys == 0 then + return false + end + local set = keySets[display] + if not set then + set = {} + for key in display:lower():gmatch("[^%s%+]+") do + set[key] = true + end + keySets[display] = set + end + for i = 1, #keys do + if not set[keys[i]] then + return false + end + end + + return true +end + local function canonicalKeyset(raw) local parts = splitChain(raw) for i = 1, #parts do @@ -164,6 +199,8 @@ end return { build = build, displayKeyset = displayKeyset, + displayWithoutShift = displayWithoutShift, + holdsKeys = holdsKeys, canonicalKeyset = canonicalKeyset, splitChain = splitChain, chainSep = chainSep, diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index b5fe77c4e20..6784df02302 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -494,7 +494,7 @@ function M.load() store = decoded store.version = store.version or STORE_VERSION -- A hand-edited file can repeat a name; keep the first so lookups stay unambiguous. - local seen, kept = {}, {} + local seen, kept, inferred = {}, {}, false for _, p in ipairs(store.profiles) do if type(p) == "table" and type(p.name) == "string" and not seen[p.name] then seen[p.name] = true @@ -502,10 +502,21 @@ function M.load() if type(p.fakeMeta) ~= "string" or p.fakeMeta == "" or p.fakeMeta:find("%s") then p.fakeMeta = nil end + -- Which shipped profile it was forked from. Only a name that still ships means + -- anything: a retired one would have the editor comparing against nothing, so a + -- profile without a usable one is given the closest shipped profile instead, and + -- that is written back so every surface reads the same origin from then on. + if type(p.basedOn) ~= "string" or not M.isBuiltin(p.basedOn) then + p.basedOn = M.inferBase(p) + inferred = inferred or p.basedOn ~= nil + end kept[#kept + 1] = p end end store.profiles = kept + if inferred then + M.save() + end return store end @@ -596,13 +607,97 @@ function M.adoptEditedKeymap() return name end +-- The shipped profile a player's profile is closest to: the one it differs from on the +-- fewest actions, comparing each action's keysets as written. For a profile with no recorded +-- origin - imported, or made before origins were recorded - this stands in for one: a fork +-- of Grid differs from Grid on a handful of actions and from Legacy on a hundred, so the +-- closest is the right answer, and even a layout written from scratch is best measured +-- against whatever it most resembles. +function M.inferBase(profile) + local ownSets = {} + for _, b in ipairs(profile.binds or {}) do + local set = ownSets[b.action] + if not set then + set = {} + ownSets[b.action] = set + end + set[b.keyset:lower()] = true + end + + local best, bestDiff + for _, builtin in ipairs(builtins) do + local theirSets = {} + for _, b in ipairs(builtin.binds or {}) do + local set = theirSets[b.action] + if not set then + set = {} + theirSets[b.action] = set + end + set[b.keyset:lower()] = true + end + + local diff = 0 + for action, set in pairs(ownSets) do + local theirs = theirSets[action] + if not theirs then + diff = diff + 1 + else + for keyset in pairs(set) do + if not theirs[keyset] then + diff = diff + 1 + break + end + end + if diff == 0 or theirs then + for keyset in pairs(theirs) do + if not set[keyset] then + diff = diff + 1 + break + end + end + end + end + end + for action in pairs(theirSets) do + if not ownSets[action] then + diff = diff + 1 + end + end + + if not bestDiff or diff < bestDiff then + best, bestDiff = builtin, diff + end + end + + return best and best.name or nil +end + +-- The shipped profile a profile descends from: itself for a shipped one, the recorded fork +-- for the player's own. What an editor compares against to say which keys the player +-- changed. Every profile of the player's carries one: recorded when it was forked or +-- duplicated, inferred as the closest shipped profile otherwise. +function M.baseOf(name) + local builtin = M.isBuiltin(name) + if builtin then + return builtin + end + + local own = M.get(name) + + return own and own.basedOn and M.isBuiltin(own.basedOn) or nil +end + -- Adds a profile of the player's own, without selecting it: whether it becomes the live one -- depends on the keymap reaching disk, which only the caller finds out. Selecting it up front -- would leave the picker naming a profile the engine never loaded when that write fails. -function M.create(name, binds, fakeMeta) +-- `basedOn` names the shipped profile it was forked from; without one, the closest shipped +-- profile stands in. +function M.create(name, binds, fakeMeta, basedOn) M.load() name = M.uniqueName(name) - store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = fakeMeta } + local profile = { name = name, binds = binds, fakeMeta = fakeMeta } + profile.basedOn = (basedOn and M.isBuiltin(basedOn)) and basedOn or M.inferBase(profile) + store.profiles[#store.profiles + 1] = profile if not M.save() then Spring.Echo( "[keybind_profiles] Error: could not write " @@ -658,6 +753,71 @@ function M.delete(name) return M.save() end +-- A profile as text a player can paste anywhere: the same bind-file form the engine loads, +-- headed by the profile's name, so what is shared is what would be applied. +function M.exportText(profile) + return toBindFile(profile) +end + +-- Every line of bind-file text with what the reader makes of it, for showing a player what +-- an import will take before it does: "bind" for a binding, "directive" for anything else +-- the reader acts on, "comment" for a comment or a blank line, "error" for a line it cannot +-- read and will drop. Follows readBindFile line for line, and counts the binds and the +-- errors with it. +function M.classifyBindFile(text) + local lines, binds, errors = {}, 0, 0 + if type(text) ~= "string" then + return lines, binds, errors + end + + for raw in (text:gsub("\r\n", "\n"):gsub("\r", "\n") .. "\n"):gmatch("([^\n]*)\n") do + local line = raw:gsub("//.*", ""):gsub("%s+$", "") + local kind + if line:match("^%s*$") then + kind = "comment" + elseif line:match("^%s*bind%s+%S+%s+%S") then + kind = "bind" + binds = binds + 1 + elseif + line:match("^%s*unbindall%s*$") + or line:match("^%s*unbindaction%s+%S") + or line:match("^%s*unbindkeyset%s+%S") + or line:match("^%s*unbind%s+%S+%s+%S") + or line:match("^%s*keysym%s+%S+%s+%S") + or line:match("^%s*keyload%s+%S") + or line:match("^%s*fakemeta") + then + kind = "directive" + else + kind = "error" + errors = errors + 1 + end + lines[#lines + 1] = { text = raw, kind = kind } + end + + -- The split above leaves one empty line after a trailing newline, which is no line. + if #lines > 0 and lines[#lines].text == "" then + lines[#lines] = nil + end + + return lines, binds, errors +end + +-- The reverse: bind-file text, however it was produced, as binds plus the fakemeta key and +-- the profile name our own output is stamped with. nil binds when the text holds none. +function M.parseBindFile(text) + if type(text) ~= "string" or text == "" then + return nil + end + + local binds = readBindFile(text) + if not binds or #binds == 0 then + return nil + end + + return binds, readFakeMeta(text), generatedName(text) +end + -- Write a profile out where the engine can keyreload it, and return that path. function M.materialize(name) local profile = M.get(name) or M.isBuiltin(name) diff --git a/luaui/Widgets/gui_changelog_info.lua b/luaui/Widgets/gui_changelog_info.lua index 6a9cb80e168..51d519c5e77 100644 --- a/luaui/Widgets/gui_changelog_info.lua +++ b/luaui/Widgets/gui_changelog_info.lua @@ -690,7 +690,24 @@ function widget:MouseWheel(up, _value) if x <= area.x1 + metrics.sidebarW and y > listBottom and y <= sidebarTop() then setCatScroll(catScroll + (up and -1 or 1)) else - setStartRow(startRow + (up and -metrics.wheelRows or metrics.wheelRows)) + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole + -- page - the rows the band holds from where the text is now, since they are not all the + -- same height. + local _, ctrl, _, shift = Spring.GetModKeyState() + local step = ctrl and metrics.wheelRows * 3 or metrics.wheelRows + if shift then + local band, used = listTop - listBottom, 0 + step = 0 + for i = startRow, #rows do + used = used + rows[i].h + if used > band then + break + end + step = step + 1 + end + step = mathMax(1, step) + end + setStartRow(startRow + (up and -step or step)) end return true end diff --git a/luaui/Widgets/gui_flowui.lua b/luaui/Widgets/gui_flowui.lua index 31da1e84640..80c2741fd83 100644 --- a/luaui/Widgets/gui_flowui.lua +++ b/luaui/Widgets/gui_flowui.lua @@ -2947,9 +2947,18 @@ WG.FlowUI.Draw.ScrollerGeometry = function(px, py, sx, sy, contentHeight, positi local thumbHeight = mathFloor((fraction * trackHeight) + 0.5) local trackTop = sy - padding + local travel = trackHeight - thumbHeight local top = trackTop - mathFloor((trackHeight * ((position or 0) / contentHeight)) + 0.5) + -- Held inside the track whatever the position says: a list scrolled to its end shows + -- whole rows only, so its position can run a little past what the track height allows, + -- and a thumb drawn past the track's end lands on whatever sits under it. + if top > trackTop then + top = trackTop + elseif top < trackTop - travel then + top = trackTop - travel + end - return top, thumbHeight, trackTop, trackHeight - thumbHeight + return top, thumbHeight, trackTop, travel end ---Draws a vertical scrollbar. diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index 51594ee47c9..55a47a322dd 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -7,7 +7,7 @@ function widget:GetInfo() author = "Floris", date = "May 2017", license = "GNU GPL, v2 or later", - layer = 2, + layer = -99990, enabled = true, } end @@ -2558,7 +2558,25 @@ function widget:MouseWheel(up, _value) if x <= area.x1 + metrics.sidebarW and y > listBottom and y <= sidebarTop() then setCatScroll(catScroll + (up and -1 or 1)) else - setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole + -- page - the rows the band holds from where the list is now, since they are not all + -- the same height. + local _, ctrl, _, shift = Spring.GetModKeyState() + local step = ctrl and metrics.wheelRows * 3 or metrics.wheelRows + if shift then + ensureRowMetrics() + local band, used = listTop - listBottom, 0 + step = 0 + for i = scroll + 1, #rows do + used = used + rowHeightOf(rows[i]) + if used > band then + break + end + step = step + 1 + end + step = mathMax(1, step) + end + setScroll(scroll + (up and -step or step)) end return true diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 5eddaf7ac76..4107e9174f0 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -200,6 +200,20 @@ look.faded = setmetatable({}, { return faded end, }) +-- The same colours as text colour codes, keyed by group, for naming a widget in a tooltip in the +-- colour of the group it is in. A component of 0 would end the string, so none goes below 1. +look.groupText = setmetatable({}, { + __index = function(self, group) + local c = look.groups[group] or look.groups.other + local code = "\255" + for i = 1, 3 do + code = code .. string.char(mathMax(1, mathFloor(c[i] * 255 + 0.5))) + end + self[group] = code + + return code + end, +}) -- A category the search found nothing in. It stays in the column, so the column does not -- jump about under the typing, but it reads quieter than the ones holding matches. look.emptyText = "\255\95\95\95" @@ -213,6 +227,9 @@ look.tip = { value = "\255\225\225\225", name = "\255\175\205\240", warn = "\255\235\195\125", + -- Titles are left uncoloured, so the tooltip draws them in the green every tooltip heads with; + -- a button that throws something away heads its tooltip in red instead, as its face is. + danger = "\255\255\125\125", } local colorTitle = "\255\235\235\235" local colorName = "\255\145\143\140" @@ -305,6 +322,14 @@ local GROUP_ORDER = { } local OTHER = "other" +-- Which column a widget belongs in, from the prefix on its filename. +local function groupOf(data) + local base = data.basename or "" + local prefix = base:match("^(%a+)_") + + return (prefix and GROUPS[prefix]) or OTHER +end + local L = {} local show, showOnceMore @@ -585,6 +610,13 @@ function deps.users(name, sole) return users, unchecked end +-- A widget's name in the colour of the group it is in, the one its square carries in the list. +function deps.colored(name) + local data = widgetHandler.knownWidgets[name] + + return look.groupText[data and groupOf(data) or OTHER] .. name +end + -- A list of names cut to `limit`, with a count of the rest. function deps.names(list, limit, sep) local shown = {} @@ -636,13 +668,15 @@ function deps.tooltip(row, maxWidth) return a < b end) local parts = {} + -- Each name in its group's colour; what is off says so after it, red where the widget + -- never checks for it, quiet where it does. for i, name in ipairs(names) do if state[name] == "blind" then - parts[i] = tagColors.iserror .. name .. " (" .. L.depsOff .. ")" + parts[i] = deps.colored(name) .. tagColors.iserror .. " (" .. L.depsOff .. ")" elseif state[name] == "off" then - parts[i] = "\255\130\130\130" .. name .. " (" .. L.depsOff .. ")" + parts[i] = deps.colored(name) .. "\255\130\130\130" .. " (" .. L.depsOff .. ")" else - parts[i] = tip.name .. name + parts[i] = deps.colored(name) end end local line = tip.label .. L.depsUses .. ": " .. deps.names(parts, 8, tip.label) @@ -675,8 +709,9 @@ function deps.tooltip(row, maxWidth) line = line .. " (" .. tip.warn .. #unchecked .. " " .. L.depsUnchecked .. tip.label .. ")" end local parts = {} + -- In their groups' colours too, so the ones that never check are marked after the name. for i, name in ipairs(users) do - parts[i] = (blind[name] and tip.warn or tip.name) .. name + parts[i] = deps.colored(name) .. (blind[name] and (tip.warn .. " !") or "") end line = line .. " - " .. deps.names(parts, 3, tip.label) out = out .. text.carryColors(font:WrapText(line, maxWidth)) .. "\n" @@ -751,14 +786,6 @@ function sweep.errors(data) return (log and log.entries[1]) and log or nil end --- Which column a widget belongs in, from the prefix on its filename. -local function groupOf(data) - local base = data.basename or "" - local prefix = base:match("^(%a+)_") - - return (prefix and GROUPS[prefix]) or OTHER -end - -- One line of description, with the newlines a multi-line one carries turned into spaces: -- this sits on the row beside the name, and the tooltip is where the whole thing lives. local function oneLine(str) @@ -3151,7 +3178,6 @@ local function loadLabels() -- Said plainly, because raise and lower do not move a widget by one place: they send -- it to the front or the back of the band of widgets sharing its layer, and it can -- never leave that band. - L.hint = tr("hint", "Click to toggle. Right-click sends it to the front of its layer, middle-click to the back.") L.order = tr("order", "Load order") L.changed = tr("category.changed", "Changed") L.isrml = tr("isrml", "rml") @@ -3430,11 +3456,10 @@ function widget:Initialize() buildButtons() widgetHandler.knownChanged = true - -- barwidgets binds F11 to `luaui selector`, which looks for a loaded widget whose - -- basename is exactly selector.lua and otherwise tries to load LuaUI/selector.lua. - -- Neither is this file, so the key does nothing at all. Pointed at the action instead, - -- which is the same thing /widgetselector reaches. - spSendCommands({ "unbindkeyset f11", "bind f11 widgetselector" }) + -- F11 is left to the keymap. The shipped presets bind it to `luaui selector`, which the + -- handler answers by toggling this panel, or by switching it back on when an error took it + -- down, and a player who moves it in the keybind editor keeps it where they put it. + -- Rebinding it here on every start undid that edit on the next launch. -- Lets the handler hide the rest of the interface while the list is open. This widget -- holds the real widgetHandler, so it passes itself. @@ -3463,6 +3488,10 @@ function widget:Initialize() -- would rather not use F11. widgetHandler.actionHandler:AddAction(self, "widgetselector", function() setShow(not show) + -- Holds the key it fired on. A preset saved while this widget still bound F11 to itself + -- can carry this and `luaui selector` on one key, and a handler that declines lets the + -- next action on that keyset toggle the panel straight back shut. + return true end, nil, "tp") widgetHandler.actionHandler:AddAction(self, "factoryreset", function() factoryReset() @@ -3637,7 +3666,7 @@ function tipCache.keep(a, b, c, d, title, text) end local function showTooltip(row) - local caption, body + local caption, body, danger if hover.sb > 0 and categories[hover.sb] then local c = categories[hover.sb] @@ -3682,11 +3711,12 @@ local function showTooltip(row) if hover.btn == "userwidgets" then caption = widgetHandler.allowUserWidgets and L.disallowUser or L.allowUser body = widgetHandler.allowUserWidgets and L.disallowUserWarn or L.allowUserWarn + danger = widgetHandler.allowUserWidgets else body = L.desc[hover.btn] for _, b in ipairs(buttons) do if b.id == hover.btn then - caption = b.label + caption, danger = b.label, b.danger end end caption = caption or L[hover.btn] @@ -3694,13 +3724,13 @@ local function showTooltip(row) end if caption and body then - if not tipCache.same("control", caption, body, false) then + if not tipCache.same("control", caption, body, danger or false) then tipCache.keep( "control", caption, body, - false, - colorTitle .. caption .. "\n", + danger or false, + (danger and look.tip.danger or "") .. caption .. "\n", "\255\255\255\255" .. string.gsub(font:WrapText(body, WG.tooltip.getFontsize() * 90), "[\n]", "\n\255\255\255\255") ) @@ -3733,7 +3763,7 @@ local function showTooltip(row) row.name, row.state, false, - colorDanger .. L.cleardataTitle .. "\n", + look.tip.danger .. L.cleardataTitle .. "\n", "\255\255\255\255" .. string.gsub(font:WrapText(warn, maxWidth), "[\n]", "\n\255\255\255\255") ) end @@ -3752,7 +3782,7 @@ local function showTooltip(row) row.name, data, false, - colorTitle .. L.showdata .. "\n", + L.showdata .. "\n", dataView.preview(row.name, maxWidth) ) end @@ -3860,8 +3890,6 @@ local function showTooltip(row) .. (row.isLocal and (label .. " (" .. tagColors.islocal .. L.islocal .. label .. ")") or "") .. "\n" .. deps.tooltip(row, maxWidth) - .. "\255\130\130\130" - .. L.hint -- With the cost column on, what the widget is spending it on, broken down the way the -- profiler breaks it down: time, allocations, callin. A tooltip is one string in a -- proportional face, so the columns are padded to a fixed number of characters rather @@ -4118,9 +4146,13 @@ function widget:MouseWheel(up, _value) if dialog then return true end + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole + -- page of whatever is being scrolled. + local _, ctrl, _, shift = Spring.GetModKeyState() -- The settings window takes the wheel while it is up: it is the thing being read. if dataView.name then - dataView.setScroll(dataView.scroll + (up and -3 or 3)) + local step = shift and mathMax(1, dataView.page) or (ctrl and 9 or 3) + dataView.setScroll(dataView.scroll + (up and -step or step)) return true end @@ -4130,7 +4162,8 @@ function widget:MouseWheel(up, _value) if x <= area.x1 + metrics.sidebarW and y > categoryBottom() and y <= sidebarTop() then setCatScroll(catScroll + (up and -1 or 1)) else - setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) + local step = shift and pageRows() or (ctrl and metrics.wheelRows * 3 or metrics.wheelRows) + setScroll(scroll + (up and -step or step)) end return true diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index 216972df8c9..0c1a2a8be8c 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -1850,8 +1850,9 @@ function widgetHandler:ConfigureLayout(command) self:SendConfigData() return true elseif command == "selector" then - -- F11's original binding, which looked for LuaUI/selector.lua. This game ships no such file: its - -- selector is Widgets/widget_selector.lua, which binds F11 to /widgetselector itself once it runs. + -- F11's binding in every shipped preset. It once looked for LuaUI/selector.lua, which this game does + -- not ship: its selector is Widgets/widget_selector.lua, reached through what it puts in WG, or + -- switched back on here when an error took it down. if not self:RecoverWidgetSelector() and self.WG.widgetselector then self.WG.widgetselector.toggle() end