diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b4668a..04c89bb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: shell: bash run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Core.Tests/SharpMUTerm.Core.Tests.csproj + - name: Test — Client + if: ${{ !cancelled() }} + shell: bash + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Client.Tests/SharpMUTerm.Client.Tests.csproj + - name: Test — Graphics if: ${{ !cancelled() }} shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c939f10..fbd6c9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,376 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Added +- In the desktop window the rules under tab strips and panel headings, and the panels' seams, now run right up to the + line between two panes instead of stopping just short of it. +- In the desktop window the sidebar reads as the mockups: a tracked CONNECTIONS heading, world names without a + coloured bar, a small dot in the world's colour per character (a ring while offline), and window shortcuts and + unread counts in a quiet monospace beside each window. +- In the desktop window every row of the command palette is the same height, whether or not it shows a shortcut, + and the key band at the foot of the palette, search, history and help lines up with the list above it and no longer + covers the card's rounded corners. +- In the desktop window the command line's field stays drawn under the command palette and the other window dialogs, + rather than showing raw cells with a dark block at the end. +- The desktop window's full-screen composer has a plain "Compose" title like the docked one, outlined mode chips, and + key caps in its footer matching the rest of the window. The welcome page's key hints sit under its column. + +- In the desktop window the sidebar's large fold chevron is gone: a small sidebar button left of the app's name in the + header folds and opens it, in the same place either way. +- In the desktop window a pane's front tab is drawn as the mockup's card, without the accent bar along its top; the + focused pane's tab has a faint blue edge instead. +- What the client itself says is no longer cut off on an 80- or 60-column terminal. The status row's notices (nothing + connects at start, switching to an offline character, a pane with no connection, the map's edit keys) and the move + and drag prompts use shorter words where the row is narrow, keeping every key they name. The reconnect countdown, + the "NEW" and "AWAY" bars, the composer's header and the docked composer's keys do the same instead of ending in "…". + +- The first connect fits a small terminal. At 80×24 the key tour shown after your first connect is one line again (it + wrapped), keeping ⌃P and how to hide it. At 60×20 the welcome no longer covers the command line, the header stays on + one row, the world list's note and a failed listing say their whole sentence, and the "no world" notice keeps + `/welcome` instead of cutting it off. +- In the desktop window's welcome, Esc now steps back one level instead of closing it: from a filled-in form back to the + world list as you left it (same search, sort and row), and from Browse worlds back to the welcome, which also has a + Back button there. The find card has a *Browse all worlds* button. The welcome page no longer scrolls at 1440×900 or + at the window's default size. +- The notice after adding a world names F5 only once, and says which text encoding the world was saved with when it + was given one. + +- Connecting to your first world is smoother. In the desktop window's welcome, the arrow keys pick a world from the list + and Enter adds it. When a game has several addresses, the keyboard starts on the addresses rather than on Back. The + filled-in form starts at Character and names the world and its text encoding, as the terminal's does. In the + terminal, Esc steps back one screen at a time: from the form to the world list (without fetching it again), and from + the list to the welcome. The key tour now appears under the "connected to" line instead of between dialling and + connecting. At 80 columns the form and the list's notes no longer run off the edge or stop mid-sentence. + +- In the desktop window every button, field and list on the settings window, the command palette, the dialogs, the + welcome page and the composer can be reached with Tab, shows one rounded focus ring in the theme's colours when the + keyboard reaches it, and has a name a screen reader can say. Tab stays inside an open dialog, and Enter or Space on a + focused button presses that button. + +- In the desktop window a tab can be dragged: along its own strip to change its place, onto another pane's middle to + join its tabs, or near a pane's edge to split it there. While you drag, the window shades where it would land and + says what the drop will do, as the terminal's drag preview does; Esc or a right click cancels. +- Settings edits can be undone and redone for the rest of the session: Ctrl+Z and Ctrl+Shift+Z (or Ctrl+Y) in the + desktop settings window, ⌃Z and ⌃Y on the terminal's settings screens. Each settings page keeps its own last 50 + changes, an undo is saved and reaches connected sessions like any other change, and the footer (window) or header + (terminal) says what was undone — or that there was nothing to undo. +- In the terminal, a docked panel's heading band is the boundary between stacked panes: Roleplay's composer and the + who-is-here list no longer have a grey row between them (drag the HERE band's empty part to resize), and the panes + are one column wider, because the spare column between the sidebar's rule and the output is gone. +- Vitals have colours: health is green, mana blue and movement gold on the status row, the terminal's vitals strip and card, + and the desktop window's gauges, and the target's bar is orange. A low vital turns red everywhere. Pick any meter's + colour in F7's meter table (a new colour column; `auto` keeps the default for its name). +- In the desktop window, Combat's map uses its pane: rooms are drawn at full size, every room's name is written out in + full where it fits without covering another room or name (shortened only where it truly cannot fit), the map pane no + longer repeats a lone "Map" tab above its own heading, and the vitals card is only as tall as its gauges, so the map + gets the rows that used to stay empty under Movement. +- In the terminal, the who-is-here, composer and vitals panels head themselves with a filled band, the scrolled-back + divider is a filled band too, modal pop-ups have a quiet grey frame instead of a cyan one, the header shows your + character in the world's colour without a grey chip, the vitals card no longer draws a stray rule when nothing is + targeted, and the welcome's `/connect` example is highlighted — closer to the terminal mockups. +- In the desktop window's Light theme, the header sits on the same plane as the sidebar and status row instead of a + separate lavender grey, text fields in the composer, the welcome and the pop-ups have a visible edge, and the + welcome's fields are sunk into their cards (with a magnifier in the search box) in every theme. +- The settings window is closer to its mockup: while searching, a section with no match no longer shows a greyed `0`, + the meter table's paths and live values are in the 12-point monospace face, the list of received paths scrolls inside + its panel instead of pushing the page down, the list editors' drop-downs line up with the text boxes beside them, + and a disabled button no longer looks heavier than an enabled one. + +- In the desktop window, the map is drawn the way the mockups draw it: dark rooms outlined in a rule, exits as thin lines, + your room ringed in the accent with an `@`, Up/Down/Areas as proper buttons, and the legend in a strip at the foot with + "Click a room to walk there" under it, so it never covers a room. +- In the desktop window, the header shows only the search box and your character, and the status bar reads + "Corvid connected · UTF-8 · Log off" with a short hint on the right such as "2 new in Chat" (or the keys of the mode + you are in). Notices still take the whole bar while they show. +- In the desktop window, Combat's quick bar is a proper strip of buttons, each key a key cap beside its name, and a + binding marked *danger* on F4 (new `style` field) is drawn in red there and in the terminal's quick bar. +- In the desktop window, a panel a layout collapses (Combat's chat) is a one-row bar at the foot of its column saying + how many new lines it holds; click it to open it as a pane. The terminal keeps it as a background tab. +- The composer has a Command field above the text, in F1 and the docked panel, in the terminal and the desktop window: + put the verb there (`+pose`, `say`, `+bbpost 3=`) and write the post below it. ⇥ moves between the two (⏎ in the + command moves on to the text); the command is sent in front of the text unescaped, joined by a space (none after + `=` or a one-character shorthand like `:`), and it stays after a send for the next post. Leave it empty to write the + whole command in the text as before. The window's docked panel now reads as the design: Command and Text fields, the + Literal/As typed chip, `2 paragraphs · 31 words`, Preview and Send. +- The package review keeps ⏎ install and Esc cancel on screen at 80×24: the review scrolls (↑↓ PgUp PgDn Home End) + under its keys and stops above the command line. +- Browse worlds: a world listed at several addresses (TLS and plain, another port, an old one) now lets you choose + which before the connect form opens; the form takes that address's TLS setting, and the world is saved with the + game's character set when mu*index names one other than UTF-8. `/browse N M` says address M in `--plain`. +- Browse worlds shows mu*index's list as mu*index gives it — every row, in its order, with its player counts and their + labels — and asks it to leave out games listed only by an address. +- In the desktop window, every docked panel has a heading saying what it shows (Compose for Corvid, Here The Grand + Plaza · 6, Vitals GMCP Char.Vitals, Map Aetherfall · level 0). Who is here marks each person active, idle or NPC; a + tall vitals pane draws a card with the target under a rule; the quick bar's buttons show their key and end in a + "+ Bind a key" button that opens F4. +- The docked composer in the window has its escaping on a chip you can click, a paragraph and word count, and Preview + and Send buttons; Preview opens the full-screen composer with the as-sent band. +- The window's welcome is one page: find a world from mu*index and add it, connect directly (with the `/connect` line + it stands for), and pick a starting layout from two cards. Nothing is fetched until you ask for the list. A world + listed at several addresses lets you choose one right in the Find a world card (Esc goes back to the worlds). +- The desktop window's status line is now a footer under the whole window, in the window's own type, with keys in words. + Clicking "Ctrl+P palette" opens the palette, and clicking a notice's action (Reconnect, Open F5…) runs it. +- The desktop window draws the command line as a rounded field with the prompt in its own type and a hint at the right + ("Enter sends · Ctrl+Enter new line") that steps aside when your typing reaches it. Typing, history, completion and + selection work exactly as before. +- The desktop window ships the Inter typeface for its menus, panels and dialogs, and uses the bundled JetBrains Mono for + the text unless `--font` names another, so it looks the same on every computer. `--line-height` spaces the rows + (1–2, default 1.12). + +- In the desktop window each pane's tabs are drawn as native tabs: the tab in front is a card joined to its pane, + unread counts are in the accent, a tab's close appears when you point at it, a right click shows the tab's menu, + and tabs that do not fit are behind a `+N` button. The terminal is unchanged. +- In the desktop window a pane split by scrolling back shows a native band — "Scrolled back N rows · New lines keep + arriving below" — with a Jump to latest button that does what End does. +- A script error in the script console that names a file and line is now a link: click it to open that script with the + caret on the line and the line marked. Closing the editor goes back to the console. +- ⌃P ▸ Recent alerts lists the pages and alerts every character has had this run, newest first, with how long ago; + choosing one goes to that character's window. +- ⌥S sends the command line to the send group (⌃P ▸ Send to group), and `g` on a sidebar row while ⌥C holds the keys + adds that character to the group or takes them out. + +- The script editor numbers its lines in the desktop window too, and after a save whose reload fails, the line the + error names is marked in both the terminal and the window. +- When Ctrl+F finds fewer matches than it might because the pane no longer holds older lines, its status line offers + Alt+H to search older history, saying how many more lines there are (in the window, clicking the hint does it). +- The read-only history page an older match opens is centred on the match, with the three lines either side drawn as + its context and the rest of the page quieter. +- Browse worlds: the welcome's Browse row, ⌃P ▸ Browse worlds… and `/browse [words]` list worlds from the mu*index + directory, most played first, searchable and sortable by name. Picking one fills in the connect form with its host, + port and TLS; nothing is saved or dialled until you press ⏎ there. The directory is asked only when you open the list. + With `--plain`, `/browse` reads the list out and `/browse 3` tells you the `/connect` line for world 3. + +- Scripts can be edited without leaving the client. ⌃P ▸ Edit script… (or ⌃E in the script console, or `/script edit`) + lists your character's script files; one opens in the composer with line numbers, exactly as written. ⌃S saves it, + reloads that character's scripts and shows the first error with its file and line. ⌃P ▸ New script… (or + `/script new `) starts one. Scripts from a package open read only. +- ⌃F can search older history. Press ⌥H to search the whole of a character's scrollback, including what has been + moved to disk, as well as what the pane still shows. Results stream in below the pane's own, marked "older"; ⏎ on + one opens a read-only page of history around it. Capture windows keep no older history, and the search says so. +- Alerts reach more of the desktop. On Linux (X11, and XWayland) the window is marked as wanting attention, so the + taskbar or window list highlights it until you come back. On Windows, an installed copy shows a toast as well as + flashing the taskbar, and the window groups with its Start menu shortcut. +- A new world alerts you when you are paged, told or whispered to while you are in another window (F5 ▸ alert on ▸ + pages). Alerts for your character's name stay off until you choose "name & pages", since a name comes up in every + scene. Worlds you already have keep the setting they had. +- While a scene log records, the status row shows `● REC` and the desktop window's header says so. The "scene saved" + notice offers Open (the scene file in your viewer; only files the client saved under its own log folder) and Copy + path. +- `/map find` lists the rooms it found in Ctrl+P as "Walk to …" entries; Enter walks there. + +- Import worlds, characters, triggers, aliases, timers and keys from MUSHclient (.mcl), Mudlet (a profile folder, + an XML package or a .mpackage) and TinTin++ with `/import `, Ctrl+P ▸ Import from another client… or the welcome + screen. A review shows what can act as you, what will be added and what was left out (scripts are never imported or + run) before anything is written; nothing you already have is replaced, and passwords are stored only if you say so. +- The terminal map names the room you are in, and where each of its exits leads when the pane has room. +- Maps can be edited by hand: Ctrl+P ▸ Edit map in the terminal (arrow keys select, Shift+arrows move, r, a, l, w, x, + n, s, Del and u edit), or right-click a room in the desktop window for its menu and inspector. Rename a room, set + its area, lock it, weight it, add or remove an exit (or link one to the next room you walk into), move it, delete it, + or give it a note or a symbol; the last ten edits can be undone. What you changed by hand (a name, an area, exits, + a position) is kept when the game describes the room again; Reset to server (R, or the inspector's button) gives it + back to the game. +- The map can show another level or area and come back: the ▲ ▼ and "here" in the map's title and the area names in + its legend in the terminal, the Up, Down, Areas and "Back to where I am" buttons (or Shift+wheel) in the desktop + window, PgUp/PgDn, v and h while editing, Ctrl+P ▸ Map: level up / level down / next area / back to where I am, and + `/map level up|down`, `/map view ` and `/map here`. Walking still goes from where you are. +- In the desktop window, drag a room while editing the map to move it; it snaps to the grid. +- Ctrl+P ▸ Find room… and `/map find ` list the rooms whose name or area matches, with the `/walk` for each, + and show the first on the map. +- Ctrl+P ▸ Script console: one per character, it shows what your Lua scripts printed and the errors they raised + (with the file and line), runs a line of Lua as that character (a runaway loop is stopped after half a second), + remembers what you typed, lists the character's `store` keys with Delete, and reloads the scripts. Without a + scripts folder it says what to create. +- F5 ▸ channels puts each GMCP channel (`Comm.Channel.Text`) in a window of its own: `copy` keeps the line in the + main window too, `move` takes it out. Off by default. +- F5 ▸ names highlights the names you list (`Ashlyn, Merrow=red`) in a colour of your choice, as whole words, + wherever they appear. +- Ctrl+P ▸ Start scene log records the window in front to a file of its own under the log folder, HTML or plain text, + poses only or every line (F7 ▸ Scene log), with a header naming who was in the room; Stop scene log closes it. +- A world can reconnect by itself after its connection drops (F5 ▸ reconnect). It waits 5, 15, 30, 60 and 60 + seconds between tries, counts down on the status row, and stops after five; Alt+R dials at once, and Disconnect or + Ctrl+P ▸ Stop reconnecting cancels. Your own Disconnect, quitting, or the game closing right after you typed a + command (a logout) never reconnects. +- The client can tell your desktop when something wants you while you are in another window: tick "alert me when + away" on a trigger, or set F5 ▸ alert on to "name & pages" for a world. The desktop window flashes or shows a + notification; kitty, WezTerm, Ghostty and foot show a notification, other terminals ring the bell. At most one every + ten seconds, and F7 ▸ alert when away turns it all off. While the desktop window is behind another, its title + counts the lines that arrived. +- Command stacking, per world and off by default (F5 ▸ command stacking): `n;e;e` sends three commands and `#3 kill rat` + sends one three times. Only lines typed on the command line are split — never a composer post. +- A tab strip too narrow for its tabs now says how many it could not draw (`+3 ▾` at its right edge); click it to + list them and go to one — a menu in the desktop window, a short list in a terminal. +- The composer shows how many characters the post will be once escaped and joined, and Alt+P shows exactly what will + be sent (both in the terminal and in the desktop window). +- When a pane is narrower than the line width a game wraps at, the client says so once and offers to turn on + "join wraps" for that world. +- Help is built in: `/help` (or Ctrl+P ▸ Help) lists the slash commands, every key this client answers to, and short + how-to pages on getting started, reading output, layouts, triggers and the composer. Type to search; Enter on a + slash command puts it on the command line without sending it. +- No spelling dictionary? Alt+W in the composer (or Ctrl+P ▸ Get a spelling dictionary) offers to download an English + one into your settings folder. Nothing is fetched until you say yes, and the file is checked before it is saved — + so spelling now works on Windows too. +- While a pane is narrower than the width a game wraps at, Ctrl+P suggests "Join server wraps for" that world and F5 + marks its "join wraps" setting as recommended. It stays off until you turn it on. + +- Desktop window: the map, vitals, who is here and the quick bar can be popped out into windows of their own — from + the button in the panel's corner, the pane's right-click menu, or Ctrl+P ▸ Pop out. The output takes the room back, + the floating window works exactly like the panel did, typing in it still goes to the command line, and closing it + (or Dock) puts the panel back in its place. Where each one floats is remembered with your layout and brought back on + screen if a monitor has gone. The terminal keeps every panel in its pane. + +- Desktop window: the quit prompt, search (Ctrl+F), history search (Ctrl+R), the layouts list (Alt+M) and the + first-run welcome are native dialogs in the window's light or dark look, worked from the keyboard exactly as in the + terminal. The quit prompt always comes up in front of whatever is open — closing the window while writing a post + no longer hides it behind the composer — and Stay takes you back to what you were doing. +- Packages (F10) open in the desktop window's settings as a package manager: switch each installed package on or + off, see what it adds, who it applies to and whether the index offers an update, copy its sets to edit or remove + it. The Browse tab fetches the package index only when you ask, lists your world's packages first, and Install + shows a review that leads with what can act as you; nothing is written until you install. In the terminal, F10 + now also shows each package's author, description, scripts, media and update, and the install review opens with + what can send commands as you. +- `telnet://` and `telnets://` links open the client: it adds the world (or dials the one you already have for that + address) and connects, and never logs in or sends anything a link names. Links with a user name, a password or + anything after the address are refused, and say why. If SharpMUTerm is already running the link opens there. The + Windows installer has an option to register the links, and the Linux desktop file declares them. +- Character passwords are kept in the system's credential store — Windows Credential Manager, the Secret Service + keyring on Linux, the Keychain on macOS — when there is one, and moved there from `secrets.json` the first time. + Without one, `secrets.json` works as before. +- The window's title, and the terminal's, say who is in front: `Character@World — SharpMUTerm`. The terminal's own + title comes back when you quit. +- Desktop window: a screen reader that moves to the output finds a read-only document holding the newest lines of the + window in front, alongside the lines it already hears as they arrive. +- Triggers, aliases, key bindings and timers (F2, F3, F4, F6) open in the desktop window's settings as a list + beside the selected item's fields: tick a box to switch one on or off, add, copy, delete or move it up and down, + and watch the trigger's live test mark the matching lines as you type its pattern. A package's sets are shown + read-only with *Copy to edit*. *Make a trigger from the selection* opens there on the new rule. In the terminal, + Alt+Up and Alt+Down move the item under the cursor within its set, and the settings search finds every one of + their fields. +- Desktop window: the composer is a native text editor. F1 opens full screen over the panes and the docked composer + is an editor in its pane, with the platform's selection, undo, input methods and paste, a monospace or proportional + face (right-click), and a wavy red line under a misspelt word whose right-click menu offers suggestions and Add to + dictionary. The draft, Ctrl+S, Alt+L, Alt+W and Esc work exactly as in the terminal, and the same text sends the same + command. +- Alt+C moves the keyboard into the connection sidebar, in the terminal and the desktop window: the arrow keys, + Home and End walk its rows, Enter goes to the one you are on, and Esc (or Alt+C, or just typing) returns to the + command line. +- Desktop window: a sidebar with more rows than fit scrolls, with the mouse wheel or a scrollbar like the panes', + and a right click on a world, character or window opens a menu of what you can do with it (switch, connect or + disconnect, edit, go to, close, clear), each with its key. +- Switching theme repaints the output already on screen, not only what arrives next, and Space on a theme in the + picker uses it as Enter does. +- A narrow terminal gives its room to the game: below 100 columns the connection rail folds to its slim strip + on its own (in the desktop window, the sidebar does), and below 100 columns or 30 rows an empty command line + is one row tall and grows as you type. At 80×24 a pane is now 72 columns wide instead of 60, two rows taller, + and a world's 80-column text no longer leaves a stray word on every other line. Folding or unfolding the rail + yourself (Ctrl+B b) always wins, until the window next crosses 100 columns. +- The status row names the keys of whatever you are in: scrolled back (End returns, Alt+F freezes), frozen + (Alt+F thaws), on a search result (Alt+G next hit), or writing in a docked composer (Ctrl+S sends, Esc + releases, F1 full screen), in place of the pane hints, and only where they fit. +- Desktop window: notices also appear as toasts in the bottom-right corner. They stay while you hover over them, + fade after a few seconds, and never take the keyboard. Some carry a button, such as *Change layout* or + *Reconnect*. The status row still shows each notice, as it does in the terminal. +- Maps colour each area, mark rooms with stairs up or down, and show a legend: a one-line legend under the map in + the terminal, and a legend in the corner in the desktop window. In the window, room names appear when you zoom + in, and hovering over a room shows its name. +- A theme picker at the top of F7: each theme with a preview of the panes, a tab strip and the command line. + Moving onto a theme shows the screen in it, Enter uses it and Esc keeps the one you had. The desktop window's + settings show the same themes as cards; click one to use it. A customised theme stays in the list when you + try another. +- Desktop window: the settings window now edits Worlds & Characters (F5) and Characters (F9) too, with a + picker for which world or character you are editing and a button to the full screen for adding and deleting. +- Ctrl+P lists *Fold or unfold the connection rail*, the same fold as Ctrl+B b and as the desktop window's + sidebar button. +- Desktop window: the header and the connections sidebar now have room of their own — a full-height toolbar + across the top and a 232-pixel sidebar down the left — instead of being squeezed into one row and one column + of text, and the text area is the rest of the window, so every pane is as wide and tall as it really is (and + that is the size your games are told). While Ctrl+B is armed the header lists the keys it answers, dimming the + ones that cannot run, with each key's meaning in its tooltip. The sidebar folds to a slim strip of characters + with the chevron at its top or Ctrl+B b, and stays in place, inactive, while a dialog or settings screen is + open. `--cell-chrome` draws them as text, the way the terminal does. +- Desktop window: Ctrl+P opens a native command palette, a rounded popup near the top of the window with a + search box, grouped results, the matched letters highlighted and each command's key on the right. It lists + the same commands in the same order as the terminal's palette, recent and suggested first. Arrow keys and the + mouse choose, Enter runs, Escape closes and returns you to the command line. The header's search box and a new + *More commands…* entry at the foot of every right-click menu open it too. +- Desktop window: F7 (Text & display) and F8 (Input) open a settings window with real switches, number boxes + and a search box. Matches are counted per section as you type. A changed setting shows a dot and a Reset + button, and every change applies and saves as you make it. The status-row meters are a table that shows each + meter's current value, with the paths your game has sent listed for you to pick from. The other screens are + listed in the window and open as before. +- Desktop window: a Layouts button at the right end of the header opens a menu of the built-in and your saved + layouts (the one in use is checked), with *Save changes*, *Save as new*, *Use for* the world or the character, + and *Manage layouts…*. It runs the same actions as Alt+M. +- Desktop window: the vitals panel is drawn as smooth gauges, with each meter's name and numbers, the low ones + in the warning colour (*Mana · low*) and what you are fighting. A tall vitals pane stacks them; a strip lays + them side by side. +- Desktop window: the header and the connections sidebar are drawn as native controls. The header has a + search box that opens the command palette (Ctrl+P) and a pill naming the character in front, its world and + whether the connection uses TLS. The sidebar lists worlds, characters with presence dots, unread badges and + "offline", the open character's windows with their Alt+number keys, and an *Add a game or connect* button. + Clicking a row switches exactly as clicking it in a terminal does, and a drag on empty header space still + moves the window. +- Desktop window: the map is drawn as a canvas of rooms and exits. The wheel zooms, a drag pans, a + double-click re-centres on you, and a click on a room walks there. Hovering a room shows its name. +- Desktop window: who is here is a native list, with a coloured dot for each player (dim when idle), how long + they have been idle, and creatures dimmed below. Hovering a name lights the row and shows the full name; a click + puts `look` and the name on that character's command line, as in the terminal. The quick bar is a row of + buttons, with the bindings that do not fit under a `+N` menu and *F4 edits keys* as a link. Neither takes the + keyboard away from the command line, and native panels no longer sit on top of a settings screen or ⌃P. +- Scrolling a pane back splits it: the history you are reading stays on top, a band says how far back you + are (`scrolled back 120 rows · End returns · ⌥F freezes`), and the newest lines keep arriving underneath. + End (with the cursor at the end of the command line), Ctrl+End, or scrolling the history back down to its end + closes it; ⌥F turns it into a freeze. The split is inside the pane, so the game is not told a new window + size. Text & display (F7) ▸ *split the pane when scrolled back* turns it off. +- Worlds & Characters (F5) has a `join wraps` row on each world: on, lines the game broke at its own width + (usually 78 or 80 columns) are joined back together, so a paragraph fills a wide pane and does not wrap twice + in a narrow one. It is careful — it joins only a full row that ends mid-sentence onto a row that carries on in + lower case, in the same colour — and triggers see the joined line. It applies to lines that arrive after you + turn it on. +- A first run has somewhere to start: with no worlds, the client opens on a welcome offering *Connect to a + host* (a small form for the host, port, TLS and your character's name), *Browse games* from the package + index and *Open settings*, with the `/connect` line spelt out and a starting layout to pick (Roleplay, + Combat, or suggested from what the game sends). Esc closes it; `/welcome` or Ctrl+P ▸ *Welcome* brings it + back. With `--plain` the choices are read out as commands to type. +- After the first connect of a new configuration, one row names the keys worth knowing (Ctrl+P, Alt+M, F1, + F5). Ctrl+P ▸ *Key tour* hides it and shows it again. + +- Ctrl+P matches as you type by initials and fragments, not only by prefix: `trig sel` finds *Make a trigger + from the selection*, and the letters it matched are highlighted. The commands you last ran from it are + listed first under RECENT, followed by what the moment suggests (copy while something is selected, back to + live output while scrolled back). Every entry's key sits in one right-aligned column, spelt one way (⌥⏎, + not Alt+⏎), and a narrow window shortens titles but never keys. +- Keypad & hotkeys (F4) marks a binding that hides one of the client's own keys (such as Ctrl+C copy) with an + amber △ and says the binding wins, and marks a second binding on the same key as one that never runs. + Ctrl+P lists *Key clashes (N)* while there are any. +- Layouts. Alt+M (or Ctrl+P ▸ *Layouts*) lists the built-in Roleplay and Combat layouts and the ones you save: + ⏎ applies one to the panes, `s` saves the panes as they are into the selected layout, `n` saves them as a + new one, `w` makes it the world's default and `c` this character's; `r` renames one of your own and `d` + deletes it, after asking. Worlds & Characters (F5) has a `layout` + row on each world and each character. A panel appears only once the game sends what it shows, so a MUSH + never gets an empty map pane, and the first time you connect to a world the client picks Combat or Roleplay + from what the game sends and says which in a notice. A workspace you arranged yourself is still what comes + back after a restart. +- Search the settings: press `/` on any settings screen and type. Every option, and every settings screen by + name, is listed with how many matches each screen holds; ⏎ goes straight to the option on its own screen. +- A setting you have changed from its default is marked `● changed` on Text & display (F7) and Input (F8), + and `r` on it puts the default back. +- Four panels a layout can place, each also on Ctrl+P: *Show who is here* (the players and creatures in your + room, from GMCP; click a name to put `look ` on the command line), *Show vitals* (your meters as bars + with values, a warning colour when low, and what you are fighting), *Show quick bar* (your function-key + macros as `F11 kick`; click one to run it) and *Dock the composer* (the F1 editor as a pane that shares F1's + draft: ⌃S sends, ⌥L switches escaping, F1 opens it full screen, Esc goes back to the command line). + The vitals strip and the quick bar are exactly one row tall, with no tab over them, and the docked composer + never shrinks below a few rows. While the vitals strip is on screen the status row leaves its meters out + rather than showing them twice. Panels are part of the layout, not places you go: they take no row in the + sidebar and no Alt+number, so your map and capture windows keep theirs. +- Make a trigger without writing a regex: select a line of output and choose Ctrl+P ▸ *Make a trigger from + the selection* (also on the right-click menu). Triggers (F2) opens on a new rule whose pattern is the line + with its numbers, quoted speech and known player names turned into `*`, and shows the regex behind it. + While you edit a pattern, F2 shows how many recent lines it matches and the newest few, and a rule can now + match "contains the text", "starts with" or "matches with * wildcards" as well as a regular expression. +- `/connect [port] [tls]` adds a world and connects to it, and `/connect ` opens a configured + one. It is the way to add a world in `--plain`, where the settings screens cannot be used. +- The desktop window bundles JetBrains Mono and the Nerd Font symbols, so its icons draw on a machine with no + Nerd Font installed instead of showing boxes. - A crash now leaves its stack in `logs/client-diagnostics-*.log`, in the window and the terminal alike, along with errors the UI framework used to swallow without a trace. - A `scripts` field on each trigger set in Worlds & Characters (F5), and Ctrl+P ▸ *Reload scripts*, which @@ -106,12 +476,296 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Changed +- In the desktop window text is drawn with light hinting, as the design mockups are: labels and headings no longer look a + size heavier than intended, and a panel's heading sits in the middle of its band. Map exits are crisp 2-pixel lines, and + the docked composer's Literal / As typed chip is a quiet outline with a slightly taller Command box. + +- In the terminal, the map draws corridors three cells long when the area fits, puts its title on the panel heading + band, keeps its words a cell in from the edge and says "Click a room to walk there" in a tall pane. +- The settings search lists its sections on a navigation column of their own, a shade off the page, with the selected + section in bold. +- In the desktop window the map's rooms and exits are a quiet blue step of the panel, as the combat mockup draws them, + rather than a neutral grey (Light and Solarized Dark take the same lean toward their own focus colour), and the + collapsed Chat bar at the foot of the combat column is a slim 38 px row instead of reaching up beside the command line. +- The terminal looks more like its design: the sidebar's heading and world names are plain (no corner or stripe), the + who-is-here list, vitals card and quick bar keep a space of margin at their edges, quick-bar keys are quiet so the + names read first, vitals bars show their empty part as a dim solid track, the docked composer's text sits in a darker + well, the scrolled-back band no longer colours its count, the header says "no connection" when no world is set up, and + every two-column settings screen's divider runs the full height. The settings search lists its sections under a + SECTIONS heading with a wider search box. +- In the desktop window's combat layout the vitals card, the quick bar and the collapsed chat bar are drawn on the same + plane as the map beside them, parted by a thin rule, as in the design mockups. The vitals card's numbers are in the + game text's monospace face, and the vitals, quick bar and chat bar text is the size of the rest of the side column. +- On the desktop window's welcome page the layout cards centre their text and the two check boxes sit closer together. + +- The desktop window is closer to its design: interface text is a size smaller (13 px, 12 px for labels and the status + footer), the connections sidebar is a little wider with its own edge and taller rows, the header's search box is + narrower and quieter, the footer and the command line's strip sit on the header's colour, tabs line up with the output, + and the composer's text is the same size as the game's. +- The desktop window's settings window now matches its design more closely: the section list, search box and page line + up as drawn, small text such as column headings, help lines and the footer is a size smaller, the meter table's rows + are spaced and padded as drawn, and each meter's live value is shown in that meter's colour (red when it is low). +- The terminal's package list (F10) names ⌃Z undo in its key line while a package is listed. +- In the desktop window a room's name on the map never runs across an exit line or a way into another area: a name with + no clear place is shortened, and one that cannot be is shown when you point at its room. +- In the desktop window's combat layout the vitals card ends just under its last gauge, so the map below it starts + higher and the empty band between them is gone. + +- The first-run welcome looks like its design. In the desktop window it is the whole first page rather than a card + over an empty client: a centred column on the dark ground, Find a world wider than Connect directly, the first listed + world raised with Add in the accent, the chosen starting layout outlined in the accent, and square check boxes. Drag + its empty ground to move the window. In the terminal the welcome's title and its keys sit on bands across the top + and foot of the box, the box is the settings screens' plane with a quiet frame, and the layout note sits beside the + layout chips when the terminal is wide enough. +- In the desktop window, Browse worlds' search box is sunk into the card with a magnifier, like every other search box. +- A pane scrolled back now splits in half rather than giving the live tail 30%, and the history being read is drawn + on a slightly different background from the lines still arriving. In the desktop window the "Scrolled back" band is + taller, runs the whole width of the pane with its words lined up with the output and a full-size "Jump to latest" + button, and the scrollbar runs beside the history only. +- In the desktop window the command line sits under the output column only, as wide as the text it sends to; the + side column (composer and who is here, or vitals and map) runs down to the status footer. The field is also a few + pixels further in from its edges, as the mockups draw it. The terminal is unchanged. +- The Dark theme is navy, as the mockups are, in the terminal and the window alike: deep blue panes with the focused + one a step lighter, the sidebar and status row on the darkest ground, a dark header with a hairline under it, a + command line on `#233049` with a blue edge, blue-grey rules and text. Light and Solarized Dark are unchanged, and + pane tints look as they did. +- The window's sidebar marks one row as selected, with a teal plate: the character, while their main window is in + front, or the other window that is. +- A tab strip whose tabs all belong to one character names them plainly (`Corvid`, `Chat 2`); the owner is still + written in a strip holding two characters' windows, and on a capture alone in its pane. +- The desktop window lines up: one spacing scale for every native view, the header at the mockup's 44 px, output + starting about 20 px in from its pane's edges with a row of space under the tab strip, the sidebar's names, the brand + and every panel's heading, rows and buttons on one 16 px edge, the "+ Add a world or connect" button left-aligned, + equal-height buttons and fields, and every panel heading the same height. Rules and borders are one crisp pixel at + 125% and 150% display scaling instead of a blurred two. + +- In the desktop window, the line between two panes is a hairline where the panes meet instead of a grey band, and + lights up wider while you hover or drag it. In the command line, what you type starts right after the prompt. + The docked composer's "Click to write a post" is in the interface font. +- The command palette in the terminal shrinks to the rows a search matches instead of filling the screen with blank. +- In the window, notices are drawn on the same card colour as the palette and dialogs, and body text is a size larger + (13 px) across the window's own views. +- The built-in layouts follow the new designs: Roleplay puts the composer in the side column above who is here, with + the chat windows as tabs behind the output; Combat puts the vitals in the side column above the map as a card, one + meter over another. Below 100 columns they fall back to what fits: Roleplay's composer goes under the output and + Combat's vitals become a one-row strip across the top. Layouts you saved are unchanged. A pane showing only a panel + (the composer, who is here, the vitals) no longer draws a tab strip over it; the panel's own heading names it. +- In the desktop window, each pane's tabs are taller with larger, centred labels, game text no longer runs against the + pane's edge (a column of space either side, reported to the game as the pane's width), the header shows the plain + "SharpMUTerm" name with the search box beside your character, and the command line's prompt sits at the field's + left edge. +- In the desktop window, ⌃P, the quit prompt, search, history, layouts, help, the script console, toasts and the + right-click menu now match the window's new look: blue-grey cards, search boxes sunk into the page with a magnifier, + keys drawn as small key caps, an accent bar on the selected row, and a footer band naming the keys. +- The desktop window's settings look like the rest of the redesigned app: a header with the search box and, for worlds + and characters, which one the settings apply to; the sections down a darker side column; each group of settings in a + card of its own, with what you searched for marked in the labels, a "Changed" mark and a "Reset to default" link; and + a footer saying changes apply as you make them. The meter table shows the paths received as a drop-down style panel. +- The older-history page fits an 80×24 terminal: it sizes its page to the screen, keeps its keys on screen and no longer + covers the command line. +- Ctrl+F with older history says both counts on one line (`this window 3 of 39 · older 4 of 6,000`). Older matches say + how far back they are (`1.6k lines ago`) and the pane's own say `here`. +- The script console opens at the size of what it holds, so its log no longer starts under blank rows. +- In the desktop window the map opens sized to the rooms around you, with their names, and stays where you put it + once you zoom or pan (a double-click fits it again). +- The pop-out button on a docked panel shows only while the pointer is over that panel, and never covers the text of a + one-row strip. +- The command palette's script entries are under Help & setup, and a five-letter search such as "scene" no longer + matches letters scattered across unrelated entries. +- F5 at 80 columns shortens long values with "…" instead of cutting them off, and the highlight-names help shows each + colour in that colour. + +- The settings screens' key hints no longer lose words from the middle at 80 columns: the least-used keys are left + out and the way to close is always there. +- Help topics have headings and show their keys as keys, in the terminal and the window, and a topic's title fits + on one row at 80 columns. +- The window's dialogs (quit, search, history, layouts, welcome) dim the window behind them more clearly. +- Editing F5 ▸ highlight names shows the `Name=colour` syntax and the colours you can use, in both the terminal + and the window. +- "Connecting", "connection lost" and "connection failed" are now client rows in the window, like the reconnect + countdown: they no longer go into transcripts, the restore log, scene logs, triggers or Ctrl+F. +- At 80×24, F5's world column shows a page of fields instead of one, naming the group at the top. +- When a notice has an action, the terminal's status row names it (`· ⌃P ▸ Show messages`, or the action's key) + if there is room. +- At 80 columns the terminal's key rows fit on one line: the F1 composer, the script console, the map's edit keys, + the import review and F10 shorten their words and then leave out the least-used keys, never the one that closes. + The map's edit keys are now one row under the map instead of a block beside it; `?` lists them all (also under + `/help` ▸ Maps). +- The terminal map writes room names beside the rooms when the pane is wide enough, and always lists where your + room's exits lead in the same place, under the map. +- F5's world page lists its fields under headings (Connection, Login & protocols, Text & input, Alerts & channels, + Map, Sound & layout) with their full names, in the same order in the terminal and in the desktop window. +- Ctrl+P's OUTPUT group is split into WRITING, MAP, PANELS and HELP & SETUP. A new configuration's palette suggests + Help, Welcome and Import first. +- Reconnect messages are written as a client row in the window, not as session lines, so they no longer reach + transcripts, the restore log, scene logs or Ctrl+F. +- The desktop window no longer shows a toast for each map edit; failed edits still do. A connection failure's notice + offers Show messages instead of a bare Ctrl+P. +- The first-run status row no longer says which graphics protocol is in use, and F5 says "1 character", not "1 chars". +- Light theme: background tabs and one-row panels (vitals, quick bar) sit on a lighter grey, and small text on the + settings screens is darker, so it is as easy to read as body text. +- Desktop window: the composer's "no dictionary" chip is a button that offers to get one; in the terminal F1's key row names + Alt+W beside it. The world settings show the same "join wraps" recommendation the terminal's F5 does. + +- A character is named one way everywhere: the command line's prompt, the window title and tab titles now read + `Corvid · Aetherfall` and `Corvid · Chat` instead of `Corvid@Aetherfall` and `Corvid - Chat`. +- Messages from packages, the map and sound name where they came from as a label ("Sound: muted.") rather than in the + place a shortcut key is shown. The desktop palette's empty search box now starts with "Command palette". + +- Desktop window: one look across settings, menus, the command palette and every prompt — the client's own teal + instead of a stock blue, one set of text sizes and corner radii, and every colour readable on Dark, Light and + Solarized Dark. The settings panel now has a title bar with a close button, dims the window behind it, and when + opened from "Make a trigger from the selection" sits on the right half so the line you picked stays visible. +- Light themes: the header's coloured segments are deeper, with light text that is easy to read, and the low + vitals warning is a readable dark orange instead of a pale yellow — in the terminal and the window. +- The terminal's command palette, quit prompt, search, history, Ctrl+B panel, game data and message log now + follow the theme instead of staying dark on Light. +- A character is named the same way everywhere — `Corvid · Aetherfall` — in the quit prompt, the command palette, + the layouts list, Packages and the client's messages, instead of `Aetherfall.Corvid` in some places and + `Corvid (Aetherfall)` in others. +- The command palette is called that everywhere, its output commands are grouped under OUTPUT, and a short word + you type (four letters or fewer) now matches only at the start of words, so `layo` finds the layout commands + rather than unrelated entries that happen to contain those letters. +- The keypad screen (F4) and the quick bar show keys as `⌃F1`, `⌥M` like every other surface. +- The welcome says *world* throughout, labels every field of its connect form, and the desktop window's version + matches the terminal's word for word. +- The folded sidebar marks each world with a coloured bar and gives two characters with the same initial two + letters each, so they can be told apart. +- Desktop window: a trigger's route on F2 is a drop-down listing every window, and still takes a new name typed in. +- "Offline" notices and messages that tell you how to reconnect name the key (`⌥R`) as well as the palette entry. +- The command line starts as one row and grows as you type; a height you set before is kept. +- The status row no longer wraps onto a second row when a message is long; it is shortened to fit. +- The same message said twice in a row is shown once. In the desktop window, notification cards no longer cover the + quick bar or the composer, and are hidden while settings, Ctrl+P or a dialog is open. + +- Panes stacked one above the other no longer have an empty row between them when the lower pane shows its + tabs: the tab row is the boundary, and the panes get the row back. Drag the empty part of that tab row to + resize, or double-click it to even the panes out. The vitals and quick-bar strips sit flush against their + neighbours, on a slightly darker background. Side-by-side panes are unchanged. +- The status row's meters are edited in a table on Text & display (F7): one row per meter with its label, + value path and maximum path, what it reads now, `[+ meter]`, and Delete to remove one. While you are in + the table it lists the game data this connection has received, and a path field offers those paths, with + their values, to pick from. +- F7 is now called Text & display and its options are grouped under Colour & legibility, Text, Links & + pictures, Status row & meters, Activity and Sound. A list taller than the terminal scrolls with the cursor + instead of running off the bottom. +- The ten pane colours a character can be given (F5 ▸ `tint`) have been re-chosen so they stay apart for + players with red-green or blue-yellow colour blindness, and a little further apart for everyone else. + The names are unchanged, so nothing needs setting again; Ochre is now a red ochre and Ember a red. In the + window, pointing at a tinted character in the sidebar or at its tab names the colour. - An MXP picture is downloaded and decoded once per session. A room picture shown on every look used to be fetched again whenever the pane's width had changed, and once per look that arrived while it was still loading. ### Fixed +- The older-history view (⌥H, then ⏎) no longer wraps every line onto a second row and shows a scroll bar at 80×24, and its + title no longer wraps at 120 columns. +- A web page opened in a pane beside the sidebar is laid out at that pane's width, so its paragraphs no longer leave a word + alone on every other row. +- Every settings screen's title now sits one cell in, the same on F2–F6 as on F7 and F8. +- Shortened text no longer leaves a space before the `…` (the map's title and legend, Browse worlds, packages, game data). +- The question after deleting worlds keeps a cell between its frame and its words, like every other pop-up. +- The settings search's section list no longer shortens "Triggers & spawn routing" at 80 columns when it fits. +- The desktop window fits the screen at 125% and 150% display scaling. It used to open taller than a 1080p screen, with + the command line and status row below its edge; it now opens a few rows shorter, centred. The settings window fits + too, and the welcome page no longer scrolls at 150%. +- At 125% and 150% the desktop window's pane dividers, tab strip rule and command-line field edge are one sharp pixel + rather than a blurred two, and the tab strips and the command-line field reach the window's right edge instead of + stopping a few pixels short. +- The docked composer's footer says `28 words` where `2 paragraphs · 28 words` does not fit, rather than cutting the + count off, and no scroll bar shows over its edge. +- In the desktop window's settings, Ctrl+Z in a text box you have just confirmed with Enter now undoes that setting + instead of only changing the box back on screen; the box keeps Ctrl+Z for typing you have not confirmed yet. +- The desktop window's trigger, alias, key and timer pages no longer show a second scroll bar beside the editor. +- On the Light theme the history half of a pane you have scrolled back is now a visibly different shade from the live + lines below it, in the terminal and in the window; before it was the same white. +- Dragging a tab in the desktop window no longer draws the drop zone twice: only the window's wash shows where the tab + will land. +- Switching a package on or off on F10 can now be undone with Ctrl+Z (and redone with Ctrl+Y), in the terminal and in + the window's package page. +- The desktop window's welcome page now takes the keyboard when it opens; typing used to land on the command line + hidden under it. +- In the desktop window the command line no longer shrinks to a sliver under a narrow output pane: when the output's + column is under 54 cells (three panes side by side, say), the bar goes under the widest column around it that fits, + or across the whole window, and moves back when the window is widened again. +- In the desktop window, the Roleplay layout no longer draws a thick coloured bar between the Compose and Here panels, + only the hairline seam; the docked composer's Command hint now reads whole (`+pose, say, +bbpost 3=`). +- Terminal polish from a sweep of every screen at 80×24 and 120×32: the header's brand reads `SharpMUTerm` where the row + has room; the welcome's chosen row is a band across the list; pop-ups keep a cell between their frame and their words; + a tall pop-up (the import review at 80×24) no longer covers the command line; the `❄ FROZEN` bar's rule runs to the + pane's edge instead of vanishing in a narrow pane or stopping short in a wide one; the script editor's line numbers are + legible on the Light theme; and the status row no longer says a key twice (`⌥R … connects it · ⌥R Reconnect`) or a + label twice (`Map: map: …`). + +- The command palette no longer covers the command line on an 80×24 terminal. +- A tab squeezed by a crowded strip keeps at least three letters of its name. +- The build no longer warns about an ImageSharp advisory in a decoder SharpMUTerm never uses. + +- The import review at 80×24 no longer scrolls its Enter and Esc keys and the passwords question out of sight: they + stay at the bottom while the rest scrolls (arrow keys, PgUp/PgDn, Home/End). +- The script console's store no longer says "read only" while Del deletes a key; the console names Del only on the + store list, where it works. +- The map's edit keys at 160 columns no longer read "PgUp PgDnlevel". +- F5: editing a world field low on the screen (channels, names) now scrolls the field into view instead of leaving + the cursor off screen. +- F5 ▸ channels `move`: a channel message whose echo arrives just before its GMCP in the same read is no longer left + in the main window as well. + +- Desktop window: the settings window can be resized again from any edge, and opens at the size you last left it + for the rest of the session. Notification cards disappear as soon as it opens rather than showing beside it. +- Clicking a tab strip's `+N ▾` no longer also selects (or closes) the tab drawn underneath it. +- Light theme: the trigger and keypad screens at 80 columns are no longer drawn in the dark theme's colours, error + notices on the status row are readable, the command palette's selected row is readable, and the sidebar and status + row are a light grey rather than a mid-grey that looked disabled. The window's sidebar labels are easier to read too. +- The composer no longer marks the command word (`+bbpost`, `@emit`) or your own character's and the room's names as + misspelt. +- Who is here keeps whole names at 80 columns by dropping the "active" / "idle" words first (the dot still says it). +- The status row's room name uses the space a wide terminal has instead of being cut short. +- The composer's "as sent" preview wraps at spaces instead of in the middle of a word. +- Desktop window: the docked composer no longer says "click to write" twice. +- Demo: the composer and the startup message no longer say nothing is connected while the header says Corvid is. + +- The away, new-activity, search and restore bars fit the pane they are in: their rule runs to the pane's edge, + never past it, and the words shorten before the rule does in a narrow pane. They redraw when the pane is resized. +- Search (Ctrl+F) fits an 80-column terminal: its key hints shorten instead of pushing the window wider than the + screen. +- A trigger pattern anchored at the start of the line (`^\[public\]`) now finds lines in search and in F2's live + test that a highlight rule has marked, as it already did in the game. +- Desktop window: number-pad keys bound on F4 fire, and F4 no longer marks them as keys that never arrive. +- F1 opened from an offline character's window names that character instead of no one. +- Starting the client with a saved workspace whose panes held a panel (vitals, who is here, the quick bar, the + composer) or the map no longer fails before the first frame. +- Desktop window: the "no world configured" notice at first start shows once, not as two identical toasts. +- Desktop window: a notice you point at stays where it is while the others fade, and notices no longer cover the + map's legend. +- Desktop window: every key is written in words everywhere in the window — the menus, notices, Ctrl+P, the composer, + dialogs and settings now all say Ctrl+P, Alt+M and Enter rather than mixing them with ⌃P, ⌥M and ⏎. +- Desktop window: the settings window takes the keyboard when it opens, so you can type in its search straight away, + and gives it back to the command line when it closes. +- Desktop window: the composer's proportional or monospace font is remembered for each character, and kept after a + restart. +- Desktop window: the trigger, alias, timer and key lists in settings remember which item you had selected when you + switch section or reopen the window. +- Packages (F10) fit an 80×24 terminal: the list no longer scrolls to hide its last rows or the close key. +- While Alt+C has the keyboard in the sidebar, the command line is drawn idle, since Enter goes to the sidebar row. +- Desktop window: the Keys screen (F4) and the quick bar now describe the window's keys rather than a + terminal's. Ctrl+digit, Ctrl+Shift+letter, Ctrl+Tab and Alt+O are shown as working keys you can bind, and + Alt+Enter is marked as taken because it maximises the window. +- Terminal: the quick bar's *F4 edits keys* can be clicked, as it already could in the desktop window. With no + room known, the map panel now centres on the same room in the terminal as in the window. +- The message a client with no worlds shows no longer wraps the status row onto a second row at 80 columns. +- A `--snapshot` frame with an MXP picture in cells a server left for it no longer sometimes leaves one of + the picture's rows blank. +- At 80 columns the status row no longer wraps onto a second row: the encoding and the Ctrl+P hint are + dropped first. +- Health, mana and movement meters stay on the status row on a narrow terminal, shown as percentages when + the full numbers do not fit. +- Triggers (F2) and Keypad (F4) no longer draw their editors off the right-hand edge on a narrow terminal. + When both columns do not fit, the screen shows the one you are in, and header and action bars shorten + their text rather than cutting off the keys. +- The settings screens (F1–F10), the deletion review and the package and send lists follow the Light and + Solarized Dark themes instead of staying dark on every theme. - MXP pictures draw again. Since the TelnetNegotiationCore update the client stopped answering a server's MXP `` and `` questions, so a game such as SharpMUSH decided it could not show `` and sent the picture's address as text instead. diff --git a/CLAUDE.md b/CLAUDE.md index a6b36fb5..80f18587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ fallbacks) for inline images/maps. ## Repository state -**M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all twelve projects on +**M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all fifteen projects on `net10.0`, with the full test suite passing. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model (a capped @@ -106,6 +106,21 @@ fallbacks) for inline images/maps. - **A rebuild clears the engines' runtime rules first** (`ClearRuntime`), or a reload doubles every `trigger.add`. `SaveConfiguration` rebuilds only when a session's file list changed (`SyncFiles`), since a rebuild restarts script timers; ⌃P ▸ *Reload scripts* always rebuilds and re-reads. + - **Scripts are edited in the composer's script mode** (`ScriptFiles`, Scripting; `SharpMUTermApp.ScriptEdit.cs`; + `ComposerEdit.Script`; ⌃P ▸ *Edit script…* / *New script…*, the console's ⌃E, `/script edit|new`; the `script-list` + and `script-edit` views). The files are ⌃P entries (`script:open:`, a family listed only once asked for, map + find's rule). Names go through `ScriptFiles.Check` and then `SessionScripts.Resolve`; a save is a temp file in the same + folder and a rename, refused through a link and outside the root. Lines verbatim (no `%r`, no escaping, no spelling, + line numbers, monospace). ⌃S saves, reloads that character's scripts and says the first error (`file:line`) in the + header and the console; the editor stays open. Esc keeps unsaved text per file for the run. `package:` scripts open + read only. The demo saves nothing. + - **The error's line is one model field, `ComposerEdit.ErrorLine`** (Client `ScriptErrors.LineIn`, the one reader of + `file.lua:(N,…)`, also behind ⌃P's `! line N`; `ScriptEdit.Said(status)` sets it from a `! …` status naming this file + and clears it on anything else). The cell editor puts that line on a band (`MultilineEditControl.SetLineHighlight`, + `ComposeOverlay.ErrorBand`: warn 28% over the well — a syntax token carries a foreground only); the window numbers + lines in a gutter (`ComposerView.LineGutter`, read off the `TextBox`'s own layout via `ComposerEditor.LineSpans`) and + flags the line there and with a band over the text (`DrawMarks`). The gutter re-measures only when its digits or face + change: the editor's layout raises `MarksMoved`, and measuring on each was an infinite layout loop. - **GMCP and MSDP data is held per session in `GameDataStore`, and three things read it**: the status row's meters (`StatusMeter`, F7 ▸ `meters`), ⌃P ▸ *Show game data* (`GameDataOverlay`) and Lua (`gmcp.get`, `msdp.get`). GMCP packages are stored by name with objects merged key by key, because IRE and Aardwolf send @@ -118,6 +133,14 @@ fallbacks) for inline images/maps. - **A meter is drawn only once its value has arrived, and from then its fields are reserved** (value and max five cells each, `StatusMeter.Fit` scales past that). A world with no game data costs the row nothing; one with it never reflows the row as numbers change. + - **Each meter has a colour** (`StatusMeter.Colour`, null = `DefaultColour(label)`: hp green, mp blue, mv gold, else the + accent; no schema bump, absent means default; the one-line form writes ` @name`). Client `MeterColours` turns a palette + name into a *meter shade* (the mockups' `#4cc38a`/`#5aa9e6`, not web green/blue), `VitalsMeter.Colour` and + `VitalsTarget.Colour` (`#ff8a65`) carry it, and each host holds it on its own plane: the cell strip and card + (`VitalsStripRenderer.Ink.For`/`Hold`) and the native gauges (`VitalsGauges.FillFor`) to 3:1 on the pane, the status row's + label (`StatusMeterInk`) to 4.5:1 on the row. **Low is the warning red in all three** (`WorkspacePalette.Low` = + `ScreenPalette.Warn` held to 4.5:1, every theme): it was the notice amber, which said nothing beside a gold movement bar. + F7's meter table has a colour column (`auto` + the highlight palette, typed `#rrggbb` too) in both hosts. - **Maps** (`Core/Mapping`: `WorldMap`, `MapRoom`, `PathFinder`, `MapStore`). Mudlet's room model (id, area, x/y/z, exits by direction and by command, room and exit weights, locks). `MapStore` keeps one file per **endpoint** under `maps/` (`MsspCache`'s reasoning), memory-only with no directory, never overwrites a @@ -130,11 +153,37 @@ fallbacks) for inline images/maps. `CommandSent` carries player commands only — never the login line or on-connect commands. - Maps are written on disconnect, session removal and exit, not per room (`IsDirty`). MXP room tags wait for `` (Phase 5a). + - **The cell map names rooms on the grid when the level fits** (`MapRenderer.Labelling`): the column step becomes + name + 6 (`[ ] name──[ ]`), the longest name up to 16 at which the whole level fits, the block centred rather than the + room; else 8 on a grid ≥100 wide; else the plain `[ ]` grid (every test map is narrower than `LabelFromWidth`). The + exits list is always under the grid, above the legend; the side column is edit mode's inspector only. Edit mode's + keys are one laddered row at the foot (`KeysLine`, `KeyDrop`); `?` (in `MapEditing.Keys`) opens `/help` ▸ Maps + (`HelpTopics.MapTopic`), which lists every key. - **The map window is a window, not a pane control** (`map:`, `WindowKind.Auxiliary`, owned by the session; `MapRenderer`, Tui). It is drawn from the recorder on `Changed` and whenever its pane's box changes (`SyncMapSizes` on `PostBufferPaint`), never fed from a line buffer, so its control lives in `_mapControls` and not `_panes`. Its `LinkClicked` is its own and answers only `mux:map:` — the output panes' handler never sees a map link, and the map's never sees a send. + - **Both hosts draw the map from one `MapViewModel`, which carries the colours** (`MapAreaPalette`, Client; + `MapRenderer` cells, `MapCanvas` native). Each area's colour is picked by a stable hash of its name (FNV-1a, + so the same area is the same colour every run and in both hosts) from one of two eight-hue sets, dark-pane + and light-pane (`WorkspacePalette.DarkPanes`), because no fixed hex clears 3:1 on both kinds of theme; every + entry clears the floor on its themes' reading planes (`MapAreaPaletteTests`), and the cells still pass each + colour through `Contrast.Legible`. `Areas` lists the drawn area first, then areas reached by an exit. A room + carries `Up`/`Down` (an up/down exit or stub, or an exit to a room on another level) and `Elsewhere` (the + other area an exit leads to). The cells draw a one-line legend on the last row from 6 rows up — words go + first, then names shorten, then areas are counted (`+2`); it never wraps. The window opens fitted (`MapCanvas.Fit`: the + rooms within 6 steps of the character's, up to a 90 px step, the mockup's) and refits on a new model until the player zooms or pans + (`Fitted`; a double-click or another level or area fits again); rooms are the mockup's 32 px (`RoomTarget`) wherever the + step leaves a line between boxes (`RoomSize`: at most 0.56 of a step). It names rooms whenever a step is 40 px or more + (`NamesFromStep`), the current room always, plus the hovered name on a plate, stairs triangles, a halo on the + current room and a legend plate. **Names are laid out, not centred blindly** (`Labels`/`LayOutNames`, cached per view): + the character's room first, then nearest first, each whole (up to `NameMaxWidth`) at the first place covering no room, + no placed name and no line the map draws, staying between the look buttons and the legend — under, over, beside, else + pulled aside — then elided to the widest that fits one of those places, else hover only (under `NameMinWidth`). **A + name never crosses a line** (wave 27): every exit of the view and every area spur is an obstacle (`ExitSegments`, not + only the room's own or those on screen), tested on the plate as drawn (`PlateOf`) after snapping; `MapNameExitTests` + lays out the demo streets and a dense 9×9 grid at five panes and five zooms and fails on any crossing. - **A speedwalk sends one step and waits for the map to say it arrived** (`Speedwalk`); it goes through `SendCommandAsync`, so the recorder sees the step. It stops off the path, on the world's `WalkFailurePattern`, after `StepTimeout` with no room, and on disconnect. @@ -142,6 +191,35 @@ fallbacks) for inline images/maps. read off the UI thread: every such read takes `MapRecorder.SyncRoot`, which the recorder holds while it changes the map and never while it raises `Changed` (a handler can end in the alias engine, which takes the script host's lock). A change a script asks for (`map.walk`, a lock) is posted to the UI thread. + - **The map names rooms, and is edited by hand in both hosts** (`WorldMap.Rename`/`SetArea`/`SetExit`/`Remove`/…, Core; + `MapRecorder.Edit(label, touched, change)`, `LinkNext`, `Undo`; Client `MapEditing`, `MapEdit`; Tui + `SharpMUTermApp.MapEdit.cs`; Avalonia `MapInspector`; the `map-edit` view; `map-level`, `map-area`, `map-find` and `map-mine` show looking up a level, another area, a find and + a hand-renamed room in edit mode). The cell title is the current room's name, + and `MapViewModel.Nearby` (each exit and where it leads) fills a side column from 64 cells or the rows under the grid + from 18. Every edit is a `MapEdit` through `ApplyMapEdit` → `MapEditing.Apply` → `MapRecorder.Edit`, on the UI thread + under `SyncRoot`, dirty and saved like a recorded room; undo snapshots the rooms an edit touches (10 deep, areas it + created go too). **Selection is client state per map window and selecting takes the keys** in both hosts (⌃P ▸ Edit + map, a cell click while editing, the canvas's right-click menu or click), so `SurfaceParityTests` compares + `MapDigest()`. Cell keys are `MapEditing.Keys`; the ones that need words put a `/map …` command on the command line + and put back what was being typed once it runs. "Next" on an exit links it to the next room walked into, never its + own. Deleting the room you are in is refused. + - **A hand edit is kept from the server** (`MapRoom.HandEdited`, `MapFields`: name, area, exits, position). Each edit + marks the fields it changed; `MapRecorder` skips those fields on every later report (the rest still follow the + server), and keeps each room's last report this run (`_served`). **Reset to server** (`MapEdit.ResetToServer`; + inspector button, menu entry, cell `R`, `/map reset`) clears the marks through `Edit`, so it undoes, and re-applies + that report at once if there is one; otherwise the next report wins. Undo restores the marks with the room. + `MapStore` is format 2 (`handEdited` per room); a version-1 file migrates on read, and unknown field names are dropped. + - **A map window can look at another level or area** (Client `MapLooking`, `MapViewpoint`, `MapLook`; Tui + `SharpMUTermApp.MapView.cs`; `LookMap`, a `PanelOverlay` callback and a surface action). The look is client state per + window (`_mapLooks`), handed to `MapViewModel.From(…, look)`, which anchors on the room there nearest the current + room's x/y and sets `Viewing`, `Levels`, `LevelAbove`/`Below` and `AreaChoices` (64 at most). Looking where you are + clears it. Walking still targets the real room. Ways in: cell title `▲ ▼ ⌂ here` and legend area links + (`MapRenderer.LookPrefix`), edit keys PgUp/PgDn/`v`/`h`, the canvas's Up/Down/Areas/Back buttons and ⇧wheel, + ⌃P `map:level-up`/`-down`/`next-area`/`here`, and `/map level|view|here`. Nearby is hidden while viewing. + - **A room is dragged on the canvas while editing**: snapped to the grid, one `MapEdit.Move` per drop (the cells' + ⇧arrows' edit), ghost outline while held; a drag outside editing still pans. + - **`/map find `** (⌃P ▸ *Find room…* puts it on the command line) lists up to 20 rooms by name, then area, as + chrome lines in the session window with `/walk `, and points the map at the first. - **Sound** (`Core/Media`: `MediaCommand`, `MediaQueue`, `MediaCache`, `IAudioOutput`). MCMP's `Client.Media.*` is read with TNC.Gmcp's typed records into `MediaCommand`s (`WorldSession.MediaReceived`), and one `MediaQueue` per connection applies the specification's rules — priority halts and refuses lower, @@ -181,12 +259,24 @@ fallbacks) for inline images/maps. character's own, so an update replaces them wholesale and never touches the player's edits; `c` on F10 copies them into the player's sets to edit. The only thing saved is `DisabledPackages`. - **The review is the consent step**: nothing is written until ⏎, and it leads with what can act as the - player (rules that send, scripts that run). Downloads are https only, capped while streaming. + player (rules that send, scripts that run). Downloads are https only, capped while streaming. The review is one + `PackageReviewModel` (Client) drawn by both hosts, and F10 itself is declared in Client + (`BuiltInSettings.Packages`); see *F10 is a package manager page* under the native settings window. - **Scripts are `package:/`** and `SessionScripts` resolves them only under that package's `scripts/`, through `PackageStore.ResolveScript`. They still need the scripts root, like any script. - One folder per package (`packages//`, beside `config.json`), swapped in through a `.id.new` staging folder; a snapshot's store has no folder and writes nothing. F10 is claimed (`opens Packages`); GNOME Terminal may take it for its menu, so ⌃P ▸ *Packages* is the way in that always works. + - **A `ListOverlay` is as tall as its lines *wrapped*** (`ListOverlay.Rows`, measured by the framework's own + `MarkupControl.MeasureDOM` at the modal's inner width), not as its line count: F10's header wraps at 80 columns, + and counting lines made the modal a row short, so it scrolled its close key out of sight at 80×24. F10 always + emits its two confirm rows (blank when idle), so the modal sized once at open still fits a confirmation. + - **A consent screen's keys are pinned, not scrolled** (`ListOverlay.Open(footer:, scrollKeys:)`): the body goes in a + `ScrollablePanelControl` and the footer rows are a sticky-bottom control, so a body taller than 80×24 scrolls under + them (↑↓ PgUp PgDn Home End, answered before the caller's key handler). The import review uses it (`ImportReview.Footer`: + passwords question + keys); `Lines` is body then footer. So does the package review (`PackageScreen.ReviewFooter`: a rule, + then ⏎ install · ↑↓ scroll · Esc cancel laddered, the "nothing is written" note only where it fits); a modal with a + footer stops 4 rows short of the desktop, so the command line stays in view at 80×24. - **The index** (`PackageIndex`; ⌃P ▸ *Browse packages*, `b` on F10) is `{"packages":[…]}`, each entry a manifest plus an https `url` and a `sha256`, at `AppConfiguration.PackageIndexUrl`. **It is fetched only when the player opens it, never at start.** An entry that breaks a rule is dropped and counted, not @@ -194,6 +284,78 @@ fallbacks) for inline images/maps. the review opens, so the review is never shown for bytes the index did not name. The focused window's world (its session's, else its owner's, never `_active`) sorts its own packages first; a `*` package does not count as the world's own. The default index repository does not exist yet. +- **Worlds and automation can be imported from MUSHclient, Mudlet and TinTin++** (`Core/Import`: `ClientImport`, + `MushclientImport`, `MudletImport`, `TinTinImport`, `ImportPlan`; Client `ImportReviewModel`/`ImportReviewSession`; + Tui `SharpMUTermApp.Import.cs`, `ImportReview`; Avalonia `ImportReviewView`; `/import `, ⌃P ▸ *Import from another + client…*, the welcome's last row; the `import-review` view). Formats: a MUSHclient `.mcl`, a Mudlet XML package or + profile save, a Mudlet profile folder (newest `current/*.xml` plus its `url`/`port`/`login`/`password` files), a + `.mpackage`/`.zip` (read in memory, never extracted), and a TinTin++ script. Decisions not to relitigate: + - **The file is untrusted text meeting the configuration.** Read by sniffing content, not the extension; XML with no + DTD and no resolver; bounded (16 MiB, 5,000 rules, 100 worlds, 4 KiB a field, 512 zip entries, depth 32). A rule + that cannot be carried over exactly is skipped and listed with its reason (`ImportSkip`), never approximated: script + code (Lua, VBScript, a MUSHclient `script=`, a Mudlet chain or colour trigger, a TinTin `#` command in a body) is + listed as left out and never executed; a rule whose command survives keeps it and lists its script as a `part`. + - **Nothing is written before ⏎, and nothing is overwritten.** `ImportPlan.For` decides on the client's thread what + is new: a world at an existing endpoint gains only characters it lacks, a clashing name gets ` (2)`, and the rules go + into one new set `Imported from ` (suffixed if taken), listed on the *new* characters only. `Apply` re-plans + and refuses if the configuration moved since the review. Saved through `SaveConfiguration`. + - **Passwords are off unless asked.** The review says how many the file holds; `p` (the window's check box) stores + them on the new characters, which the vault then takes. They never appear in a model, a notice or a log. + - **The review is the package review's shape**: `WHAT CAN ACT AS YOU` first, then `WHAT IT ADDS`, then `LEFT OUT (n)`; + one model drawn by `ImportReview.Render` (cells, a `ListOverlay`) and the native card (`SurfaceCatalog.Import`). + - **`ClientHost.FilePicker` (`IFilePicker?`, required)** is the window's native open dialog; null in the terminal and + headless, where ⌃P puts `/import ` on the command line instead. `--plain` answers with `/import yes|no|passwords`. + - Not done: Potato and BeipMU (formats not verified), MUSHclient time-of-day timers and colour-changing triggers, + Mudlet multi-line/colour/chain triggers, TinTin `#regex`/`#if` blocks. +- **The shared client shell** (`SharpMUTerm.Client`; `ContributionRegistry`). UI-agnostic, references Core only + (`ClientAssemblyTests` fails on a SharpConsoleUI or Avalonia reference). The registry holds six kinds — + commands, panels, settings sections (field descriptors only), status cells (reserved width, drop order), + overlays, output decorators — each with a stable id unique within its kind and an owner (built-in or a + package id, so `RemoveOwnedBy` takes a package's contributions with it). Order is registration order unless + the kind defines one, ties by registration. Only **commands** are live so far: + - **⌃P's catalog is the registry's.** `BuiltInCommands.Register` (Client) says what each entry is called, + its group, its key per host (`KeyHint`, `HostKey.Newline` resolved from `HostKeyGlyphs`) and when it is + offered; the work is the Tui's `RunBuiltInCommand`, handed in as `run`. `CommandPalette`, the pointer menu + (`AddCommands`, key from the contribution) and `DispatchCommand` all read `SharpMUTermApp.Contributions`. + Entries generated from live state (characters, windows, panes, settings screens, the send group) are a + `CommandFamily` owning an id prefix; a single command's id may not fall under one. Dispatch ignores + availability (a rail row or the other half of a stateful pair still runs). A new ⌃P command is a + contribution in `BuiltInCommands` plus a case in `RunBuiltInCommand`; Core's `CommandCatalog` keeps only + `CharacterCycle`. +- **⌃P's ranking and grouping are the Client's, so both hosts list the same rows** (`CommandPaletteModel`, + `FuzzyMatch`, `RecentCommands`, `KeyGlyphs` in `Client/Palette`; the Tui only draws, `CommandSurfaceRenderer`). + Core's `CommandMatcher` is gone. The surface is called *Command palette* everywhere (title, the native header's + search box, its automation name), and the group of output commands is `OUTPUT`. Decisions not to relitigate: + - **Matching is subsequence, word by word, in order**, scored per character: +1 matched, +8 at a word start, + +6 straight after the previous match, +4 on the first character, −1 per skipped character (−4 at most + per gap), +20 when the title starts with the whole query. Each word is aligned by dynamic programming, + falling back to the earliest-ending alignment. **A query word of `FuzzyMatch.StrictLength` (5) letters or + fewer must start at a word start and continue in runs or at word starts** (`layo` no longer finds "Clear + window, say you"; `scene` no longer finds Import or Packages); a longer word may match as any subsequence. A title match always + lists above a match on the detail, the key or the group's name; ties keep catalog order. Groups keep + their headers but are ordered by their best entry, so the best match is the first row. + - **An empty query lists RECENT, then SUGGESTED, then the groups**, each entry once. SUGGESTED is read off + `CommandContext` flags alone (selection → copy and make-a-trigger, frozen, scrolled back, walking, + zoomed, clashes) — not a guess at habit, which is RECENT's job. ⌃P refuses over the composer and the + settings screens are modal, so there is no "screen open" mode for it to adapt to. + - **Recents are in memory for the run** (eight, newest first, `SharpMUTermApp._recentCommands`). The only + view state that outlives a run goes into `config.json`, and which menu entries were clicked is not + configuration; command-line history, the nearest relative, is in memory too. Recorded when chosen on ⌃P + (`CommandPalette.Commit`, `RunFromPalette`), never on a key or a rail click. + - **A `CommandItem` carries `Key` and `Detail` apart from `Subtitle`**; `Subtitle` stays the joined line + for the pointer menu and the tests that read it. Keys are shown through `KeyGlyphs.Normalize`, so the + host's `Alt+⏎`/`Ctrl+⏎` read `⌥⏎`/`⌃⏎` in the column while `--help` keeps the words. The key column is + right-aligned at the widest key; a narrow surface drops the detail, then elides the title, never the key. + - **The cell palette is as tall as what it lists** (`CommandPalette.FitHeight`): the whole catalog's height (capped above + the command line) while the query is empty, the matching rows' once something is typed, at least `FilteredMinHeight` (10) + and never taller than it opened; the top edge stays put, so the prompt does not move. + - **Clashes come from `MacroKeys`, never a second table** (`KeyClashes`, Tui). `ClaimedBy` (a global + shortcut: the client wins, `Verdict` already says Taken) and `YieldedBy` (what the main window does with a + chord only after `DispatchMacro` declined it: the binding wins, a warning). `YieldedBy`'s list mirrors the + window chain below `DispatchMacro` and the bar's key table, and must move with them. Two bindings on one + chord resolve as `MacroEngine.Resolve` does — the first answers even disabled. F4 marks a binding that wins + with an amber `△` (`ScreenPalette.Caution`) in the key's own well, a loser with `▲`, and says which side + wins in the footer; ⌃P lists `Key clashes (N)` only while N > 0, and it opens F4. - **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn windows** + web view, with unread badges), each a `MarkupControl` in a `ScrollablePanelControl` viewport (PgUp/PgDn, Shift+↑/↓, ⌃Home/⌃End, wheel; unread badges count output arriving below a @@ -202,8 +364,9 @@ fallbacks) for inline images/maps. command line (wrapping, auto-growing, per-window drafts, plus an optional per-window second bar), status line, `Ctrl+Q` quit, per-pane NAWS (every connected session is told its own pane's output rectangle, on every resize and layout change, rate-limited to four writes a second with a trailing - flush). The tab/pane set is driven by the tested `Core.Workspaces` model, with **splits** (thin - single-line dividers) and the **connection rail** now rendered as well — and **clickable**: a world, + flush). The tab/pane set is driven by the tested `Core.Workspaces` model, with **splits** (a thin + single-cell divider column side by side; stacked, a divider row only where nothing else marks the + boundary — see *No divider row where a tab strip or a strip panel already marks it* below) and the **connection rail** now rendered as well — and **clickable**: a world, character or window row switches to it, dispatched through the rail control's *own* `LinkClicked` (never the output panes' handler, so a world cannot drive the client's UI from the wire). - **Panes come back after a restart, and the log that does it is keyed by *window*** (`RestoreLog`, @@ -250,6 +413,8 @@ fallbacks) for inline images/maps. mid-word cannot split a match and `#ff0000` cannot find every red line (`UrlDetector`'s rule, one layer down) — and it is computed once at append, because the surface refilters over every line of every window on every keystroke. Chrome rows carry none, so a search cannot find its own bars. + It is the line's span text (`StyledLine.Text`), not the stripped markup: the `▌ ` a highlight rule draws + is markup only, so `^\[public\]` matches in ⌃F and in F2's live test as it does in the engine. - **Case is ignored in both modes**, matching `HistorySearch`; `(?-i)` is the way back, which is why there is no third toggle. An invalid pattern is a *state* (a regex is unparseable most of the time it is being typed), and there is a match timeout, because this runs on the UI thread per keystroke. @@ -267,6 +432,32 @@ fallbacks) for inline images/maps. - **Two kinds of client chrome now live in the line buffers**, so `InsertChromeRow`/`RemoveChromeRow` are the one place that fixes up everything indexing into one — the freeze point, the pending boundary, the activity bar and the search bar move together or none of them do. + - **A bar's rule is sized to its pane** (`BarRule.Fit`): each bar's `Bar` closure reads `BarWidth(windowId)` (the + viewport width) when drawn, the label elides before the rule shrinks, and `SyncChromeBarWidths` (on + `PostBufferPaint`) redraws a window's bars in place when its width moves. Width 0 (not arranged) draws 48 cells. + ⌃F's footer is a drop ladder (`SearchPrompt.HintLadder`), so the surface fits from 44 columns. + - **⌥H searches older history too** (`ScrollbackSearch`, Core; `SharpMUTermApp.DeepSearch.cs`; `ScrollbackViewModel`, + Client; `ScrollbackViewSurface`, Tui; `ScrollbackView`, Avalonia; the `search-older` and `search-history` views). Only + a session window has any: its session's `ScrollbackBuffer`, ring and spill, walked newest first in ranged reads of + `MaxRangeLines` off the UI thread (inline headless, so frames are deterministic), cancelled by the next query or by + closing, `OutputSearch.Match`'s timeout per line, at most 500 hits. Lines the pane still holds are skipped by + reference (a session window's `PaneLine.Source` is the ring's instance). Older rows list after the pane's, their + window column saying how far back (`SearchModel.AgeLabel`: `1.6k lines ago` — the scrollback keeps no clock) and the + pane's rows `here` (`SearchModel.LabelFor`, both hosts). **Both counts are one row** (`CountText`: `this window 3 of 39 · + older 4 of 6,000`, `older 2… 1,200 of 6,000 read` while it reads; `OlderFound`/`OlderSearched`/`OlderTotal`); the note + says only what is not a count (an error, captures keep no older history). ⏎ on one goes to the pane if it holds the + line, else opens the **history view**: a modal page, every move a ranged read, never inserted into a pane buffer. ⌥G + stays pane-only. **The page size is the model's** (`ScrollbackViewModel.PageRows`, from `PageRowsFor(rows, columns)` at + open: the modal ends above the command line, 13 at 80×24, at most 20), title and position on two rows below 100 columns + (`TwoTitleRows`), keys pinned with `ListOverlay.Open(footer:)` (`KeysLadder`); the window reads the same page. It opens + **centred on the hit** (`TopFor(…, page)`, clamped at the ends), and `Role(line)` makes the `ContextRows` (3) either side + context: body ink behind a `│` in cells, a rule in the window; the rest of the page muted/dim. The demo's history is + stamped 18:00–22:59 and only moves forward (`DemoHistoryStamp`). + - **⌥H is offered on the status line, not in a modal, only while the pane cap trimmed what ⌃F searched** + (`SearchModel.OlderLines`/`OlderSource`/`OffersOlder`: a query, ⌥H off, and the session scrollback holding lines the pane + does not). `OlderHintLadder` is four rungs, all starting `⌥H`; the cells take the longest that fits beside the count + (`NoteFor(room)`, chord in bold accent), the window shows the first in words, accent, and a click on it is ⌥H. Nothing + trimmed, nothing said. `FooterFitTests` pins the offer and the count on one row at 80×24. - **The demo scene loads with `_watching` off.** It pours a spawn window's whole history in before the first frame, and every line would otherwise count as missed — so any frame that later made such a window visible carried an activity bar reporting the client's own setup as news. @@ -458,6 +649,116 @@ fallbacks) for inline images/maps. it. A window that gained nothing gets no bar. - **`SimulateKey` used to discard a global shortcut's result** and swallow the key either way. Harmless while every claim returned true; wrong the moment one declined, and it now honours the decline. +- **Scrolling a pane back splits it** (`SharpMUTermApp.ScrollSplit.cs`, `ScrollSplitBarRenderer`; F7 ▸ + SCROLLBACK ▸ `split the pane when scrolled back`, on by default; the `scrollback-up`/`scrollback-split` views). + History on top, a band (`── scrolled back 120 rows · End returns · ⌥F freezes ──`), the live tail below. + Decisions not to relitigate: + - **It is freeze's layout, not a third way of drawing a pane** — one more arm of `BuildTabContent`, the same + two-viewport grid as `BuildFrozenContent`. But the halves are the other way round: the window's *own* + control and its *own* `_paneScrolls[windowId]` viewport stay on top, so the scroll position, the + scrollback keys, the wheel, `ScrollTarget`, `RevealSearchBar`/`RevealAwayBar` and the status row's segment + all keep addressing the viewport they always did. The bottom (`tail:`) is a *copy* of the buffer's + last `TailLines` (64) lines, re-fed by `RefreshScrollTail` wherever the window's control is told about a + change (`AppendWindowLine`, `RepaintPane`, `InsertImageRows`). It indexes nothing, so no chrome row, trim + or picture has a split point to move — which is why it is not built on `_freezePoints`. + - **Opened and closed in `SyncScrollbackState`**, where every scroll route lands: open while the window's + viewport is detached (`!AutoScroll`) and scrollable, closed once it re-attaches — scrolling the history down + to its end, ⌃End, or bare End. **Bare End is claimed only while a split is open and the command line's + caret is already at its end** (`CaretAtEnd`), where End has nothing left to do in the editor and where a + caret rests after typing; anywhere else End is the editor's and a second press returns. Also closed by ⌥F + (freeze takes over and splits at its freeze point, as it always does), by a frozen pane, and with the setting off. + - **The tail is half of what the band leaves, the odd row to it, at least 3 rows, and the history keeps at least 3** + (`TailRowsFor(rows, band)`; wave 25, after both mockups, which give the arriving rows at least the history's room; + it was 30%); a pane shorter than 7 rows does not split. Fixed when the split opens (a `Cells` row), not re-derived + per frame. + - **The history is a plane of its own** (`WorkspacePalette.History`: ×0.87 on a dark plane, toward white on a light + one, so it only widens the contrast `ReadingPlane` guarantees; the terminal mockup's `#1a1a20` over `#1e1e24`). A light + plane with under 3 L* of room above it (Light's white focused pane, its L* 96 unfocused one) steps *down* ×0.96 instead + (wave 27: the history was the pane); only near-white panes lack the room, the reading plane never does, so it is never + passed (`TheHistoryIsAVisibleStepOffEveryPaneAndNeverPastTheReadingPlane`, all three themes). The + history viewport is wrapped in a grid wearing it (`_scrollHistorySurfaces`, re-coloured in `RefreshPaneFocus`); the + tail keeps the pane's plane. Where the host keeps a gutter, `PaintScrollHistoryGutters` paints the blank gutter and + pad-row cells beside it after the frame, and the window's overlay scrollbar runs beside the history alone. + - **The window's band is two rows** (`ScrollBandRows`: 2 under `HostChrome.Tabs`, else 1; the second row is blank band), + published across the gutter with `ScrollBandOverlay.Inset`, so `ScrollBandView` lines its words up with the output + and keeps its button as far from the right edge — the mockup's 40 px band with a real button. Still `rows`, not the + mockup's `lines`: a buffer index is not a viewport row. + - **Inside the pane rectangle, so NAWS never moves**: the band and the tail are rows of the tab's content; + `PaneOutputRects` is unchanged (`ScrollingBackSplitsThePane…` pins it). + - **A split pane is not "scrolled back" for the workspace** (`SetScrolledBack(…, !AutoScroll && !split)`): + its new lines are on screen in the tail, so they badge nothing and record no `NEW` boundary. + - **The band's count is the status row's figure** (display rows below the history viewport, capped through + `UnreadBadge` in a reserved field), refreshed on `PostBufferPaint` with the status row repainted beside it + when it moves, so the two surfaces never disagree. It says *rows*, not lines: a buffer index is not a + viewport row. +- **Below 100 columns the layout is narrow** (`SharpMUTermApp.Narrow.cs`; `NarrowColumns`, `ShortRows`; + `NarrowLayoutTests`). The rail folds itself to its strip, and below 100 columns *or* 30 rows an empty command line + is one row (`SyncInputHeights`: `MinRows` becomes `InputSettings.MinRows`; it still grows to `MaxRows` as it wraps). + At 80×24 that is a 72-column pane instead of 60 and two more output rows. **`InputSettings.Rows` now defaults to 1 + too**, so a fresh client's command line is one row everywhere and grows as it wraps; a saved value is kept. + **The window's native sidebar folds by the same rule** (verified under Xvfb at `--size 60x24` and on a live resize): at + `--size 80x24` the window is ~102 columns wide with its sidebar, so it stays open and the pane is the 80 columns the + rule protects. Decisions not to relitigate: + - **100 because of the server's wrap width**: the open rail and its divider cost ~20 columns, so 100 still + leaves the pane 80; below it a world's 80-column text orphans a word on every other row. 30 rows because above it + the output keeps 24 rows with a three-row bar. Height never folds the rail. + - **The automatic fold acts only on a crossing** (`_narrowSeen`) and once at start, never on a resize that stays on + one side, so ⌃B b / ⌃P / the window sidebar's fold button win until the width next crosses 100. It is the same + `_railCollapsed` those toggle, published as `SidebarModel.Collapsed`, so whichever rail is drawn folds. + - **The window measures its whole width, not its cells** (`LayoutColumns`; `IPointerPresenter.WindowColumns`, set by + `TerminalView` before each grid resize as the view's width plus its margin, in cells). Folding a sidebar the host + draws widens the cells; measured on them, the fold would cross back over the line and undo itself. A host that + says nothing (the terminal, every test presenter) is measured on its cells. + - **A growing bar takes back the editor's scroll** (`InputBarControl.MeasureDOM`, `_verticalScrollOffset` by + reflection). The editor scrolls to keep the caret on a row the viewport does not have yet and never scrolls back + when the viewport grows, so a newline in a one-row bar drew the new row and a blank. The three-row floor had hidden + it; `NewlineKeyTests` caught it. +- **A cell key row never wraps; it ladders** (`KeyLadder`, `LadderKey`, Tui). Rungs: full labels, parentheticals cut, + `Short` labels, then keys dropped lowest `Drop` first (reading order is not drop order); `LadderKey.Keep` (the way + out, the accept) is never dropped. F1 (`ComposeOverlay.FooterKeys`; the header no longer repeats the `⌥W` chip), the + script console (per state: Del is named only on the store list), the map's edit row, the import review and F10's + header use it. `FooterFitTests` renders every cell modal/footer view at 80×24 and fails unless its first and last + hints share one painted row — add a view there when it gains a key row. Settings screen headers go through it too + (`ScreenChrome.Hints(…, room)`, `HeaderRoom`): `{F}/Esc close` and an edit's ⏎/Esc are `Keep`, the pane and select + keys go first; `FooterFitTests.SettingsHeaders` fails on a `…` in any F2–F10 header at 80×24. +- **Wave 19's terminal sweep** (every `SnapshotViews.All` view at 80×24 and 120×32, Dark and Light; `TerminalAuditWave19Tests`). + Cell pop-ups take `WithPadding(1, 0, 0, 0)` (ListOverlay, quit, ⌃B, ⌃F, ⌃R, game data, messages; not ⌃P, whose `├` + headings join the frame): each already laid its lines out a cell or two narrower than its inside, so the padding spends + that slack, and ⌃F/⌃R grew one column where they had none. A `ListOverlay` with pinned keys (`footer:`) is at most `desktop − 4` + rows, so a centred one never covers the command line; one without keeps `desktop − 2`, since its keys are its last rows. The `❄ FROZEN` bar is sized to its pane like every other bar (`_freezeBars`, redrawn + in `SyncChromeBarWidths` and on a theme switch). The header's brand is `SharpMUTerm` unless the row would then not fit, + where it is `muterm` (`BrandWord`/`ShortBrandWord`). A notice whose words already name its action's way gets no + `· ⌥R Reconnect` after them (`NamesWay`). +- **The status row names the keys of the mode you are in** (`ModeHints`/`StatusMode`, Client; + `SharpMUTermApp.ModeHints.cs`). Composer armed > search bar in the focused window > frozen > split > scrolled back; + while one holds, its hints replace `FocusHints` (`End returns · ⌥F freezes`, `⌥F thaws`, `⌥G next hit · ⌃F searches + again`, `⌃S sends · Esc releases · F1 full screen`). The keys are `KeyHint`s resolved through the host's + `HostKeyGlyphs` and written through `KeyGlyphs.Normalize`; `HostParityTests.TheStatusRowNamesEachModesKeysAsThisHost…` + reads each mode's row once per host at 80 and 120 columns. The longest candidate goes in that fits once as few of the + encoding and `⌃P palette` cells as it needs are given up; meters and the scrollback segment are never given up for + it, and nothing may wrap the row (the D1 lesson). A change of mode alone repaints the row: `SyncStatusMode` rides + `PostBufferPaint` as well as freeze, the composer's bands and the scroll path. +- **F5 ▸ `join wraps` joins the lines a server hard-wrapped** (`ServerWrapJoiner`, Core; `WorldSession.Reflow`; + `WorldDefinition.JoinWrappedLines`, off by default; the `reflow` view). **A false join is worse than a missed + one**, so every condition is a reason not to join: the wrap column is known (third-longest of the last 128 + non-blank lines, at least 8 seen, at least 40); neither line is a prompt, blank, rule-coloured, interactive + (MXP), or holds a three-space run (a column layout); the first row is *full* — within `Slack` (max(8, col/6)) + of the column, no longer than it, and the next line's first word would not have fitted (one cell of + tolerance) — and ends in a letter, digit or comma (never a hyphen); the next starts unindented with a + lower-case letter; the style at the break is the same on both sides. Joined with one space; a paragraph + chains, each decision against the previous *physical* row. Decisions not to relitigate: + - **Ingest-time, in Core, before the triggers** — so a trigger matches the line the player reads, including + a phrase the server split. Like `strip incoming colour`, it changes the next line and never history. + - **A full row is held until the next line says whether it continues**, and released after + `ReflowHold` (250 ms) of quiet, by a prompt, a blank line, a parser swap or a disconnect — so nothing is + shown out of order and only the last full row of a burst is ever late (a paragraph's last row is short and + never held). The read loop and that timer share `_ingestGate`; nothing on the UI thread or in a script takes + it, so it cannot close a cycle with the script host's lock. Rebuilt per connection, like the parser. + - **Suggested, never switched on** (`SharpMUTermApp.JoinWraps.cs`). While a pane reported to a server is more than + two columns narrower than its wrap column (`NoteRaggedWraps`, on each NAWS report; `RaggedWrap`, Core), ⌃P lists + *Join server wraps for * under SUGGESTED (`CommandContext.RaggedWraps`) and F5's `join wraps` row reads + `off · recommended · wraps at 78, pane is 60` (the `join-wraps`/`join-wraps-f5` views); the once-per-world notice + stays. Joining, or the pane catching up, clears it. - **Every server's MSSP report is kept, and the INFO screen reads it** (`MsspCache`, Core; `mssp.json` beside `config.json`, keyed by `host:port`; F5 ▸ `i`). Fourth of the `save:`/`logRoot:`/`restore:` family with **one deliberate difference**: the constructor parameter is null by default like the @@ -499,7 +800,65 @@ fallbacks) for inline images/maps. in that order and then dials them **concurrently**: focus is taken by the first before any packet leaves, so several auto-connects always land you in the same place, and a black-holed host cannot hold the other worlds' windows hostage. `ConnectAtStartup` is *not* `AutoLogin` — one opens the socket, the - other sends the connect line once one is open, and either is useful alone. + other sends the connect line once one is open, and either is useful alone. **`StartAsync` runs once per app** + (`_startupRan`): the window's demo lays out a scene that may itself start up (the welcome does) before `Run` schedules + startup, and running it twice said every startup notice twice — two identical toasts. +- **A client with no worlds opens on a welcome, and the first connect of a new configuration leaves a key tour** + (`WelcomeSurface`, `SharpMUTermApp.Welcome.cs`; `/welcome`, ⌃P ▸ *Welcome* / *Key tour*). The welcome is a + `ListOverlay` over a pure model (the `QuitPrompt` split): *Connect to a host* (a form: host, port 4000, TLS, an + optional character, a password reminder), *Browse worlds* (mu*index, below; with no https + `WorldDirectoryUrl` it says so on the row instead), *Open settings* (F5), the + `/connect` example, and a starting layout. Decisions not to relitigate: + - **Its words live on `WelcomeModel`** (headings, intro, field labels, key rows), so both hosts say the same thing; + the client calls a game a *world* on every surface of it. + - **The form goes through `ConnectCommand`**, with a `shape` callback that adds the character — so it can do + nothing `/connect` cannot. Esc on the form goes back to the menu; on the menu it closes, leaving + `NoWorldsNotice` (short enough for an 80-column row, and it names `/welcome`), and nothing reopens it that + run. The menu and the form are drawn at **one height in one window**: reopening the overlay to resize it + worked headlessly and left the old window in front under the window host's loop. + - **The layout row's default is *suggested*, which writes nothing**, so `LayoutSuggester` still applies at the + first connect; Roleplay or Combat is written to `WorldDefinition.Layout`. It applies to the first world a + configuration gains however it is added, so a `/connect` typed under the welcome gets it too. + - **`--plain` gets lines, not a modal** (`ClientHost.ScreenIsSeen`, false only there): a modal on a screen + nobody reads would take the reader's typing. The choices are announced as commands, and `/welcome layout + roleplay|combat|suggested` stands in for the arrows. A host with an announcer hears the lines either way. + - **The tour is owed, not "shown"** (`AppConfiguration.KeyTourPending`): set on the way to a save + (`PersistConfiguration` → `NoteFirstWorld`) once a configuration that started the run empty has a world, and + cleared when the tour row is put up on the next `Connected`. A configuration from before the tour has + worlds and never gets it, with no migration; a new player who quits before connecting still gets it next + run. The row is chrome (`AppendWindowLine`), found again by its markup, and hidden or shown by ⌃P ▸ + *Key tour* — not by a link, because the panes' link handler is scheme-tagged server text and a client action + there would be a new trust path. +- **Browse worlds lists mu*index, and a pick only fills in the welcome's connect form** (`WorldDirectory`, Core + `WorldIndex/`; `WorldBrowseModel`/`WorldBrowseSession`, Client; `WorldBrowseSurface`, `SharpMUTermApp.WorldBrowse.cs`, + `DemoWorldDirectory`, Tui; `WorldBrowseView`, Avalonia; the welcome's Browse row, ⌃P ▸ *Browse worlds…*, `/browse + [words]`, `/browse N`, `/browse N M`; surface `browse-worlds`; the `browse-worlds`, `-failed`, `-picked` and `-endpoints` + views). Decisions: + - **Asked only when opened**, through `worldFetch:` (the `save:` family: null in tests and snapshots, which say they asked + nothing; `Program` passes `WorldDirectory.DownloadAsync`; the demo answers from `DemoWorldDirectory`, made-up worlds on + `.example` hosts). https only (`AppConfiguration.WorldDirectoryUrl`, default `https://mu-index.com/api`), 2 MiB capped + while streaming, 15 s, 200 rows, 8 addresses a game; an answer from a stale request (newer question, or closed) is + dropped by generation. Every list asks `unnamed=!yes` (MUIndex's facet that leaves out games listed only by host:port). + - **The directory is the authority on its own data; the client does not second-guess it.** Rows are shown in its order, + archived, repeated, unnamed or id-less included (an id-less pick says so), with its player count and its label (`~` + declared, `?` unlabelled); addresses in its order, leaving out only `gone` and kinds this client cannot dial (websocket, + http). What stays is safety: the caps above, and every text is server text, bounded and stripped of control, format + (bidi), private-use and unassigned characters (`WorldDirectory.Clean`). + - **A pick asks `GET /games/{id}`.** One address goes straight to the form; several are a choice in the same surface + (`WorldBrowseModel.Choosing`, a `WorldGame`): `host:port`, TLS or plain, active or stale, the first connectable one + selected; Esc goes back to the list (`LeaveWorldChoice`), ⏎ is `ChooseWorldEndpoint`. An address `ConnectEndpoint.Parse` + refuses is listed `not connectable` and refused out loud, never put in the form. Then Browse closes and the welcome's + form opens prefilled (`WelcomeSurface.Prefill`: host, port, TLS, `WorldName`, `Encoding`); nothing is saved or dialled + until its ⏎, which goes through `ConnectFromWelcome` → `ConnectCommand` and names the world as listed (name and encoding + dropped if the host is edited). + - **Charset is the game's, not an address's.** mu*index's `GameView` has no per-endpoint charset; the only one is + `fields.CHARSET` (the precedence winner of a game field: handshake-measured or MSSP-declared, with `source`/`measured` + beside it). The choice says it once, for the whole game. A world is saved with an encoding only when that names a charset + F5 offers that is not UTF-8 or ASCII (`WorldDirectory.EncodingFor`: ISO-8859-1, CP1252, CP437); otherwise `auto`. + - ⏎ on a search none of the held rows match asks the directory (`AsksDirectory`). Every listing shows the CC BY 4.0 + credit (`AttributionLine`). `--plain` says the rows as numbered lines; `/browse N` says the `/connect` line, or the + addresses numbered when there are several, and `/browse N M` says address M's. + - *Browse packages* is unchanged; the welcome no longer leads there. The live API has not been exercised from here. - **The URLs a world prints as plain text are marked up by this client, because the terminal's own detection cannot survive a pane** (`UrlDetector`, Core; F7 ▸ `detect links in output`, default on). Kitty, WezTerm and Ghostty all find URLs in the cells they paint — across the terminal **row**. A pane @@ -575,7 +934,7 @@ fallbacks) for inline images/maps. - **Drafts are per character and in memory only**, for the life of the run. Not on disk deliberately: a post is a few minutes' work, and a file would be a fourth thing this client writes, a purge entry, a `--help` line and somebody's unsent post in their home directory. Keyed by the *window's* owner, never - `_active`; a window belonging to no connection keeps none. **`Close()` raises `Closed`, which is what + `_active`; a window belonging to no connection keeps none. The header names that owner even while it is offline. **`Close()` raises `Closed`, which is what stores the draft, so a send must close first and forget after** — the other order posts the text and hands it straight back next time the window opens. - **F1 is claimed in `MacroKeys.AppShortcuts` but is not a settings screen**, so `ShortcutAction` answers @@ -587,14 +946,26 @@ fallbacks) for inline images/maps. named only while a dictionary is open. - **Spelling is a colour, not an underline** (`SpellChecker`, Core; `SpellingHighlighter`, Tui). The marks go through `MultilineEditControl.SyntaxHighlighter`, whose tokens carry a foreground and nothing else; - an underline needs a styling hook upstream. Hunspell via WeCantSpell.Hunspell, **no dictionary + an underline needs a styling hook upstream (the window's native editor draws a real one, see below). Hunspell via WeCantSpell.Hunspell, **no dictionary bundled**: `dictionaries/` beside `config.json`, then the system folders, chosen by the POSIX locale variables — not `CultureInfo`, because the build is `InvariantGlobalization` and every culture is the invariant one. The dictionary is opened the first time F1 opens, never at startup, and the app takes a `Func` that is null by default, so a test and a snapshot read none and their header says `no dictionary`. A MOO edit is not checked (its buffer is code as often as prose). `%`-codes, a word before `(`, and any word with a digit or an inner capital are skipped. ⌥W appends to - `dictionaries/user-words.txt`. + `dictionaries/user-words.txt`. With no dictionary F1's header reads `no dictionary · ⌥W gets one` (the docked panel + says only `no dictionary`), and the window's chip is a button (`ComposerView.DictionaryChip`) that opens the same + offer through `ComposerSession.GetDictionary`. **A command token is skipped** (one starting `+ @ & /`, up to its first `=`), and so + is **every name the client knows** (`SpellChecker.KnowNames`: configured worlds and characters, each session's + `CompletionIndex.Names()`), refreshed each time F1 opens and held for the run, never written to disk. + - **With no dictionary, ⌥W offers to download one** (`DictionaryDownload`, Core; `DictionaryOfferModel`, Client; + `DictionaryOffer`/`SharpMUTermApp.Help.cs`, Tui; `DictionaryOfferView`, Avalonia; ⌃P *Get a spelling dictionary…*; + the `dictionary-offer` view). F1 closes (keeping the draft), the offer says what is fetched from where and saved + where, and F1 comes back when it closes. **Only on ⏎**, https only, each file capped at 8 MB while streaming, and + both checked against a **pinned SHA-256** before anything is written (LibreOffice's `en_US`/`en_GB` at tag + `libreoffice-24.8.4.2`; `For(locale)` picks GB for en_GB/IE/AU/NZ/ZA/IN). Written as `.new` and renamed, `.aff` + first. `dictionaries:` is a constructor root of the `save:`/`logRoot:` family, null by default, so a test and a + snapshot fetch nothing and the offer says nothing can be saved. - **An MXP `` is drawn in the output pane as rows of markup under the line that named it** (`MxpParser.HandleImage` + `MxpImageSource`, Core; `InlineImageHost`/`InlineImageRows`/`InlineImageLayout`, Tui; the `mxp-image` view). The parser emits a `[image: name]` span whose `SpanInteraction.Image` carries @@ -711,9 +1082,10 @@ fallbacks) for inline images/maps. - **A span carrying a background is measured against *it*.** That plane is known exactly and a highlight's pair must be judged as a pair; a span with the default background emits none and takes the pane, so it is measured against the reading plane. `reverse` swaps first. - - **`ScreenPalette` is deliberately not built this way.** Those constants sit on the settings screens' - own fixed backdrop, which no theme moves; measuring them against a theme plane would be measuring - them against a plane they are never painted on. `ChromeInk` is the other four — `Accent`, `Notice`, + - **`ScreenPalette` is not built this way either, but it does follow the theme** (`ScreenTheme`, Tui; + defect D6). The settings screens are their own backdrop, so their inks are measured against their own + planes rather than a pane's; but the planes themselves move with the theme. See the *settings screens + follow the theme* entry below. `ChromeInk` is the other four — `Accent`, `Notice`, `Draft`, `Marker` — and it carries **the plane it was resolved against**, so a renderer can hold a colour it was *handed* (a world's accent) to the same floor. - **A fill is not text.** A ribbon segment's accent, a pane tint and F2's swatch are identity or @@ -723,14 +1095,50 @@ fallbacks) for inline images/maps. - **The audit that found most of this is a test** (`FrameContrastTests`): every emitted SGR pair over 24 views × 3 themes. Reading the source found four of the offenders; the paint found nine. It exempts the powerline wedges and box-drawing rules (fill boundaries and dividers), the solid blocks (a swatch - is a colour sample shown as the *pane* will paint it), and the framework's `[dim]`. **The half blocks + is a colour sample shown as the *pane* will paint it), and nothing else. **The half blocks are not exempt** — `▌` is the trigger left-rule and the focus marker, and one of them was a real defect this caught. - - **One thing is outside the floor's reach and is named rather than hidden.** SharpConsoleUI resolves - `[dim]` to a fixed `#808080` through no option we hold: 4.01:1 on Dark, **2.52:1** on Solarized Dark's - focused pane. Reaching it means giving up `[dim]` across every renderer for an explicit floor-checked - grey — a sweep, for a near miss on one theme. The exemption is a named predicate with the number in - it, so whoever does that sweep can delete it and watch the test pass. + - **The framework's `[dim]` is no longer exempt.** It is faint on the inherited ink, or a fixed `#808080` + with none; it once measured 2.52:1 on Solarized Dark's focused pane. The panes have since deepened, the + status row swaps it (`StatusInk`), and a light settings screen rewrites a bare `[dim]` to a held grey + (`ScreenTheme.Tag`, light planes only, so Dark and Solarized frames are unchanged), so every `[dim]` cell in + every view clears the floor. A new one that does not fails like any other ink. The workspace's own `[dim]`s + (rail, popups) were not swept, because they did not need to be. + - **Small text on a light settings screen is held to 4.5:1** (`ScreenTheme.FloorOn`, `LightTextFloor`); 3:1 + stays the floor on dark planes. Light's L*-mirrored greys left labels at about 3.2:1, which read as disabled. + `ScreenThemeTests.ALightSettingsScreensTextClearsBodyTextContrast` walks the frames. +- **The settings screens follow the theme, and the renderers do not know it** (`ScreenTheme`, Tui; defect + D6). `ScreenPalette`'s constants are *roles* spelt as the Dark theme's values; renderers keep writing + `[{Label}]…` in them, and the views resolve the markup and every control colour through one immutable + `ScreenTheme` the app builds from its theme (`ScreenTheme.For`, held in `_screens`, handed to + `SettingsOverlay`, `ComposeOverlay`, `ListOverlay` and `EditReviewOverlay`, and to every `*ScreenView.Build` + as a **required** parameter). Decisions not to relitigate: + - **Resolve at the view, not in the renderers.** A palette parameter on every renderer function is every + screen and every renderer test rewritten for a value only the last step needs; a mutable `ScreenPalette` + is global state the parallel suites race on. `ScreenChrome.Panel`/`Band`/`VerticalRule(scheme)` and + `ScreenTheme.Colour` are the only ways a role reaches a control. A view that hands a renderer's lines + straight to a `MarkupControl` is a dark island on Light — legible, so `FrameContrastTests` cannot see it; + `ScreenThemeTests.NoLightSettingsFramePaintsADarkPlane` can. + - **Dark resolves to the constants byte for byte** (`IsIdentity`), so no Dark frame moved. A dark theme + keeps each role's lightness and takes its background's hue shift; a light one mirrors L* (100 − L*) and + keeps the tint, the menu shadow is placed below the darkest plane, and every ink is then held to + `Contrast.Floor` on the planes it is drawn on (`PlanesUnder`: a label never sits on the menu's selected + row). The wells are the *lightest* thing on a light screen — the input-field convention there. + - **`Paint` rewrites colours and nothing else**: escaped `[[`/`]]` and `link=` targets are text; a run of + `█░▒▓` is a colour *sample* and keeps its hex even when it equals a role's (F2's swatch of a highlight + that happens to be `#00f5b7`); a foreground that is no role (a world's accent) is held to the floor on + the plane it lands on, except on Dark, which stays as it was. + - A bordered modal's title, border and unstyled text are the framework's cyan and `#eeeeee`, 1.4:1 on a + light backdrop; `ScreenTheme.Frame` recolours them off Dark, and on Dark gives the border and title the label/value + inks (the terminal mockups' grey frame, wave 23) while the text stays the framework's. **The workspace popups follow it too** (⌃P, quit, ⌃B panel, + search, game data, message log, history): each has a `Scheme` the app sets in `SchemePopups()` (on + `ApplyTheme`), paints its plane with `Scheme.Colour(MenuBg)`, its lines with `Scheme.Lines` and its frame + with `Scheme.Frame`; ⌃P's prompt well takes `EditBg` off Dark. Dark is byte for byte unchanged. + - **The header's fills are derived per theme** (`WorkspacePalette.HeaderFill`/`HeaderInk`/`HeaderMuted`): on a + light theme a segment's fill is deepened until light ink clears 4.5:1 on it, and the muted right cluster is + held to 4.5:1 on the status plane; a dark theme's fills are as they were. The native header reads the same + inks through `ChromePalette.BrandInk`/`HeaderMuted`. The low-vitals cue is `WorkspacePalette.Low`: the notice + ink on dark, a deep orange held to 4.5:1 on light, in the status row, the vitals strip and the gauges. - **A trigger's `route` says three things, and the third is "nothing"** (`TriggerActions.MainWindow`, Core; F2's `route` list). `SpawnTarget = null` has always meant *this rule adds no destination — the line follows whatever the other matched rules decided*, which is exactly what a highlight rule wants. @@ -836,6 +1244,35 @@ fallbacks) for inline images/maps. (`PaneResize.DragBorder`, which changes only the two children either side), a double-click evens the split (`PaneResize.Even`). A recolour costs no cell and moves no pane rectangle — the NAWS rule. While a drag is held the 100 ms bare-press repeat is read as "the pointer is here". + - **No divider row where a tab strip or a strip panel already marks it** (`PaneExtent.DividerRows`, read by + `LayoutSolver`, `PaneExtent`, `PaneResize.Minimum` and `BuildLayoutNode` alike). Stacked, there is no row + above a pane that draws its own tab strip (`PaneNode.DrawsHeader`, host-derived by `PanelSizing.Apply` + like `FixedRows`, false until then): that strip is the boundary, and its empty run past the last tab is + registered as the divider (`DividerSlot.HeaderPaneId`, `HeaderFill`) — the same hover, `DragBorder` drag, + double-click and tooltip, lit by recolouring those cells after the paint (`PaintHeaderDividers`; the + control's own background is what its content inherits, so it cannot be set there). A tab, its `×` and the + `│` between tabs stay the framework's, and `PaneDragSurface.IsDragHandle` keeps the run from starting a + window drag. There is no row either side of a fixed strip panel; it sits flush, and an *unfocused* strip is + recessed (`SurfaceToneIn`), so it is never the same plane as an unfocused pane. `WorkspacePalette.Recessed` + is the backdrop's step on a dark plane and a shallower 0.90 on a light one (`LightRecessScale`, about L* 87 + under Light's L* 96 panes, twice the light backdrop's step), so Light's background chips and strips are not + the deep grey a dark theme's step gives. Side by side, nothing changed. The + freed row went to the panes, which is the point and a NAWS change; `StackedDividerTests` and the + `split-stacked` view are the pins. + - **A panel's heading band is a boundary too, in the cells** (`PaneNode.DrawsHeading`, set by `ApplyHeadingPanes` + after `ApplyAll` for a lone composer or who-here pane, `PanelSizing.DrawsHeadingBand`; `HeaderPanes` counts it). + Roleplay's composer over who-here has no divider row; the HERE band's empty run is the handle (`HeadingFill`), + **read off the paint** (`MeasureHeadingRuns`, before `PaintHeaderDividers`: the longest blank run on the band's or + the lit rule's plane), because three renderers lay their headings out three ways. Who-here draws its band before + the roster arrives, so the boundary never comes and goes. **Not in the window** (`HostChrome.Strips`): its native + panels lie over that row and take its presses, so it keeps the divider row. `HeadingDividerTests`. + **Under `Strips` the cells draw no band at all** (`PanelHeadingInk` is null, the bare accent label): the window + paints each half of a divider row in the plane read off the cell beside it, so a band under a native panel's heading + was read as that pane's plane and drew a filled bar across the seam (wave 23's regression). + - **The rail is `rail │ panes`: the spacer column after the rule went to the panes in wave 24** (NAWS +1 column). + Measured, not preferred (`RailEdgeTests`): the mockup lets the planes carry the rail's edge (~6 L*), but here an + unfocused pane is 2.9 L* above the backdrop on Dark (a tinted one less) and the rule 15.9, so the rule stays and + the rail-coloured spacer beside it separated nothing. Pane text meets the rule as at any pane divider. - **The menu is the ⌃P catalog's own entries** (`AddCommands`), so it cannot offer an action the keyboard lacks, and each says its key (`ShortHint`). A right-click on a link or output focuses that pane first, and on a tab activates it, so the entries act on what was clicked. @@ -854,14 +1291,548 @@ fallbacks) for inline images/maps. without taking them. - Screenshots of the window's cues come from a real window under Xvfb, driven with XTest; `--render-png` has no pointer and cannot show them. +- **The window draws native views over the cells, and the cells stay** (`DesktopLayer`, `HeaderButton`, + `VitalsGauges`, `MapCanvas`, `WhoHereList`, `QuickBarButtons`, `NativeMenu` in Avalonia; `SharpMUTermApp.Desktop.cs`; + Client `VitalsModel`, `MapViewModel`, `WhoHereModel`, `QuickBarModel`, `LayoutCommands`). A Layouts button in the + header, native vitals gauges, a native map, a native who-here list and native quick-bar buttons. Decisions not + to relitigate: + - **An overlay, never a cell.** The layer is a `Canvas` above `TerminalView` in the window's root panel; the + client paints every cell exactly as in a terminal underneath, so no pane rectangle moves (NAWS), the parity + test (which compares cells) does not see it, and a host that draws nothing native loses nothing. A native + view covers its pane's output rectangle and paints the pane's own plane, read off the frame's cell there, so + tint and focus show through without the client saying them twice. + - **The channel is `IPointerPresenter`**, the one the scrollbars ride, with two default-implemented members + (`Panels`, `HeaderActions`) so the terminal and the test presenters need no change. `PublishPanels` runs on + `PostBufferPaint` and publishes only on change; a panel is on screen when its window is a realised pane's + front tab **and no modal is open**. A settings screen, ⌃P or F1 is drawn in cells over the panes and a native + view would sit on top of it; a modal covering the whole window leaves the main window unpainted, so + `ModalStateService.StateChanged` publishes too (`TheEditLinkDispatchesTheF4ScreenAndNoNativePanelCoversIt`). The map's model is rebuilt only when `RenderMap` has run since (`_mapRevisions`), and its walk + callback is one instance per window so an overlay compares equal frame to frame. + - **Data is a view-model both hosts draw.** `ReadVitals` builds the `VitalsModel` that `VitalsStripRenderer` + draws as cells and the gauges draw natively; `VitalsModel.LowShare` is the one low threshold + (`StatusMeterRenderer.LowShare` reads it). `MapViewModel.From` is the current room's area and level, nearest + first, at most 1,500 rooms, names bounded. `ReadWhoHere` builds the `WhoHereModel` and `QuickBarEntries` the + `QuickBarModel` that `WhoHereRenderer`/`QuickBarRenderer` draw as cells; row or item N is the same person or + binding in both, and the empty/unknown sentences and the `F4 edits keys` hint live on the models. + - **The window never builds a command.** The Layouts menu is `LayoutsMenuItems()`, built on the client's thread + (`HeaderAction.Menu`), each entry a registry dispatch: `layout:lib:apply:`, `…save`, `…save-new`, + `…use-world`, `…use-character` (a `CommandFamily` that lists nothing on ⌃P — ⌥M is the keyboard's list), plus + `layout:layouts` for *Manage layouts…*. Those ids run the ⌥M list's own methods, which now return a + `LayoutActionResult` the list shows as its status and a command shows as a notice. A map click is + `PanelOverlay.WalkTo` → `WalkFromMap`, the method a cell map's `mux:map:` link ends in. A who-here or quick-bar + click is `PanelOverlay.Pick(index)`: the client reads row N back from the overlay it *published* for that + window (out of range is nothing) and runs `OfferLook` / `RunQuickMacro`, the cell links' own methods, on the + window's own character. `F4 edits keys` is `PanelOverlay.Dispatch(QuickBarModel.EditCommand)` + (`screen:keypad`). One pick callback per window (`_panelPickers`), for the map walker's reason. + - **Input, by view.** The header button is the window's alone: press, release, wheel and movement stop at it, + a right press included, and nothing reaches the client (`ThePressOnTheLayoutsButtonNeverReachesTheClient…`). + It never takes focus. It sits over the header's last three cells, which the header always leaves blank + (`right` ends in three spaces), clear of the 5 px resize band, and stands down if any of them holds text. + The vitals gauges are `IsHitTestVisible = false`: every pointer event goes to the cells, so focus, the + pane's menu and selection are the terminal's. The map takes the pointer (wheel zooms about the pointer, + drag pans from the character's room, double-click re-centres, click walks) and hands a right click on to + the client as a right click on the cell (`TerminalView.ForwardRightClick`). The who-here list and the quick bar + take the pointer the same way (hover plate, tooltip, left click is a pick, right click forwarded, wheel + swallowed) and are `Focusable = false`, as the map is: the keyboard stays on the client's command line. The + quick bar lays out the cells' rule in pixels — the link first, then bindings in order, `+N` for the rest, whose + `NativeMenu` entries pick by the same index. Every binding is drawn alike; only a key that does not fire is dim. + - **Gauges choose their arrangement from the pane's height alone**: stacked blocks when every meter and the + target fit as name-over-bar, else side by side, where the words shrink (`Mana · low 23 / 120`, then + `mp! 23/120`, then `mp 23`) before a bar goes under 24 px. The UI font, not the cell font: this is the window's + presentation. `hp`/`mp`/`mv` read as Health/Mana/Movement (`VitalsMeter.Title`). + - **Not done**: no animation on a + gauge change; the map draws one level and area (other levels show only as stairs marks, other areas only as + a coloured spur), with no room symbols beyond the game's own; the button's icon is the only header action. The window's menu is Avalonia's `ContextMenu`, which needs a second + click under a WM-less X server to take the first item (seen under Xvfb only). + +- **The header and the sidebar are data, and the window draws them natively beside the cells** (Client + `Chrome/HeaderModel`, `Chrome/SidebarModel`; Tui `Hosting/ClientHost.cs` `HostChrome`, `Pointer/SharpMUTermApp.Chrome.cs`, + `ChromeOverlay`; Avalonia `NativeChrome.cs`: `NativeHeader`, `NativeSidebar`; `DesktopLayer`'s `NativeLayout`). + Decisions not to relitigate: + - **One model per surface, and the cells render from it.** `BuildHeaderModel()` feeds `HeaderMarkup(model)`; + `BuildSidebar()` feeds `BuildRail()` (`SidebarModel.Rows()` is Core's `RailModel.Build` over the same tree). + `SidebarModel` wraps Core's `RailWorld`/`RailCharacter`/`RailWindow` tree rather than duplicating it, adding the + selected window, collapsed, targets and value equality. It is not called `RailModel` because Core's `RailModel` + is the row projection. The header's presence dot reads `ConnectedCharacters()`, the same answer as the rail's + dot and the fraction. While ⌃B is armed `HeaderModel.Prefix` carries `PrefixPanel.Entries` (key, title, why it + is blocked) and the exit, so the native header shows the keymap where the cell header shows its strip. + - **`ClientHost.Chrome` (`HostChrome(Header, Rail, Tabs)`, required) says which chrome the host draws, and the client + leaves no cell for it.** `HostChrome.None` for the terminal, the snapshot, `--plain` and the tests; + `Desktop` (header, rail, status row and field) for the window (`WindowHost.Create`, from `WindowOptions.Chrome`; `--cell-chrome` and + `Native` (header, rail and tab strips) for the window (`WindowHost.Create`, from `WindowOptions.Chrome`; `--cell-chrome` and + `--render-png` say None). It is a flag per part, not a size: the space is the host's own business (44 px, 244 + 8 px, + `NativeLayout`), and the client is only told the part is not its to draw. With `Header` the header control is + `Visible = false` — a hidden sticky band takes no row — and `ChromeRows()` counts no header row, so the + input-height veto (`SyncInputHeights`) and the layout agree. With `Rail` `BuildWorkspaceRow` builds the pane area + alone (no rail column or divider; `_railColumn` is null, so `ApplyRailWidth` does nothing). Everything + else follows from arranged bounds: `PaneOutputRects` and so per-pane NAWS are the panes the cells really hold. + `HostChromeLayoutTests` pins rows, rectangles, NAWS and the veto at 80×6. + - **The window moves the cells out of the way; nothing native sits over a cell.** `DesktopLayer` spans the window + and sets `TerminalView.Margin` to `NativeLayout.Insets` (header height on top, sidebar width plus an 8 px gutter of + the sidebar's plane on the left; the slim width while `Sidebar.Collapsed`), so the view, the grid it hands the + driver and every pane are what is left. `MainWindow` sizes the window as the `--size` grid plus the insets. + Panels and the cell-mode header buttons are placed from `DesktopLayer.CellOrigin`; scrollbars, hovers, tooltips + and pointer-to-cell mapping are the view's own coordinates and needed no offset. The window's resize band is + measured against the *window* (`TerminalView.EdgeAt` translates), so it works over the header and sidebar too + (`ResizeFromChrome`, `EdgeCursorFromChrome`); a blank cell on row 0 moves the window only while + `CellsHoldHeader` (row 0 is a tab strip otherwise). The native palette (`PaletteOverlay`) is the root panel's last + child and centres over the whole window, its card starting under the header (`ThePaletteCentresOverTheWhole…`). + - **The channel is `IPointerPresenter.Chrome(ChromeOverlay)`**, published only to a host whose `Chrome.Any`, on + `PostBufferPaint`, the main window's `Activated`/`Deactivated`, and every `RefreshHeader`/`RefreshRail`/`ToggleRail` + (`PublishHostChrome`): with no cell of either a change may paint no frame. Only on change. It carries no + rectangles; `ChromePalette` carries the planes (`HeaderPlane`, `RailPlane`, `Ink`) and the notice voice, because + there is no cell to read them off. + - **A click hands back a target the client put in the model**, and `ActivateChromeTarget` refuses one the last + overlay did not offer (a rail target is offered only when `Drawn.Rail`). Rail targets go to `DispatchRailTarget`, + the search box and brand to `HeaderModel.PaletteTarget` (opens ⌃P, never toggles it shut), *Add a game* to + `term:welcome`, the header's sidebar toggle to `SidebarModel.CollapseTarget` (`ToggleRail`, what ⌃B b runs). Never + `OnLinkClicked`, and the host spells no command. + - **A modal does not hide the chrome.** ⌃P, a settings screen, the composer and the quit prompt are drawn in the + cells, which no longer reach under the header or sidebar, so the chrome stays where a desktop app's stays, under + a veil, and takes no click (`ChromeOverlay.Interactive`, `MainWindowInFront`; refused client-side too). The + settings screens and F1 size to the driver's screen, which is the cell area, so they fit and centre there. + - **The fold button is the header's** (wave 33, `NativeHeader.Toggle`): a 16 px sidebar glyph (column shaded while open) in a + 28 px square left of the brand, centred over the folded strip, offered only under `Drawn.Rail`, open or folded. + - **Folded, the sidebar is a 48 px strip**: an accent mark per world, a disc with the initial + per character (filled while connected, an unread dot), "+" at the foot; names in tooltips. + The cell rail's folded strip marks each world with an accent `▌` row and gives characters two letters where two + share an initial (`RailRenderer.CollapsedWorldMark`, `InitialLength`). + - **Input**: every press, release and wheel over the native views is the window's (`NativeChromeView`); a right + press does nothing (there is no cell under it to forward to), and nothing is focusable. A left press on empty + header space calls `TerminalView.WindowGestureFromChrome`: edge band resizes, the rest moves the window, + double-click maximises. The header leaves the header buttons' slots clear; with a native header the buttons sit + in it, 30 px, right-aligned. + - **Parity is cells against cells under the same `HostChrome`.** `HostParityTests` draws every view three times inline — + both hosts told `None`, `HeaderAndRail`, then `Native` (tabs too) — and the looping pass with `Native`, comparing every + cell. Telling both hosts the same arrangement keeps the comparison a comparison of drivers (the guarantee it + exists for), and covering both arrangements means the layout a person actually runs in the window is held to + the terminal's cell for cell, rather than compared below an offset or skipped. What the native header and + sidebar *say* is held to the cells by the shared models (`NativeChromeModelTests`), not by pixels. + - **An overlong sidebar scrolls** under the title and above the foot button, with the wheel or the panes' overlay + bar (4 px, 8 px under the pointer, dragged by its thumb); rows are laid out unscrolled, the scroll clamped, and + a row half under the title is clickable only where it shows (hit areas intersect the list). + - **⌥C moves the keyboard into the sidebar, in both hosts** (`SharpMUTermApp.RailKeys.cs`; `SidebarModel.Cursor`; + ⌃P `layout:rail-keys`; the `rail-keys` view). ↑↓ Home End walk `NavigableRows()`, ⏎ goes where a click goes + (`DispatchRailTarget`), Esc or ⌥C hands the keys back, and any other key hands them back *and* does what it does + on the command line. Focus never moves: the cursor is client state `HandleWindowKey` reads first (`TryRailKey`), + the way an armed ⌃B is, and every other global shortcut leaves it before acting. The cell rail draws the row on + the armed band padded to the rail's widest row (no cell spent; inks re-held to the floor on the band, + `RailRenderer.OnBand`); the native sidebar draws an accent ring and scrolls the row into view. ⌥C because it + is `ESC c` (one Alt chord in a terminal), C names CONNECTIONS, and nothing claimed it — not `AppShortcuts`, the + bar's table, nor the window's `HostKeys.Reserved`. **While the sidebar holds the keys both command lines wear the + idle band** (`SyncComposerBands` reads `_railCursor`, and `UpdateInputChrome` repaints the prompt to match): ⏎ goes + to a row, not to the game. The window's command line is cells, so it follows with nothing of its own. + - **A right click on a sidebar row opens the client's menu for that row** (`SidebarRowMenu`, `RunSidebarMenuEntry`; + `ChromeOverlay.RowMenu`/`RunRowMenu`; the cell rail's pointer menu reads the same method). Entries are registry + commands with their keys (switch, connect/disconnect, edit in F5/F9, go to, close, clear), each offered only if + the registry resolves it; running one brings the row forward first, and an id the menu did not offer is refused. + - **Not done**: no animation when the sidebar folds; the command line keeps its armed band while the sidebar holds + the keys; the terminal has no row menu (no pointer menus there), its entries are ⌃P's. + +- **The window draws each pane's tab strip and the scrolled-back band natively, over rows the cells keep** + (`HostChrome.Tabs`, `HostChrome.Native` = all three, the window's default; Client `TabStripModel`/`TabChipModel`, + `ScrollBandModel`; Tui `Pointer/SharpMUTermApp.PaneChrome.cs`, `PaneChromeOverlay`; Avalonia `PaneChromeViews.cs`: + `NativeTabStrip`, `ScrollBandView`; surfaces `tab-strip` (`NativeChrome`) and `scroll-band` (`CellsUnderNative`)). + Decisions not to relitigate: + - **Unlike the header and rail, the strip keeps its cell rows.** One cell grid cannot be inset per pane; the client + paints the strip rows blank in the pane's recessed plane after the frame (`BlankNativeTabStrips`, `PostBufferPaint`) + and the native strip lies on them. **Under `Tabs` a strip is two rows** (`TabHeaderStyle.Separator`, `TabStripRows`), + the mockup's ~40 px with 14 px labels centred in the tabs; `PaneOutputRects` reads `TabHeaderHeight`, so NAWS is the + pane less two rows, never a native view over a row of output. The band needs no blanking: its cells stay under it. + - **`HostChrome.PaneGutter` (the window) gives every pane two blank columns either side** (`tabs.Margin`, + `PaneGutterColumns`) **and a blank row above an output window's first line** (the content's `Margin`, + `PaneGutterRowsFor`; none for a panel or a map, drawn natively over the whole pane), in the pane's plane, so output + starts the mockup's ~20 px in from the pane's edges rather than one cell's 8. + The columns are lost by the pane: `PaneOutputRects` subtracts the margin and NAWS follows. A native panel is + published over its output rectangle *and* the gutter (`PublishPanels`), since those cells hold nothing and the panel + pads itself. The terminal has no gutter. `NativeTabStripTests.TheStripTakesTwoRows…` pins both against the cells. + - **The selected tab is the mockup's card** (wave 33, `NativeTabStrip.Render`): the pane's plane, a hairline on three + sides, radius 8 at the top, open onto the pane; no accent bar. In the focused pane that hairline is `NativeTokens.Focus` + (the command line's focus edge), the only focus cue the strip adds: the pane's plane already says it, but two strips side + by side read alike. Underline, accent top edge and pill looks were tried in a real window and dropped + (`ux-wave33b/tabs/`). + - **One model per strip, and the cells title from it**: `BuildPaneTabs`/`RetitlePane` build `StripModelFor` through + `Drawing(TabStrip, paneId, …)` and `TabTitles.Strip` writes the titles; the band is recorded in + `RefreshScrollSplitBar`. Published through `IPointerPresenter.PaneChrome` (default member) on `PostBufferPaint` + and modal changes, only on change, never while a modal or the native F1 covers the panes. Bands go to every host + with a presenter; strips only where `Tabs`. + - **Every gesture ends in the cell one's method, and is refused unless offered**: a tab → `ActivateStripTab` (what + `OnTabChanged` runs), its × → `CloseStripTab` (what the cell × runs; only where `Closable`), a right click → + `OpenTabMenu` (`AddTabMenu`, the cell tab's menu), `+N ▾` → `OpenTabOverflow` with the recorded `MenuModel`, the + band's button → `ReturnToLiveTail` (End's and ⌃End's path). + - **Presses**: the views hit only their tabs and buttons (`ICustomHitTest`); a press on the strip's empty run goes to + the cells, where the strip row is the stacked divider (`HeaderFill` returns the whole row under `Tabs`) and + `TakesNativeStripPress` strikes the button flags after the client's own handlers, so the framework never selects + or closes a tab under the native one. `PaneTargetAt` says Nothing on a strip row. + - **A native tab drags** (`SharpMUTermApp.TabDrag.cs`; Avalonia `DesktopLayer.TabDrag.cs`, `TabDragView`; Client + `TabDragModel`, `TabDragStep`). A press pulled 5 px off a tab hands the pointer to the *layer* (the client tears the + strips down for its drop preview, which would lose a strip's capture); each move goes to `TabStripOverlay.Drag` as a + desktop cell, plus the slot among the strip's tabs (frozen at begin) while over that strip — the only thing the window + works out. The client does the rest with the cell drag's pieces: frozen `PaneSnapshot`, `DropZones`, the cells' drop + preview (the target pane drawn blank under `Tabs`, so the native wash is the only zone; `BuildDragPane`) and + `DRAG … → …` prompt, and on release `DropWindowOnPane` (the cell commit's method too: edge splits, centre + joins). Over its own strip it is `ReorderStripTab` → `ReorderFocusedTab`, the method ⌃B </> end in (declared + `CellsVia: prefix`, the ⌃B keymap's `< >` row). A slot is used only on that strip's row and in range; a drag the strip + did not offer is refused; Esc abandons it. What it would do is published back (`PaneChromeOverlay.Drag`): the layer + washes the zone (`TabDragModel.ZoneOf`: the half a split takes, or the pane) with the prompt's words, carries a card + under the pointer, and the strip draws an insertion bar. `SurfaceParityTests` holds a native drag to an edge against + the terminal's mouse drag, and a reorder against ⌃→ then ⌃B >, by `WorkspaceDigest()`. + - **Not done**: a cell tab still drags only to panes (no reorder by dragging in a terminal: ⌃B </>); no hover + plate on the empty run. + +- **Layouts are realised as panes, one per panel that has data** (`Core/Layouts`: `LayoutFold` → + `LayoutRealiser` → `WorkspaceLayout.ReplaceTree`, `LayoutCapture` for the way back; Tui `SharpMUTermApp.Layouts.cs`, + `TerminalPanels.cs`; ⌥M / ⌃P ▸ *Layouts*; F5 `layout` rows; the `layouts`, `layout-combat` and `layout-roleplay` views). The layout's + name is shown nowhere on the main screen. Decisions not to relitigate: + - **A panel is a window.** `output` is the session's window, `chat` its spawn windows, `map` `map:`, and + every other panel a window `panel::` (`TerminalPanels.WindowId`) whose control comes from the + factory registered for that id (`Panels.Register(id, ctx => control)`, from `partial void + RegisterTerminalPanels()`), built once and re-parented like the map's. So panels get tabs, focus, + `Activate`/`SendTarget` (a panel window is owned by its session) and persistence for nothing. + - **A panel window is furniture, not a destination** (`WindowKind.Panel`). `Workspace.WindowsFor` leaves it out, + so it has no `⌥N` and pushes no map or capture window to a later digit; `BuildRailWindows` lists no row for it; + ⌃P has no `Go to …` entry for it, because each panel's own entry (*Show vitals*, *Dock the composer*) goes to it. + ⌃O, the ⌃arrows and a click still reach it — it is a pane like any other. + - **A strip is as tall as its content** (`PanelSizing`, `PaneExtent`; `PaneNode.FixedRows`/`MinRows`). Vitals and + the quick bar are one row; the composer has a floor (`PanelSizing.ComposerMinRows` plus its tab strip). Region + shares are proportional, so the height is a pane property instead: `BuildWorkspaceRow` derives it from each + pane's tabs on every rebuild (never persisted — a strip handed an ordinary window stops being one), the grid + gives a fixed child `GridLength.Cells` and a floored one `Star(min:)`, and `LayoutSolver` shares rows the same + way (`PaneExtent.DistributeRows`; if every child is fixed the last shares, in both). A pane holding **one panel and + nothing else draws no tab strip** (`PanelSizing.ShowsTabStrip`, applied as `PaneNode.DrawsHeader` → + `TabControl.ShowTabHeader`, which `PaneOutputRects` already reads) and offers no drag handle: every panel titles + itself (COMPOSE, HERE, VITALS, a strip's row), as the mockups draw the composer and who-here columns, and the row + goes to the panel. A panel pane holding a window or a second tab keeps its strip; the map is a window and keeps it. Only + rows: a strip's width is a share. No divider row is drawn beside a fixed pane (it sits flush, on a recessed + plane), and a drag of that border returns `PaneResizeOutcome.FixedSize`, and ⌥⇧↑/↓ take rows from a sharing pane beyond the strip or + pass to the next split up, refusing out loud when only a strip could pay. + - **The built-ins are the mockups' arrangements** (`BuiltInLayouts`). Roleplay: the output with chat as tabs behind + it, and a side column (35%) of the composer (3) over who-here (2). Combat: the output with the quick bar under it, + and a side column of the vitals card over the map, chat collapsed behind the map (a collapsed panel never joins a + strip's pane, `LayoutRealiser`). **Below 100 columns a built-in folds its narrow arrangement** + (`BuiltInLayouts.ForWidth`, same id and name; `ApplyLayout`/`RefoldLayout` read `IsNarrow()`, and + `SyncNarrowLayout` re-folds on a crossing): Roleplay's composer goes under the output, Combat's vitals become a + one-row strip across the top so the column's rows go to the map. Only `IsBuiltIn` layouts swap; a saved copy of + a built-in (a new id) is drawn as saved, and an old copy keeps the old arrangement. + - **A vitals pane under half the window's width is a card** (`PanelSizing.ApplyAll`, `WidthShares`, + `CardBelowWidthShare`; `VitalsCardRows` = 2 a meter + 6: heading, the target's rows reserved): it draws its own VITALS heading + (no tab strip, above), the cells draw name and numbers over a full-width bar (`VitalsStripRenderer.RenderCard`, chosen by box + height ≥ 3), and at 20 px rows the window's gauges stack in it, headed. Decided from the tree on every rebuild, so a + player's own layout gets it too; the terminal mockup's top strip is the narrow arrangement, not the wide one. + **Where the host draws strips (`HostChrome.Strips`, the window) the card is as tall as its gauges** + (`VitalsGaugeCardRows` = 2 a meter + 4, + 2 only while a target is named; `SharpMUTermApp.MapColumn.cs`), so the map + gets the rows. **The window answers the rows from the gauges' pixels** (`IPointerPresenter.VitalsCardRows`, default null; + `WindowPointer` from the cell height `TerminalView` writes, `VitalsGauges.CardRows` = `NeededHeight` rounded up), and + the gauges spread the rest of the last row over their gaps (`GapGrowth`, ≤ 8 px a gap), so the last bar ends 16 px + (the mockup's padding) above the map; with no view measured (tests, the parity harness) the row rule stands; a target arriving or a meter appearing re-lays the pane area (`ResizeVitalsCard`, on the panel's game + data change, only when the rows differ). The column is beside the output, so no NAWS moves. The terminal keeps the + reserved card. **There too a pane holding the map window alone draws no tab strip** (`ApplyLoneMapPanes`, under + `HostChrome.Tabs`): the native map's heading names it. The terminal keeps its strip (the cell map's drag handle and ×). + - **The status row leaves its meters out while the vitals strip shows them** (`VitalsStripShowing`: the status + row's character's vitals window is the front tab of a drawn pane, zoom included). `SyncStatusMeters` repaints + the row only when that flips (on a rebuild or an activation), so a notice is not displaced for nothing. The + rest of the row's width rules are untouched: the meters were already the optional part. An id with no + factory counts as having no data (`DrawableAvailability`), so an unbuilt panel is *Waiting* and costs no cell. + - **Applying never closes a window.** Windows no panel places stay in the pane they were in if its id survives, + else in the output pane; a collapsed panel's windows are background tabs of the first pane in its region. + - **A restored workspace wins at startup.** The first bind of a character whose windows the resumed workspace + holds keeps that arrangement; any other bind applies the resolved layout; ⌥M ⏎ always applies. Only the live + entry point applies on its own (`autoLayout:`, the `save:`/`logRoot:` family), so no test or snapshot that + binds a session has its panes rearranged. + - **Re-folding is gated twice.** Inputs (game data, the map recorder, a new spawn window) arm one 500 ms timer + (`LayoutRefoldDelay`), and the re-fold rebuilds only if `LayoutRealiser.FoldShape` changed — a burst of GMCP + that draws the same panels reflows nothing (`PaneAreaRebuilds` is the pin). It stands down for good once the + tree's shape (`TreeShape`: pane ids and nesting) is not the one it applied: the player split, closed or + moved a pane and the arrangement is theirs. A divider dragged is not a shape change and is reset by the next + re-fold. A new spawn window goes into the live layout's chat pane, not the focused one. + - **⌥M renames (`r`) and deletes (`d`, then `y`) saved layouts** through `LayoutLibrary.Rename`/`Delete`; both + are refused for a built-in and the footer names them only over a layout of the player's own. The name is typed + in the list itself; Esc leaves the rename or the question before it closes anything (`ListOverlay`'s `escape:` + hook). Every footer row is always emitted, blank when idle, because the modal is sized once when it opens. + Deleting the live layout hands the panes to the player (`_liveLayout = null`) rather than re-folding them. + - **Saving captures geometry, not intent.** `LayoutCapture` reads regions off the solved rectangles (above the + output = top strip, right of it = side, below = bottom); panels not on screen keep their old placement. + Saving changes to a built-in saves a copy and points the character (or the world using it) at the copy. + - **Layout saves go through `PersistConfiguration`**, never `SaveConfiguration`, which re-periodises timers. + F5's rows commit through `SaveConfiguration` like every screen and it re-applies a live layout whose owner + now resolves differently. + - **The suggestion is taken once per world** (`AcceptedLayoutSuggestion`), 5 s after a connect or at once when + vitals arrive, said in one notice, and only by the live entry point. +- **Four panels plug into that seam** (`SharpMUTermApp.Panels.cs`, `Tui/Panels/`: `WhoHereRenderer`, + `VitalsStripRenderer`, `QuickBarRenderer`, `DockedComposer`; Core `RoomOccupants`, `CombatTarget`; ⌃P `panel:*`; + the `panel-who`/`panel-vitals`/`panel-quickbar`/`panel-composer` views). The first three are one `MarkupControl` + each, drawn from the session's stores on change and sized from `PaneOutputRects` on `PostBufferPaint` + (`SyncPanelSizes`), so a panel's content is a function of its own pane and moves no other cell. Decisions: + - **A panel's links are its own.** Each panel control has its own `LinkClicked`, the payload is an *index* into + what that panel last drew (`mux:who:N`, `mux:quick:N`), and the action runs on the panel's own session + (`context.SessionKey`), never `_active` — the map window's rule. A who-here click puts `look ` on the + command line through `PlaceGrab` and sends nothing (the name is the game's text); a quick-bar click runs + `WorldSession.HandleKeyAsync(descriptor)`, the path the key itself takes. + - **The roster is server text, bounded** (`RoomOccupants`: 200 rows, 64-character names, no whitespace or + control characters in a player name, so a click cannot become a second command). `Room.Players` replaces, + `AddPlayer`/`RemovePlayer` edit, and creatures come from `Char.Items.*` with `location: room` and an `m` + attribute. Cleared on connect with the game data. + - **The vitals strip reserves every field**, the status row's rule: value and max `StatusMeterRenderer.ValueWidth` + each, the low mark's cells whether shown or not, the target name padded. The level of detail (bar length, + word or `!` low mark, gap, target bar, then percentages) is chosen from the width alone, so it changes only + when the pane does. It reads F7's meters through `StatusMeterRenderer.Read`; the meter model is unchanged. + - **The quick bar elides, never wraps**: `+N` counts what did not fit and `F4 edits keys` goes first. + - **The docked composer shares F1's draft** (`_composeDrafts`, per character, in memory) and sends through + `ComposeMessage.Build`, so ⌃S, ⌥L and the `%r` join are F1's exactly. F1 from it opens the same buffer full + screen; F1's close writes the draft back into the docked copy (`SyncDockedComposers`). + - **Composer focus is one fact: *armed*.** Armed means the focused pane's front window is a composer and it has + not been released (`_composerReleased`). While armed, `PinFocusToArmedBar` pins the framework's focus to the + *editor* instead of the bar — so typing, paste and the caret all land there by the pin's existing rule — and + both command lines wear the idle band. `HandleWindowKey` hands an armed composer's keys to + `HandleComposerKey` (macros, resize, ⌃C/⌃X, ⌃S, ⌥L, Esc, the ⌃arrows, else the editor). **Esc releases** (the + bar arms and ⏎ sends from it, to the same character); the ⌃↑/⌃↓ ladder releases too; **arriving** at the + composer's pane (⌃O, ⌃arrows, a tab, a click in the editor — `ArmClickedComposer` on `FocusChanged`, guarded by + `_pinningFocus` so the pin's own focus moves never count) arms it again. `ArmBar` does **not** release: it is + re-asserted on every layout change, and releasing there disarmed the composer the moment its pane was built. + ⌥⏎ arrives as Esc then Enter, so an Enter within 50 ms of the releasing Esc re-arms and inserts a newline + (`TryComposerAltEnter`). `DockedComposerTests` covers each of these. +- **The window can pop a native panel out into a window of its own** (map, vitals, who-here, quick bar; + `SharpMUTermApp.Floating.cs`, Avalonia `FloatingPanels.cs`: `FloatingPanelPresenter`, `FloatingPanelWindow`, + `PopOutButton`; Core `FloatingBounds`; Client `PanelFloatCommands`, `FloatingPanelModel`; ⌃P `panel:popout:` + and `panel:dock:`; the `floating-panel` surface). Three ways out — the button in the docked view's top-right + corner, the pane's right-click menu, ⌃P — and three ways back: the floating window's Dock, closing it, ⌃P. Decisions: + - **Floating is a fact about the panel's *window*** (`WorkspaceWindow.Floating`, screen pixels; `Unplaced` = the host + picks), so it is saved with the workspace, and a layout says the same per panel (`PanelPlacement.Floating`, + captured by `LayoutCapture` from `FloatingPanelsOf`). **The fold never reads it**: region, order and size stay the + place it docks back to, and a host that cannot float folds the layout exactly as if it said nothing. + - **The host fact is `ClientHost.FloatingPanels` (`IFloatingPanels?`, required, null in the terminal and headless)**, + not `HostChrome`. `HostChrome` says which chrome a host draws *instead of cells*; floating is a channel the client + publishes to (models out, moves and docks back), the `IPointerPresenter`/`INativeModals` shape, and a null member + is the one "this host cannot" every other optional channel already uses. `CanFloatPanels` reads it; ⌃P's families + list nothing without it (`CommandContext.Floats` null), the pane menu adds no entry, a docked overlay carries no + `PopOut`, and `PopOutPanel` refuses out loud. The catalog's actions say `OnlyOn: Window` with `CellsVia` the ⌃P id. + - **The pane goes through the workspace's own operations, and no cell is spent.** While the live layout owns the + panes, a pop out or a dock re-applies it (`RearrangeLiveLayout` → `ApplyLayout(adoptFloating: false)`) with the + floating window withheld (`DockedWindows`), so the panes are exactly the layout without that panel — the rectangles, + and so NAWS, are equal (`APoppedOutPanelLeavesThePanesALayoutWithoutItWouldHave`). Otherwise `RemoveWindow`/`AddWindow` + (back into the pane it left). An explicit apply takes the layout's floating state (`AdoptFloating`); a re-fold keeps + what is floating now. `OpenPanel`/`ShowMap` dock a floating panel: asking for it in a pane means the pane. + - **A floating panel is published like a docked one, from the same function** (`OverlayFor(…, docked: false)`): the + same model and the same callbacks, so a walk, a look or a quick key from the floating window runs the same method + on the panel's own character. Its control is made if it never had a pane (`EnsureFloatingControl`), and a redraw of + it republishes (`PanelView.Refreshed`, `RenderMap` → `NoteFloatingContent`), since no frame paints for it. + - **Resumed by either host**: `SettleRestoredFloating` takes a floating window out of its pane where the host floats, + and puts one back in a pane where it cannot, keeping the flag for the next host that can. A floating window has no + rail row (it would read `closed`). + - **The window**: owned by the main window, `ShowActivated = false`, not in the taskbar; first placed beside the main + window (left when the right has no room), later where the player left it, **clamped to the screens there are now** + (`FloatingBounds.ClampTo`: unchanged when wholly on a screen, else the screen holding most of it or the nearest, shrunk + to fit). Moves are reported after 300 ms of quiet. Every key and every typed character goes to `TerminalView`'s + handlers, so typing lands on the command line. Its plane is the character's pane tint on the theme, its ink the + theme's text. Closing it docks; the client closing it (a dock, the main window going) docks nothing. + - **Fixed on the way**: `PaneOutputRects` called before the main window existed threw, so a resumed workspace holding + any panel or map pane threw in the constructor; it now answers "nothing arranged yet". + - **Not done**: one floating window per panel window, not per panel kind across characters; no snapping or + always-on-top choice; the composer does not float; positions on a mixed-DPI desktop are assumed to be in the main + window's scaling. + +- **Wave 18 tidy-up.** The script entries (reload, console, edit, new, the files) are under HELP & SETUP; Edit and New + and the file entries are offered together, only while scripts are on or the files are listed (`ScriptsEditable`). The + cell ⌃P stops above the command line (`CommandPalette.KeepClearBelow` = `InputBandRows`), so at 80×24 its border is a row + above the prompt. F5's columns are fitted with `MarkupText.FitVisible` (elided with `…`, trailing blanks cut silently), + and `highlight names`' help inks each colour in itself, held to the floor. An overflowed tab keeps 3 letters and `…` + (`TabStripFit.MinimumName` 4); the tab the chip would leave shorter than that (`ChipReserve`) is counted under the chip. + In the window a pop-out button shows only while the pointer is over its panel (`DesktopLayer.SyncPopOutHover`), and a + one-row strip gives the button its right end (`ReservesPopOut`: the native control is narrowed, the corner painted the + pane's plane), so the button never sits on a strip's text. +- **Wave 22 combat strips (`HostChrome.Strips`, the window's `Desktop`; terminal frames unchanged).** The quick bar is + `PanelSizing.QuickBarStripRows` (3) rows there (`ApplyAll(…, quickBarRows)`), drawn as 34 px buttons (`QuickBarButtons.ButtonHeight`, + key cap in `KeyFace`, a rule on top). `Macro.Danger` (F4 `style`: normal/danger, presentation only; the engine never reads it) is + `QuickBarItem.Danger`, drawn in `ScreenPalette.Warn` held legible (`DangerInk`) in both hosts. A collapsed panel gets a pane of + its own (`LayoutRealiser.Realise(collapsedAsBars:)`, `collapsed:`, below its region's first pane) drawn as a bar + (`SharpMUTermApp.CollapsedBars.cs`, `CollapsedPanelModel`, `CollapsedBarRenderer`, `CollapsedBarView`; surface `collapsed-panel`): + `CollapsedBarRows` (1 under `Input`, else 2), no tab strip, its windows marked scrolled back so the unread reads `N new`. A click, + or `Activate` of a window in it, is `ExpandCollapsedBar` (for the run; an explicit apply clears it). `DesktopLayer.BelowGrid` + stretches a native view at the foot of a column over the field reserve where the band does not reach. +- **Wave 23 terminal (cells only; the window's native views are unchanged).** A docked panel's heading is a band + across the panel (`HeadingBand.Row`, Tui `Panels/`; `WorkspacePalette.HeadingBand` = the rule leaned 0.2 toward + `Theme.Prompt`, `HeadingInk` and the accent held to 4.5:1 on it): `HERE`, `COMPOSE` (the armed `▌` in the band's first + cell) and the vitals card's `VITALS`. The renderers take the band as an optional `Ink` member, so a renderer test with + none still gets the bare accent label. The scrolled-back band is filled with the same band (`ScrollSplitBarRenderer.Bar(…, + fill)`): it divides two viewports rather than sitting in a buffer, unlike the freeze/away/NEW bars, which stay rules. + The header's character is ` ● Corvid` in the world's colour on the band, no chip (`HeaderChip` is gone). The vitals + card draws its rule only above a target. The welcome's `/connect` example is in the accent. +- **Wave 29 terminal audit (cells; both hosts' cells move together, the window's native views are unchanged).** Measured + against the four `Terminal-*.dc.html` mockups at their own grid (138×34: 1160 px of 14 px JetBrains Mono, 20 px rows) and + at 80×24 (`ux-wave29/terminal-audit/`). The rail's heading is ` CONNECTIONS` and a world row a bold name in the ▸ + column, with no `┌ ⏻` and no `▚` spine (the world's accent is its characters' dots). Under a heading band who-here's + rows and the vitals card's blocks keep a cell of margin either side (`MarginFromWidth`/`CardMarginFromWidth` 16; a + renderer with no band is unchanged); the quick bar keeps one at each end from 24 cells, its keys in the held muted ink + (`HeaderMuted` on the reading plane) and the names in the body ink, not bold accent. A bar's empty part is solid `█` in + `WorkspacePalette.Rule` (a fill, exempt like every solid block), not lifted `░`. The docked composer's text is on the + well (`FieldBg`), a cell in from the rule. The filled scrolled-back band says everything in the band's ink. With no + world the header reads ` no connection` after the brand, where the row has room (dropped before the brand shortens). + The settings search's nav has a `SECTIONS` heading, titles in the body ink, the tally before the key, and a query + well 24 cells wide (narrowed to what the hints leave). **`ScreenChrome.Split`'s body runs to the action bar**, so the + hairline is full height on every two-column screen (it ended under a short list). Left as they are: the output's left + padding (a cell there is NAWS; the terminal has no gutter, decided), the rail's own width and cell 2 indent, the + Combat mockup's top vitals strip and no rail (the narrow arrangement; ⌃B b folds the rail), the map tab strip beside + Chat (a pane with two windows keeps its strip), area-coloured map rooms (`MapAreaPalette`), `rows` not `lines`, the + `Corvid · Aetherfall` spelling, `›` as the resting prompt, the cell settings screens having no permanent nav (only the + search lists sections). The nav's plane came in wave 30 (below). +- **Wave 30 terminal map and nav (cells; both hosts' cells move together, the window's native map is unchanged).** + Measured against `Terminal-Combat`'s map and `Terminal-Settings`' nav (`ux-wave30/terminal-map-and-nav/`). The cell map's + title is a heading band where the panels have one (`MapRenderer.Ink.Band` = `PanelHeadingInk()`, null under + `HostChrome.Strips`), its look controls at the band's far end in the band's accent. A plain grid takes the mockup's + `[ ]───[ ]` step (`WideCellWidth` 6) when every room of the level lands in the box centred on the current room, else + `CellWidth` 4; a plain way west fills the gap too, a stub stays one cell, a diagonal sits mid-gap. From + `InsetFromWidth` (24) the title, exits, hint and legend keep a cell from the pane's edge. A tall pane (≥ + `UnderFromHeight`) says `MapViewModel.Hint` over the legend, only whole. The settings search's section list is on + `ScreenPalette.NavBg` (`#141822`, a text plane `ScreenTheme` mirrors and holds inks on; `ScreenChrome.Nav` pads it to + the body's rows so the plane reaches the action bar), the selected section bold in the value ink. Left as they are: + area-coloured rooms (decided), the map's tab strip beside Chat, the title naming the room rather than `MAP` (decided), + the solid one-cell `Split` rule (shared by every two-column screen), and the mockup's target `◆` (no model carries it). +- **Wave 19 panels and welcome (window only; the cells are unchanged).** Each docked native panel draws a heading from + its model (`PanelHeading.For`, Client; `PanelText.Heading`); `VitalsModel.Source` names where the meters came from + (`SourceOf`, set in `ReadVitals`), a mixture names nothing. The docked composer's footer (summary, Preview, Send) ends + in `ComposerSession`: Preview is `PreviewDocked` → F1 with `_composer.Preview` on (surface action `preview`). The quick + bar and the vitals row leave 22–30 px at the right for the pop-out button. The native welcome is a page + (`WelcomeView`): its Find card attaches a `WorldBrowseSession` to the welcome (`WelcomeSession.FindWorlds` → + `FindWorldsInWelcome`, actions `find`/`add`), fetched only on Show worlds, forgotten when the welcome closes; Add is the + browse pick, which reopens the welcome with the form filled. +- **Wave 25 first run: both welcomes are drawn to `Desktop-FirstRun`/`Terminal-FirstRun`.** The window's is a **full page** + (`IModalView.FullPage`, default false): `ModalOverlay` paints `Well` over the whole window, header and sidebar included, + with no card, edge or scrim, and a press on its ground (`ModalOverlay.GroundTag`) moves the window, resizes it at an + edge, double-click maximises — never dismisses. The page is a 1,040 px column 56 px down, cards on `Card` at 3 : 2, + the first world row `Raised` with the `Focus` edge, the chosen layout `Raised` with a 2 px accent edge; `Display` is 28. + The cells' is a `ListOverlay` opened `flush` on `plane: PanelBg` (both additive parameters, popups unchanged): no frame + title and no padding, the heading on a `HeaderBg` band and the keys on a `FooterBg` band edge to edge + (`WelcomeSurface.Render`, body indented 2), the frame in `Rule`, the selected row on `CursorBg`, and the layout note + beside the chips once the row holds both (`PreferredWidth` 78, so from 82 columns). Not taken from the mockups: their + genre chips and *Package* tag (mu*index gives neither), example data (port 4201, TLS on), the "games" noun, and the + mockup's suggested-layout card (nothing is known before the first connect, so *suggested* stays a third state). +- **Wave 31 first connect: welcome → Browse → address → form → connect, walked in both hosts** (`ux-wave31/first-connect-flow/`). + The raised world row is the one selected (`WelcomeView._worldSelected`), so ↑↓ in the search move it and ⏎ adds it (`FindOrAdd`; + a query the held rows do not match still asks the directory). In an address choice the keyboard is on the address row (focus + posted at `Loaded`, since rows are not in the tree until laid out), not on Back. A form a pick filled in starts in Character + and says which world it is and its charset (`FormIntroLine`, both hosts). **Every Esc goes back one step** in the cells: from a + picked form to the list as it was left (`ReturnToWorldBrowse`, which re-uses the last model and fetches nothing), and from a + Browse the welcome opened to the welcome on its Browse row (`_browseFromWelcome`; from ⌃P it still closes). **The tour row + goes under `connected to …`** (`ShowOwedKeyTour`, on the `ConnectionNoted` CONNECT note, because `StateChanged` Connected + comes before that row). The `welcome` demo scene starts empty (`_startedEmpty`), so it owes the tour as a real first run does. + Cell notes wrap to two rows rather than eliding (`WorldBrowseSurface.AddNote`); the form's intro wraps to the frame. +- **Wave 32 first connect at 80×24 and 60×20 (cells, both hosts; terminal frames moved)** (`ux-wave32/terminal-first-connect-80x24/`). + **The tour row ladders** (`KeyTourKeys`, `KeyTourMarkup(width)`): it is a bar row (`PaneLine.Bar`, redrawn at the pane's width + by `SyncChromeBarWidths`, found again by its bar delegate `_tourBar`), full where it fits, short labels then ⌥M, F5, F1 given + up; ⌃P and `hide: ⌃P ▸ Key tour` stay. As chrome it is no longer found by ⌃F. **The welcome fits the desktop** + (`WelcomeSurface.MaxRows` = desktop − 6, so centred it clears the command line): every sentence is broken to the body, a + choice's detail is elided to its row, and over the rows spacers then the `/connect` example go (`Line.Drop`). Browse's + notes take up to 3 rows from the list (never below 3 list rows, `NoteRows`), and a refusal wraps. The header drops its + connected count when the shortened brand still does not fit (60 columns wrapped it); `NoWorldsNoticeFor(width)` shortens + the empty notice's words, never its commands. `FooterFitTests.TheFirstConnectViewsFitA60x20Terminal` and + `WorldBrowseTests.TheFirstConnectFitsASmallTerminalAtEveryStep` (real keys, 80×24 and 60×20) are the pins. +- **Wave 32 window welcome backtrack (window; terminal frames unchanged)** (`ux-wave32/window-welcome-backtrack/`). Esc goes back + one step in the window too, through the cells' method (`StepBackFromWelcome`, surface action `back`): a form native Browse + filled in goes back to that list as it was left (`WorldBrowseSession.Place`/`StartAt`, `WorldListPlace`: query, order, row; + `ReturnToWorldBrowse` reopens it without fetching); a pick from the page's own list fills the form *in place* (the list stays), + so Esc gives the pick back (`WelcomeSurface.ForgetPick`) and the keyboard returns to the search; a hand-typed form's Esc goes to + the search; only then does Esc close. Native Browse opened from the welcome has a Back button and Esc back + (`ReturnToWelcomeWith`, `CanReturnToWelcome`, `WelcomeKeys`; action `welcome` → `ReturnToWelcome`, now both hosts). The find + card's heading has *Browse all worlds* (`WelcomeModel.BrowseAllButton`), the page's door to it. The page fits without a scroll: + bottom margin `Space16`, and a compact tier (`WelcomeView.FitPage`: top 24, gaps 16/12, card padding 18) when the natural + height would not fit (the default window). The added-world notice is one model (`WorldNotices.Added`): F5 once (no key chip, + the words name it), the encoding in brackets when one was saved, and no endpoint (the CONNECT rows say it; with one the row did not fit 60 columns). +- **Wave 33 client text at 80 and 60 columns (cells, both hosts; terminal frames moved)** (`ux-wave33/terminal-client-text-80/`). + Text the client words is fitted by **choosing a shorter wording, never by eliding**: `FirstFitting(room, forms…)` (notices: + `NoticeRoom(chip)` is the row less a key chip or source label; `NothingAtStartNoticeFor`, `SwitchedNotice`, `NothingToSendTo`, + the no-session notice, the map's edit-keys notice), `DropHint` for the move/drag prompts (⏎ and Esc last), `BarRule.Fit(fixed, + forms, width)` for bars and client rows (NEW, AWAY, RESTORED, `ReconnectLines`; `AppendClientRow(…, params forms)`), F1's header + as a ladder of headers (field words, then spelling), and the docked composer's keys through `KeyLadder` (⌃S, Esc kept). Every + form keeps every key and command; a form may drop the owner's name. The key tour already fit with the timestamp gutter on: a + bar carries no stamp, so the gutter costs it nothing (pinned). `ClientTextFitTests` reads each fixed row off the frame at + 80×24 and 60×20. Left as they are: a list row's detail column (⌃P, help, Browse, F10, the welcome's choices: it gives way by + design), server and mu*index text, a script error's engine text in the script header, the message log's rows, F5's world + column and trigger-set descriptions at 80×24, and the settings action bar's context line and F7's search/undo header at 60 + columns (the settings screens are held to 80×24). +- **Wave 33 B display scaling (window only; terminal frames unchanged)** (`ux-wave33/window-display-scaling/`; Xvfb with + `AVALONIA_GLOBAL_SCALE_FACTOR` 1, 1.25, 1.5 on 1920×1080). **A first size fits its screen** (`WindowFit`): the main window + is the `--size` grid plus every inset (the footer's 28 px was left out) and, if that is more than the working area less + `ScreenMargin` (24) each side, loses whole columns and rows and is centred (`MainWindow`, after `TerminalView.PrepareScale`, + so the cell is measured at the screen's scale; `Screens.Primary` is null under Xvfb, so the first screen); the settings + window is clamped the same way (`WindowFit.Within`, never under its minimum). **A hairline the view draws is one device + pixel** (`TerminalView.DeviceHairline`: dividers, the strip's top rule, the field's edge), never the scale (1.5 px blurred + two rows). **The grid's leftover pixels** (the view less a whole number of cells, under one cell) belong to the last + column: a tab strip, band, panel or pop-out button ending there is widened over them (`DesktopLayer.RightOfGrid`), a + field band ending there measures its right inset from the view's edge (`FieldRect(…, viewWidth)`), and a stacked divider + reaching it runs on over them (`DrawDividers`; the cells' last background, the rule's, showed as a block at the edge). The docked composer's + summary is a `FittedText` (`ComposerModel.SummaryForms`: the longest wording that fits, else `N words`), and the view + draws no overlay scrollbar over a window a native panel covers (`TerminalView.CoveredWindows`). The welcome has a third, + short tier (`WelcomeView.Short`: the `/connect` example and the layout cards' details go, the details into tooltips). + `DisplayScalingTests`. Not fixed: under Xvfb an Avalonia `ContextMenu` at 1.25/1.5 opens a popup a few pixels smaller than + the menu and scrolls (seen before this wave; not reproducible off X11 here). +- **Wave 34 terminal completion audit (cells, both hosts; terminal frames moved)** (`ux-wave34/terminal-completion-audit/`). + Every `SnapshotViews.All` view read off the frame at 120×32 and 80×24, the four terminal mockups at 138×34, Light and + Solarized samples. **A `ListOverlay` with pinned keys lays its body out one column narrower** (`_barColumns`): the scroll + panel's bar takes a column, and laid out at the full inner width every line that filled it wrapped (the history view drew + each line twice and a scroll bar at 80×24, and wrapped its title at 120). **A web page is laid out at its pane's width** + (`WebRenderWidth`: the web window's viewport, else the focused pane's), not the screen's less 4, which wrapped it twice. + Every settings screen's title is one cell in (the list screens had two; F7/F8 and the mockup one). **A cut plain text gives + back the blanks before its mark** (`MarkupText.ElidePlain`, which the map, Browse, F10, game data, the options screens and + the history view now share: `Aetherfall…`, never `Aetherfall …`). The deletions question has the popups' left cell. The + settings search's section titles take the cells the row had spare (`width - 10`), so `Triggers & spawn routing` is whole at + 80×24. `TerminalAuditWave34Tests`. Left as they are: list detail columns, F5's descriptions and the message log at 80×24, + server text, the map's room labels and legend (laddered by design), ⌃P's reserved key column and its scroll bar on the + frame (the window is the framework's scrollable one), F4's capture prompt widening its row (`CaptureWidth`, decided), the + tab strip's grey `─` (upstream), and a number and its unit split by a wrap (the framework breaks at any space). +- **Wave 34 A window completion audit (window only; terminal frames unchanged)** (`ux-wave34/window-completion-audit/`, 1440×900 + and 1280×800 at 125%: the four mockups plus ⌃P, F1, ⌃F, a toast, quit, two tinted characters, Light). The sidebar is the + mockups': `CONNECTIONS` tracked (`NativeSidebar.Tracked`, letters one by one, since `FormattedText` has no spacing), a world + is its name alone, a character a 4 px dot in the world's accent (ring offline), a window's chord in mono `Small` at the + right with its unread count as bold accent text before it, not a pill. ⌃P rows share one pitch (`ModalParts.ChipHeight` + floor). A card's content is clipped inside its edge (`ModalParts.InsideEdge`), so a foot band no longer covers the rounded + hairline; ⌃P/⌃F/⌃R/help's band sits flush under the list rule. **The field stays drawn under a native modal** + (`InputBandNow` drops it only for a *cell* modal). F1's title is `Compose`/`Edit`/`Script`/`Read only` as the docked one, + its chips outlined and its key row `ModalParts.Chip` caps. The welcome's key line sits under its column. Left as it is: + a panel's heading rule or a strip's seam stops ~4 px short of a divider's hairline, because the hairline is centred in + the divider cell and a native view covers only its pane; moving the hairline or the view fails where both sides are + native, or narrows the divider's grab area. +- **Wave 16 groups** (F5 headings, ⌃P groups, scene mark, client rows, map find). Decisions not to relitigate: + - **F5's world column is drawn in the declaration's order under its `Group` headings** (`WorldsScreenRenderer.DetailColumn` + walks `BuiltInSettings.World.Fields`), so the cell screen and the window group alike; `WorldFieldOrder` (⇥ order and the + ordinals tests navigate by) follows it, security checkboxes excepted. Labels are the declared ones, not shortenings. + - **⌃P's groups are `CommandGroup`'s nine** (Go to, Connection, Output, Writing, Map, Panels, Windows & panes, Help & setup, + Settings); `PaletteGroupsTests` keeps OUTPUT at 16 or fewer. `CommandContext.NewConfiguration` (no worlds, or a tour + owed) puts Help, Welcome and Import first in SUGGESTED. + - **`/map find` is a ⌃P family, not links in a pane** (`map:walk-to:`, `CommandContext.MapFound`; the palette opens on + `walk to `). A link in a pane is answered by the panes' handler, which is scheme-tagged server text; a palette entry is + the client's own list, in both hosts, with no new trust path. `--plain` gets client rows instead. + - **What the client says in a session window is a client row** (`ClientRowRenderer`, `AppendClientRow`: a bar function + and no `Plain`), never a `***` session line: reconnect countdowns and stops reach no transcript, restore, scene log or ⌃F. + Core's own connection notes are too: `WorldSession.ConnectionNoted` (`ConnectionNote`: CONNECT/FAILED/LOST/CLOSED) + replaces the `*** Connecting/Connection lost/failed` lines, `BindSession` draws them as client rows and announces them. + - **The status row names a notice's action** (`NoticeActionHints`): `⌥R Reconnect` when the command has a key, else + `⌃P ▸ `, else the action's shorter label; added whole after ` · ` only if the row still fits, never cut. + - **Help topics are blocks** (`HelpPage`, Client): a paragraph or line opening `Label: ` is a heading, keys are spans + (glyph runs, F-keys, Esc, PgUp…), two-space-indented lines stay rows. The cells draw bold headings and accent keys; + the window draws headings and key chips (`NativeKeys.Words`). The cell topic header is one elided row. + - **F5's world column at 80×24 drops its group headings** when the fields do not fit, naming the top row's group on the + column's first row (`├ WORLD · CONNECTION`, `⌃ 12 more · MAP`) — re-read at every scroll step from the first field + shown, which `SearchAndScriptWaveTests` walks with ⇥ (checked in wave 18: it was already right); while a world field is focused the character form and + trigger sets are capped to a third of the height. ⇥ order is unchanged. An open `highlight names` edit draws the + syntax and colours under its well (`NamesHelpRows`; `ScreenChrome.Window(…, keepBelow)` keeps them on screen); the + window's field help is `HighlightColours.NamesHelp`, the same words. + - **The native modal scrim is `NativeTokens.Scrim`, shared with ⌃P and settings**, raised to 0x73 dark / 0x4d light; + `NativeModalTests.EveryModalIsVeiledByTheOneScrimOverTheWholeWindow` pins it. + - **A scene recording is marked** (`● REC` status cell, `HeaderModel.Recording`), and the saved notice carries two actions + (`NoticeModel.Second`): `scene:open:N` and `scene:copy:N`, by index into this run's saved scenes, never a path. Open goes + through `ClientHost.OpenFile` (required; null headless), which takes only an existing absolute `.html/.htm/.txt/.log` + (`ExternalBrowser.TryParseOpenableFile`), and only under the log root. + - **`Notice(..., toast: false)`** draws the status row without a toast: map edits that worked and map hints use it. + - The parity harness's window host has `FloatingPanels = null`, like the terminal, or ⌃P's catalog differs by the pop-out entries. ## One client, several hosts — and how they are kept the same The terminal (`sharpmuterm`), the desktop window (`sharpmuterm-window`) and the headless snapshot all run the one `SharpMUTermApp`; a host is only the place it runs. What differs between hosts is a **`ClientHost`** -(`src/SharpMUTerm.Tui/Hosting/`): driver, capabilities, clipboard, URL opener, focus reporting, keys +(`src/SharpMUTerm.Tui/Hosting/`): driver, capabilities, clipboard, URL opener, file opener, focus reporting, keys (`HostKeys`: the newline chord, and the chords the host keeps for itself), whether there is a main loop, -the Sixel cell size, and what it does with the pointer (`IPointerPresenter`, null for none). Built by `ClientHost.Terminal`, `ClientHost.Headless`/`Snapshot` and +the Sixel cell size, and what it does with the pointer (`IPointerPresenter`, null for none), whether it draws the declared settings natively (`INativeSettings`, null for the cell screens), and whether it +draws ⌃P natively (`INativePalette`, null for the cell popup), whether it draws the composer with its own editor (`INativeComposer`, null for cells), whether it draws the quit prompt, ⌃F, ⌃R, ⌥M, the welcome, help and the dictionary offer itself (`INativeModals`, null for cells), where the title for what is in front goes (`IWindowTitle`: the window's title bar, the terminal's OSC 2, null headless), whether it can pop a panel out into a window of its own (`IFloatingPanels`, null for none), whether it has a native file picker (`IFilePicker`, null for none), how it asks for attention while the player is elsewhere (`IAttention`, null headless), and which chrome it draws itself beside the cells (`HostChrome`: the header and the rail, which the client then leaves out of the grid). Built by `ClientHost.Terminal`, `ClientHost.Headless`/`Snapshot` and `WindowHost.Create` (Avalonia). Four things keep a feature from landing in one host only: - **Every `ClientHost` and `HostKeys` member is `required`**, so a member added there does not compile until @@ -876,7 +1847,8 @@ the Sixel cell size, and what it does with the pointer (`IPointerPresenter`, nul `ClientHost`. Don't use `WarningsAsErrors` for it: that ignores the folder's `severity = none`. - **Every snapshot view is drawn through both drivers and compared cell for cell**, glyph and background (`HostParityTests.EveryViewDrawsTheSameCellsInTheWindowAsInTheTerminal`, Avalonia suite), with the same - capabilities and keys, so a difference can only be the driver. The list is `SnapshotViews.All`, and + capabilities, keys and `HostChrome` — with the chrome in cells, with the header and rail drawn by the host, and with + the tab strips drawn by it too, as the window runs — so a difference can only be the driver. The list is `SnapshotViews.All`, and `EveryViewTheSnapshotKnowsIsInTheParityList` reads `RenderSnapshot`'s source and fails when it learns a view the list does not name. The harness drag goes through `IMouseInjector` so `drag` draws in both. A second pass (`EveryViewDrawsTheSameCellsWhenTheWindowsLoopIsRunning`) draws each view with the window's loop @@ -884,6 +1856,166 @@ the Sixel cell size, and what it does with the pointer (`IPointerPresenter`, nul came out as an empty frame — ⌃P and F1 blank — while the inline pass was green. The scene is now queued for the loop's first tick (`ClientLaunch.Demo`). That pass skips the bottom row (a live client has been through startup) and the two timestamp views (a loop reads the clock). +- **Every native view is a declared surface, drawn from the model the cells drew** (`SurfaceCatalog`, Client + `Surfaces/`). A surface (header, sidebar, the folded rail strip, the armed ⌃B keymap, each panel, settings, the + Layouts menu, ⌃P) is declared once: id, + Client model, actions (each naming the client method it ends in), per host `Cells`/`Native`/`Both`, its cell + renderer and its native views. Three suites hold the hosts to it: `SurfaceCatalogTests` (Client: the + declaration's own rules), `SurfaceCellTests` (Tui: the renderer takes the model, every action's method exists, + every command resolves) and `SurfaceParityTests` (Avalonia: every `Control` in the window assembly is declared; + each native model equals `DrawnModel` — what the cell renderer recorded through `Drawing(...)` in the same frame; + each native action and its cell link leave two identical clients in the same state, and not the state before). + A door only the native view offers says where the cells offer it (`CellsVia`: another surface or a ⌃P command — + which is why the sidebar's fold button has `layout:toggle-rail` beside ⌃B b). The ⌃B keymap is one model + (`PrefixPanel.Model` → `HeaderPrefix`) for the cell panel and the native header, exit hint included. + The window never builds a command: a menu is a `MenuModel` of entries the client offered, and a choice comes + back through `HeaderAction.Dispatch` and is refused if it was not offered. **To add a native surface**: put its + model in Client, draw its cells from it through `Drawing(...)`, publish the same instance to the host, declare + it in `SurfaceCatalog.All`, and add its model and action probes to `SurfaceParityTests`, whose coverage guards + fail until you do. +- **A notice is a model, drawn as the status row's text in both hosts and as a toast in the window** + (`NoticeModel`/`NoticeSeverity`/`NoticeAction`, Client `Chrome/`; a notice carries a chord `Key` *or* a `Source` + label from `NoticeModel.Sources` — Packages, Map, Sound — drawn `Sound:` on the row and as a caption on a toast, never + in the key chip; a lowercase word passed as `key:` is a test failure; `SharpMUTermApp.Notices.cs`; `NoticeToasts`, + Avalonia; surface `notice`, `CellsUnderNative`; the `notices` view). `Notice(...)` builds one model per call + (`Drawing`), draws the row from it with the same markup as before (`NoticeMarkup`; terminal frames are + byte-identical) and publishes it through `IPointerPresenter.Notice`. Decisions not to relitigate: + - **The window keeps the status-row copy in cells.** The toast is an overlay above the cells, never a + replacement: `HostParityTests` compares cells, the row is what a screen reader and a keyboard user read + (the announcer speaks the same notice), and the row has rules of its own (reserved fields, drop order) a + toast must not fork. The cost is one notice said in two places in the window, for six seconds. + - **A toast never takes focus and nothing on it reaches the client**: not focusable, every press on a card is + the window's, a press beside the stack goes to the cells. The stack is bottom-right inside the cell area, + ending above the row the pane area ends at (`NoticeOverlay.AboveRow`), newest at the bottom, four at most. + It fades after `NoticeModel.Duration`, held while hovered (plus a 2 s grace). Right click dismisses. + **While the pointer is on a toast the stack is frozen** (`NoticeToasts.Frozen`): every toast keeps its place as the + ones around it fade, so the card under the pointer never slides out from under it; a toast arriving meanwhile + stacks above, and the freeze ends when the pointer leaves or the window resizes. **The stack is lifted clear of + the map's legend** (`NoticeToasts.Avoid`, from `MapCanvas.LegendRect`), which sits in the same corner. + - **An action is a registry command the client offered**: a click runs `NoticeOverlay.Run` → + `RunNoticeAction`, which refuses a command no recent notice carried, then `DispatchCommand`. The cells' way to + the same door is ⌃P (`CellsVia: term:messages`). Three notices carry one so far: the layout suggestion + (*Change layout*), "switched to … · offline" (*Reconnect*) and the orphan trigger-set notice (*Open F5*). + - **Severity is mapped from `MessageSeverity`**: Info → info, Error → warning (a failure), Warning → refusal + (the default voice of `Notice`). The card is `NativeTokens.Card` (rule `Edge`, text `Text`) of the theme the overlay + carries (`NoticeOverlay.Theme`); the edge colours — accent, `ScreenPalette.Warn`, the notice ink — are held to + `TextFloor` on that card, because they also ink the key chip and the action line (`NoticeToasts.ColoursOf`). + - The message log (⌃P ▸ *Show client messages*) stays the history; a toast keeps nothing. + - **A notice said again while it is still on the row is said once** (`Notice` compares text, key, severity and action + with `_noticeShown`): the timer restarts, nothing is logged, announced or published twice. That was the audit's + "notices show twice" — the row-plus-toast pair is the design above, the repeat was not. + - **The row is elided to its width in both hosts** (`StatusModel.Fit` in `PaintStatus`), so no notice can wrap it + (`NoticeFitTests`, 80×24 down to 40×12). + - **Toasts stay off what is being used and are hidden under a modal.** Besides the map's legend, `SyncToasts` avoids the + native quick bars and docked composers; `DesktopLayer.ToastsHidden` (set by `MainWindow`: settings window, ⌃P, a + native modal or the quit prompt) hides the stack rather than leaving it clipped under a window. F1 keeps them (it + sits under the layer). The toast keeps its clock while hidden. +- **The window draws the status row and the command line's field too** (`HostChrome.Status`/`Input`, `HostChrome.Desktop` + = all four, the window's default; `SharpMUTermApp.Footer.cs`; Client `StatusModel`; Avalonia `NativeFooter`, `TerminalView`'s + field; surface `status`). Decisions not to relitigate: + - **The model comes first and the cells are drawn from it**: `StatusBarRow`, `NotConnectedRow`, `NoticeSegment` and the + move/drag prompts build a `StatusModel` (left and right `StatusSegment`s with a `StatusRole`, each a list of + `StatusRun`s; a null ink is the row's own, so the cells write no tag for it), `PaintStatus` records it (`Drawing`) and + writes `StatusRowMarkup(model.Fit(width))`, and the footer draws the same instance. Terminal frames are byte for byte + what the markup-first row wrote; there is no markup parser. The palette hint and a notice's action hints carry targets. `IPointerPresenter.Status`, published from `PaintStatus` and `PostBufferPaint`, only on change. + A click is `ActivateStatusTarget`: refused unless offered, the palette through `OpenMenu`, an action through + `RunNoticeAction`. With `Status` the row is `Visible = false` and `ChromeRows` counts none. Toasts sit above it. + - **The command line stays the cell editor; the host draws the field around it.** The bars get `HostFieldPad` (4) + band-coloured columns either side (`InputBarControl.LeadingPad` + right margin), the client publishes an `InputBand` + (rows, pad, prompts, hint) on `PostBufferPaint`, `Flush` snapshots it into the `ScreenFrame` it belongs to, and + `TerminalView` draws the rows above it, the strip, a rounded field filled with the band colour read off the cells (so + armed, idle and the tint are the cells'), the bars' rows `FieldTop` px lower inside it, the prompt in semibold Inter + over its cells and the newline hint over blank cells. The grid gives up `FieldReserve` px, never a row, so panes and + NAWS are a terminal's told the same chrome. Null under a modal. Rebuilding it on a `TextBox` would fork every key rule + above. + - **The typed text meets the prompt by drawing, not by cells** (`TerminalView.BarsOf`, `FieldBar`). The Inter prompt is + narrower than its cells, so each bar's text is drawn `Shift` px left (to one mono space after the measured prompt), the + caret with it, and `Cell` maps a press there back to the cell under it; the right padding the shift uncovers is the + field's fill. The gutter, the caret's cell and every key are unchanged, so parity needs nothing. + - **In the window the command line is a row of the output's column** (`HostChrome.InputInColumn`, on in `Desktop`; + `InputColumn.Target`/`Anchor`, Core; `WithInputBars`/`DetachInputBars`). The prompt row and both bars leave the window's + sticky band and become Auto rows of a grid wrapped round one node of the pane tree, so the side column runs to the + status row as in the mockups. The node: from the root, into the anchor's child of a side-by-side split unless another + child holds a character's own window (two characters share the bars), into a stacked split only through its last child, + else that split (the output and its quick bar). The anchor is the live layout's output pane, else the first pane holding + a `Main` window — **never focus**, so moving the selection moves no pane (`InputColumnLayoutTests`). + **The column has a floor** (`InputColumn.MinimumColumns`, 54: pad 3+3, a 22-cell prompt, 26 to type): the walk never goes + into a side-by-side child narrower than that (measured with `LayoutSolver.Distribute` over `PaneAreaColumns()`), and the + split is the answer — the widest node on the way that fits, else the root. Half the default 120-column grid keeps its + column; the wave-25 three-pane drop (29 each) gives the bars the left half. It reads widths, never focus; `SyncInputColumn` + (on `OnResize`) rebuilds when a resize crosses it. A rebuild takes the + bars out of the old grid before it is disposed. `InputBand` carries `Left`/`Columns`; the view draws the strip, the + field, the row shift and the press mapping only in those columns, and carries the panes and dividers beside it down + through the `FieldReserve` pixels. NAWS is the arranged rectangles. The terminal keeps the full-width band (`None`), + so its frames and the parity harness (which tells both hosts the same chrome) are unchanged. + - **At rest the footer draws the row's compact form** (wave 22; `StatusOverlay.Compact`/`Shown`, `CompactStatus`): left the + identity segment (the cell row's instance), `UTF-8` (`CompactEncoding`) and `Log off`/`Log html` (`HeaderLogFormat`, + `StatusRole.Log`); right scrolled back (not in a split, whose native band says it), `● REC`, `♪ muted`, then one hint — + the mode's keys (not `SplitScrollback`) else `2 new in Chat` (`UnreadHint`, `StatusRole.Activity`, the active + character's windows by unread). A notice or a move/drag prompt sets it null and the footer draws `Model` whole. The cells + keep the full row (meters, room, count, focus keys): the window draws those as panels, tabs and its search box. `Model` + is still what `Drawing` recorded; parity probes compare it, `TheFootersCompactRowIsTheRowsFactsInTheMockupsWords` pins + the compact one. **The native header draws no counts** (`1/3 characters · LOG off`): the brand, the sunk search box + (`NativeChromeView.Sunk`) and the character pill, as the mockups; `HeaderModel` still carries them for the cell ribbon. +- **The native map is the mockup's** (wave 22, `MapCanvas`). Rooms are the pane's plane a step lighter (`Mix(plane, text, + 0.07)`) with a rule round them (`RuleColour`, 0.24), half a step wide; exits are that rule; the character's room is the + accent at 20% with a 2 px accent ring and `@` (`YouGlyph`) in mono when the game gives no symbol. The area colour is the + heading's mark, the legend and the spurs only. The look buttons are `ControlHeightSmall` with `RadiusControl`. The + legend is a wrapped strip at the foot above `MapViewModel.Hint` (`WalkHint`/`EditHint`, Client, null while walking or with + one room), and the fit reserves both and the buttons (`TopReserve`/`BottomReserve`), so nothing covers a fitted room + (`TheFittedRoomsAreClearOfTheButtonsTheLegendAndTheHint`). The current-room line under the heading is gone (its name is + under its box). The cell map is unchanged. +- **The window draws pane dividers as the panes meeting at a hairline** (`IPointerPresenter.Dividers`, + `PaneDividerOverlay`, `DividersNow`; `ScreenFrame.Dividers`; `TerminalView.FrameDraw.DrawDividers`). The client publishes + each divider control's cells and whether it is lit (never a tab strip standing in for one, nothing under a modal) on + `PostBufferPaint`, `Flush` keeps them with the frame, and the window paints each half of the cell in the plane beside it + (read off the frame) with `NativeTokens.Rule` 1 px in the middle; lit, the cells' own `RuleHover`, 3 px. The cells, the + hit area and NAWS are the terminal's. + - `HostParityTests` draws every view under `none`, `header-rail` and `desktop`. + - **Rules meet the hairline** (wave 35, `SeamRules`/`IEdgeRules`; `ux-wave35/divider-seams/`). A native view covers only its + pane, so its full-width rules (strip foot, scroll band, panel headings, strip seams, collapsed bar) stopped half a divider + cell short; each view says its rules (`EdgeRules(size)`) and an unhittable `SeamRules` layer draws the half cell to + `TerminalView.VerticalHairlines()`. `DrawDividers` runs a horizontal divider and the field strip's top rule on to a vertical + hairline the same way. Presses there are the cells' (grab area unchanged); no cell or NAWS moves. `DividerSeamTests`. +- **The window bundles its faces** (`BundledFonts`, Avalonia; `Fonts/README.md`). Inter 4.1 (four static weights, 1.67 MB) + and JetBrains Mono are registered as `fonts:SharpMUTerm`, Inter is Avalonia's default family and the bare names map onto + the bundle, so every view resolves them from the file on every OS; installed faces are only the fallback for glyphs the + bundle lacks (emoji, CJK, neither bundled). One token each: `NativeTokens.UiFont`/`MonoFont`/`CellFace`. The cells default + to the bundled JetBrains Mono at 10 pt unless `--font` names an installed family; `--line-height` (default 1.12) adds + leading by making the cell taller and centring the glyph, so a row is still one cell. No F7 face picker: the cell face is + a launch option of the window, read before the client exists, and not a declared setting. **The eight faces are made + once per process** and every `BundledFonts` adds the same `GlyphTypeface`s, re-implementing `IDisposable` as a no-op: + the headless suite sets up an app (and a font manager that disposes its collections) per test, and remaking and + disposing the faces per test is what made a test now and then fail to find Inter. +- **A pane narrower than its server's wrap is told about join wraps, once per world per run** (`SuggestJoinWraps`, + `SharpMUTermApp.JoinWraps.cs`; `WorldSession.ServerWrapColumn`, now learnt whether or not the world joins). Checked on + every pane-size report; the notice's action is `world:join-wraps:`. **Suggested, never on by default**: a false + join is invisible and worse than a missed one, and whether a world's output is prose is the player's call. +- **A strip that cannot draw every tab shows `+N ▾` at its right edge** (`SharpMUTermApp.TabOverflow.cs`; + `SurfaceCatalog.TabOverflow`, a `MenuModel`). Hidden tabs are read off `FrameworkHitTests.TabAt` over the strip just + painted, and the chip is painted after the frame over the strip's own last (clipped) cells, so no cell, pane or NAWS + moves. A click opens a native menu in the window (`IPointerPresenter.ShowMenu(cell, MenuModel, dispatch)`) and a cell + list (`_sendMenu`, digits) in a terminal; each entry is `win:` through `RunTabOverflowEntry`, which refuses what + the list did not offer. The keyboard's way to a hidden tab is ⌃P *Go to …* or ⌃N. **A press on the chip is taken + away from the framework** (`TakesTabOverflowPress`, in `OnDriverMouseEvent` on the input thread): the chip can sit + over the tail of a tab the framework drew, name or `×`, and the framework (subscribed later, inside `Run()`) would + select or close it on the same click. The button flags are struck from the shared list, leaving a movement; the chip + rects are a snapshot array published per frame. Both hosts go through that handler (`AClickOnTheChipDoesNotAlso…`). +- **A post is a command field and a text, in one model** (`ComposerModel.Verb`, `HasCommandField`, `HeadingVerb`; + `ComposeMessage.Build(command, body, escaping)`/`Join`/`CleanCommand`, Core; Tui `ComposerCommandRow`, a one-row + editor F1 and `DockedComposer` place above the text; `ComposeResult.Verb`; `ComposerSession.EditedCommand`; Avalonia + `ComposerView`'s command box). The body is built exactly as before and the command is put in front **unescaped**, one + space between (none after `=` or a lone punctuation shorthand); a blank command is the old single buffer, so a text + starting with its verb still sends as it did. ⇥/⇧⇥ move between the fields and ⏎ in the command moves to the text + (`DockedComposer.InCommand`/`Focused`, which the focus pin and `HandleComposerKey` follow; where the caret is is never + in the model). The command survives a send (`ForgetComposeText`), so a verb-only draft is kept and the quit prompt + does not count it. An edit has no command field. The window's docked panel is the mockup's: Command over its box, + Text over the well (both shown from 232 px), summary without the chars (in its tooltip). `SurfaceParityTests` sends + the same post from both hosts' two fields. +- **The composer says how long the post is as sent, and ⌥P shows it** (`ComposerModel.Characters`/`SentLength`, the + escaped and `%r`-joined command; a MOO edit counts its text). F1 and the docked panel carry the `N chars` cell in both + hosts; ⌥P toggles an *as sent* band under F1 (`ComposeOverlay.Preview`, run-wide, shared with the native editor via + `ComposerSession.TogglePreview`; not for a MOO edit). The `compose-preview` view. - **Every surface that names a key is read once per host** (`EverySurfaceNamesThisHostsKeys`): ⌃P and `--help` must name the host's newline and none of the chords the host keeps (`HostKeys.Reserved`, which `TerminalView` acts on, so the window's chords and the list are one). The help page is one writer @@ -901,9 +2033,20 @@ turned into the window by `AppRun` itself rather than by the hand-off, because a program it started exits and the hand-off exits at once. The installer is Inno Setup, per-user by default, with an optional PATH entry so `sharpmuterm` runs from any terminal. -**Not covered yet**: F4's verdicts (`MacroKeys.Verdict`) describe the terminal only. In the window ⌃digit -does arrive (F4 says it cannot), and ⌥⏎ never reaches a macro. Making it per host means threading `HostKeys` -through `MacroKeys`, `ScreenField` and the keypad renderer. +**F4 and the quick bar read the host's keys** (`MacroKeys.Verdict(HostKeys, …)`, threaded through `ScreenField`, +`KeyClashes`, the keypad renderer and `QuickBarRenderer`). `HostKeys.ReportsChordsWhole` says whether the host +delivers a modified key as itself (the window: ⌃digit, ⌃⇧letter, ⌃Tab and ⌥O fire) or as the terminal's bytes; +a chord in `HostKeys.Reserved` or the host's newline is *Taken* with the host's own reason (the window's ⌥⏎ +maximises). `SurfaceCellTests.F4TellsTheTerminalsTruth` and `SurfaceParityTests.F4TellsTheWindowsTruth` pin each. +`HostKeys.DeliversNumpad` (true in the window, whose `KeyMapper` maps `NumPad0–9`) makes a `Num…` binding fire there; +the terminal still says *no numpad key ever arrives*. F4 and the quick bar print keys through `KeyGlyphs.Normalize`. + +**A character is named one way on every surface: `Corvid · Aetherfall`** (Client `CharacterNames`; Tui +`DisplayName(sessionKey)`, open sessions first, then the configuration, so a dotted world still splits). The quit +prompt, ⌃P's *acting on*, ⌥M, F10, refusals and notices read it; the dotted session key is an address and is never +shown. The prompt (`Corvid · Aetherfall ›`, `StatusFormatter.NameSeparator`, which `CharacterNames.Separator` reads), +the window title (`Corvid · Aetherfall — SharpMUTerm`) and a tab's owner prefix (`Corvid · Chat`, +`CharacterNames.Window`) use the same spelling; there is no `@` or ` - ` left (`WindowTitleTests`). ## Building and testing @@ -915,8 +2058,8 @@ through `MacroKeys`, `ScreenField` and the keypad renderer. ```bash dotnet run -c Release --project tests/SharpMUTerm.Core.Tests ` over the configuration property. The screen draws it, `/` finds it, and + `● changed` and `r reset` work on it with nothing else written. Things not to relitigate: + - **A default is never written in a declaration.** It is what the binding reads off a fresh + `AppConfiguration`, so the configuration types stay the one place a default is decided; a list default is + re-read per reset, so two configurations never share one. + - **`r` resets, and only where it would do something.** A row carries a reset only while it differs from + its default (`ScreenRow.Reset`), so the header names `r reset` exactly there. It goes through + `ScreenEdits.Apply`, the save funnel. On the meter table `r` resets the whole list (the field is the list). + - **`/` is the overlay's, not a screen's**, and only while the screen is navigating — inside a field it is a + character. The search is drawn in the same window in place of the screen (content-swapping, the + `_behind` reason); Esc puts the screen back untouched, ⏎ goes through `SettingsOverlay.Show`, which swaps + the screen in place. It matches label, group, help and keywords, and a section's title only when none of + its fields matched, so an undeclared screen is still found by name. + - **The meter table is F7's one non-row field.** A meter is a row of three fields (label, value path, + maximum path); `now` reads `SendTarget()`'s game data (never `_active`'s); Delete on a meter row is a hidden + trailing Remove button added only while the cursor is on one; the received-data box shows only while the + cursor is in the table; path fields offer the received paths with their values as `ChoiceNotes`. + - F7 and F8 are windowed to the screen height (`ScreenChrome.Window`), because F7 is taller than 24 rows. F5 too: + the window centres on the *caret* (an open edit or a key capture) before the cursor band, because on F5 the band + stays on the WORLDS row while a world field is typed in (`WorldFieldScrollTests`, 120×32 and 80×24; + `--view settings-edit` edits the deepest field). + - **F5's world fields and F9's character fields are declared, with scoped bindings** (`BuiltInSettings.World` + and `.Character`; `SettingScope`, `SettingTarget`, `SettingBinding.ForWorld`/`ForCharacter`; + `WorldFieldValues`, `TextRules`; Tui `DeclaredFields`). A binding reads a `SettingTarget` (the configuration + plus a world and a character, implicitly just the configuration), and a section's `Scope` says which it needs. + A target that does not reach the scope reads as default, shows `per world`/`per character`, and resets nothing. + Defaults come off a fresh `WorldDefinition`/`CharacterDefinition`, the rule above. Identity fields (name, host, + port, character name, password) are declared not resettable. **The cell F5 is not drawn by `FromSection`**: its + world row, security checkboxes and character row are built from the declarations through `DeclaredFields`, in + the order `WorldFieldOrder`/`SecurityFieldOrder`/`CharacterFieldOrder` name, because ⇥ order and the field + ordinals tests navigate by are the cell screen's, while the declaration's order is the window's grouping. A + `TextRule` (Required, Name, Pattern, Secret, Defaulted) is the one rule both the cell field and + `SettingsForm.SetText` apply. The lists (characters, trigger sets, adding and deleting) stay the cell screen's. + `DeclaredWorldSettingsTests` holds the two surfaces' field lists together. + - **The theme is F7's first field, a `ThemeField`** (`ThemeChoices`, `ThemePreview`/`ThemeCard`, Client; + `ThemePreviews`, `SharpMUTermApp.Theme.cs`, Tui; `ThemeCardView`, Avalonia). One Client model per theme: three + panes (ANSI, focused with a tab strip, tinted) and the armed command line, every colour resolved by + `ThemePreviews.Preview` the way the workspace paints it and held to the floor. The cell picker draws a row per + theme (an `Apply` button: `ScreenButtonKind.Apply`, `[⏎] Use`) and a seven-row preview of the theme under the + cursor; the window draws the same cards natively. **Moving the cursor previews by painting the screen in that + theme** (`OptionsScreen.Preview` → `SchemeFor`), so Esc closing with nothing committed is the revert; ⏎ commits + through `ScreenEdits.Apply`. F7 opens with the cursor on the theme in force. **A theme change is noticed in + `SaveConfiguration`** (`ApplyThemeIfChanged`, by colour signature) and `ApplyTheme` re-derives the chrome, the + formatters, every overlay's `Scheme` and the panes' planes. **Output already in a pane is repainted too** + (`RestylePaneLines`, the `theme-switched` view): a `PaneLine` keeps the `StyledLine` it was formatted from + (`Source`) and a chrome bar the function that draws it (`Bar`), and the switch formats each again against the new + reading plane *in place*, so no index moves — freeze point, away/missed/search bars, picture anchors (a reserved + picture's pieces through `PictureSlots.Cuts`/`Restyle`) — and the selection is kept (`keepSelection`), since only + colours changed. Rows nothing holds a source for (a picture's own rows, the restore replay) keep their markup. + Measured at the 2,000-line pane cap: ~106 ms for the switch, ~743 KiB of `StyledLine`s for a window whose lines + are not shared with the session's ring (a spawn window; a session window shares the ring's instances). + Space on a theme row does what ⏎ does (`SettingsSession.Toggle` on an `Apply` button). A customised theme replaced by the picker is kept in `AppConfiguration.CustomThemes` (no schema + bump), and listed. Unstyled text on a settings screen takes the scheme's ink (`ScreenChrome.Panel`/`Band` + set `ForegroundColor`), not the window's, because the window is still the theme in force while one is previewed. + - **F2, F3, F4 and F6 are list sections** (`SettingsCollection`/`SettingsCollection`, `SettingsContext`, + `SettingsItemGroup`, Client; `BuiltInSettings.Triggers`/`Aliases`/`Keypad`/`Timers` in `BuiltInLists.cs`). A + list section has `Scope = SettingScope.Item` and a `Collection`: the noun, the item fields (ordinary descriptors + whose bindings are `SettingBinding.ForItem`, reading `SettingTarget.Item`, defaults off a fresh item), and how + to list, make, copy, rename, add, delete, move within a set (`Move`) and move to another set (`MoveTo`, a + `ChoiceField` with `MovesItem`). Every list operation **replaces** the set's list rather than editing it, the + reads-cross-threads rule below. New kinds for item fields: `DecimalField` (timer interval), `HighlightField` + (`HighlightColours`, the palette F2's picker offers), `FlagsField` (`FlagWords`), `KeyField` (a chord), and + `TextRule` gains `RequiredPattern`, `TriggerPattern` (checked against the rule's own match kind), `Template`, + `Lines` and `Window`. `TextField.Suggest` gives the route its windows and the script its callbacks. Decisions: + - **A package's set is listed, read-only, and never written.** `SettingsForm.Groups` lists the player's sets, + then each installed package's (`set · Package`), and every write to one of those items is refused + (`ReadOnlyRefusal`); `CopyToEdit` copies the set into the player's own through the commit, F10's `c`. + - **What a list needs from the host is a `SettingsContext`** built on the client's thread (`ListContext`): the + route targets, the panes' recent lines (`TriggerTestLines`, so `SettingsForm.Preview` is the cell live test's + `TriggerPreview` over the same corpus), and the host's own answer to which chords a binding can have + (`MacroKeys.Verdict(Host.Keys, …)`, `MacroKeys.Bindable(Host.Keys)`). F4's key field is refused in the window + for what the *window* cannot deliver; `SetKey` also refuses a chord another binding holds. + - **The cell screens build their rows from the declarations** (`DeclaredFields.Item`, which maps each + descriptor to the cell field it always was), in the order `RowFieldOrder`/`EditorFieldOrder` name, so the + ordinals, ⇥ order and every frame are unchanged: F2's match kind is still field 10 on the heading row. + `DeclaredListScreensTests.EachCellScreenDrawsExactlyTheDeclaredFields` holds the two lists together. + - **⌥↑/⌥↓ move an item within its set on the cell screens** (`ScreenRow.Move`, `ScreenLists.Mover`, + `SettingsSession.Reorder`), saved only when it moved, the cursor following; a `⌥↑↓ moves …` hint row sits + under `Del removes …`. That is the one cell row this added. Order is meaning for a trigger (first stop wins). + - The search searches item fields too (`SettingsSearch.Searchable`), F10's package fields included. +- **In the window, a declared section opens a native settings window, generated from the declarations** + (`ClientHost.NativeSettings` → `INativeSettings`; Client `SettingsForm`; Avalonia `SettingsWindow`, + `SettingsPresenter` on the driver; `OpenSettingsScreen`/`TryOpenNativeSettings` in `SharpMUTermApp.Settings.cs`). + The terminal, the snapshot, the tests and the parity harness pass null, so their cells do not change. Decisions + not to relitigate: + - **The frame is the window's own** (`WindowDecorations.None`: title, ✕, Esc, drag the title to move), **so is the + resize border**: `WindowChrome.WithResizeBands` lays a 5 px band along each edge that begins `BeginResizeDrag`, the + main window's rule. The size the player leaves the centred window at is reused for the run + (`SettingsPresenter.RememberedSize`, never saved); docked beside the output (trigger builder) is not remembered. + Opening or closing it calls `OpenedOrClosed` → `DesktopLayer.Sync`, so toasts hide and return at once (another OS + window paints no frame here). Under Xvfb with no window manager `BeginResizeDrag` does nothing; untried under a WM. + - **Every way in goes through `OpenSettingsScreen`**: the F-key, ⌃P's `screen:` entries and the cell search's ⏎ + (`GoToSetting`). A section with fields goes native and any open cell screen is closed first. A section with + none is a cell screen as before. In the window's list it carries `↗`, and choosing it calls + `SettingsForm.OpenScreen`, which closes the window and opens the cell screen through `GoToSetting`. + - **The window only draws; the client commits.** `SettingsForm` reads values through each field's + `SettingBinding` and sends every write through one delegate. The app's delegate runs + `ScreenEdits(SaveConfiguration).Apply` on its own thread (`OnUiThread`), so a native edit is saved and re-syncs + bars, panels and automation exactly as an F7 edit does. `Changed` is raised after the change lands, and the + window re-reads values and marks. A number is clamped to its field's bounds and a text to its length, the cell + field's rules. Reset is `binding.Reset`, the default `r` uses. + - **Reads cross threads, so lists are replaced and never edited.** The window reads the live configuration on + Avalonia's thread. A declared value is a scalar or a list, so `SetMeters` builds a new list every time. + - **The meter table reads the game data of the window focused when the form opened**, captured on the client's + thread. `GameDataStore` is locked, but the focus is not safe to read from another thread. The `now` column is + re-read each second (`OptionsScreenRenderer.NowText`). The received paths feed each path box's auto-complete + and a list below it; clicking a path fills the path box last used. + - **A scoped section (F5, F9) has a subject picker and a door.** `SettingsForm.Subjects`/`Choose` pick the world + or character its fields edit; the form opens on the focused window's (`FocusedSubject`, resolved on the client's + thread). A write captures the subject at the moment it is made. `Open the F5 screen ↗` (`OpenScreen`) is the way + to the lists, and opens the *cell* screen: `OpenSectionScreen` calls `GoToSetting(…, native: false)`, or the door + would reopen this window. With no world there are no fields, only a sentence and the door. A text the rule + refuses is put back with the reason as a tooltip. A field may carry a per-world note the cell row shows + (`SettingsForm.WorldNotes`, by field id then world, built on the client's thread; `Note` shows it only while the + field is at its default): `join wraps` carries `recommended · wraps at 78, pane is 60`, one string from + `WorldFieldValues.JoinWrapsAdvice` for both hosts. + - **Search is `SettingsSearch.Find` and nothing else**, so the two surfaces always find the same fields. While + searching, the page shows only matched fields, and if the section on show has none it moves to the first that + has some. + - **Fluent is applied to this window only** (`Styles.Add(new FluentTheme())`), and `RequestedThemeVariant` + follows the client's theme (`SettingsForm.IsDark`, read live), so a theme card clicked here flips the window's + variant and rebuilds the page. The rest of the window stays on the Simple theme the native menu and tooltips + were built against. + - **A list section is a master-detail editor** (`ListEditorView`, Avalonia; `SettingsWindow.ListEditor`): the + sets and their items on the left (an enable check box per item, `+ Add`/`Copy`/`Delete`/`↑`/`↓` over it, ⌥↑/⌥↓, + Delete and Space on the list), the item's declared fields on the right, grouped. It fills the page below the + title and scrolls in its two halves. A package's set is drawn read-only with *Copy to edit*. F2's pattern field + has the live test under it, re-run on every keystroke, each matching line with the match marked. A text field + with suggestions (F2's route) is an editable `ComboBox` listing them all, so the windows are seen without typing. The trigger + builder opens it on the rule it made (`TryOpenNativeSettings(section, item)`); with no window it opens the cell + F2 as before. **Each list keeps its selection for the run** (`SettingsForm.Selections`, the app's + `_listSelections`, by section id; never saved): switching section and back, or closing and reopening the window, + lands on the item last chosen while it still exists. An item the form was opened on wins. + - **The window takes the keyboard when it opens and gives it back when it closes** (`SettingsWindow.FocusFirst` + on `Opened` and when an open window is reused; `SettingsPresenter.Refocus` → `DesktopLayer.FocusArmedComposer` + after `Owner.Activate()`), as a cell screen does. + - **F10 is a package manager page** (`PackagePageView`, Avalonia; Client `PackageCollection`, `PackageManager`, + `PackageReviewModel`, `PackageBrowseModel`; `SharpMUTermApp.Packages.cs`). F10 is declared as a list of + installed packages (`SettingsSectionContribution.Packages`): one `BoolField` (`enabled`, whose binding replaces + `DisabledPackages`, the one thing it writes) and read-only `InfoField`s (name, version, games, by, about, sets, + scripts, media, update, characters), plus four actions (Space, `c`, Del, `b`). The cell modal + (`PackageScreen.List`) and the page draw the same fields in the same order + (`PackagePageTests.ThePageDrawsTheDeclaredFieldsTheCellScreenDraws`). Decisions: + - **Every gesture goes to the client** through `IPackageHost` (`PackageHostCalls`, marshalled with `OnUiThread`): + on/off (`SetPackageEnabled`, one `SaveConfiguration`), remove, copy sets, fetch the index, install, accept, + cancel. The page writes nothing; `PackageManager` is what the client publishes for it to read + (`PublishIndex`/`PublishReview`/`PublishStatus`, `Changed`). + - **One consent path in both hosts**: `ReviewPackage` checks the SHA-256, id and version, then builds one + `PackageReviewModel`; the cell overlay draws it with `PackageScreen.Review`, the window as a card over the page. + Both end in `AcceptPackage(model)`, which refuses a model that is not the pending one, so a stale review + cannot install. It leads with `WHAT CAN ACT AS YOU`. + - **The index is fetched only from the Browse tab's button** (or `b`/⌃P ▸ *Browse packages*, which bring the tab + forward through `BrowseRequests`), never when the page opens. The browse list is `PackageBrowseModel`, the + focused world's packages first, as on the cells. + - The `--demo-config` scene installs two fake packages and serves a fake index from memory (`DemoFetch`, real + SHA-256s); screenshots `ux-wave8/packages-window-*.png` and `packages-terminal-*.png`. + - **A settings `--view` opens the native window when the host has one** (`RenderSnapshot`: a declared screen with + no `-edit` goes through `TryOpenNativeSettings`, as do `packages`/`package-browse`/`package-review`). The parity + harness passes `NativeSettings = null`, so its cells are still compared. + - **Committed edits undo per section, for the run** (Client `SettingsHistory`; `ScreenEdits.Track`/`Undo`/`Redo`; + `SettingsForm.CommitAs`/`UndoRedo`/`HistoryNote`/`Showing`; the `settings-undo` view). `ScreenEdits` captures the + configuration as JSON before each change and records it after the save, under the section it was made on (the cell + overlay's F-key, the window's page); 50 a section, a new edit clears redo, a no-op is not recorded. An undo writes the + *before* values back only at the paths that step changed, onto the configuration as it is now (an F5 edit made since + survives an F7 undo), merged into the live objects: an unchanged list element stays its instance, a changed list is + replaced. The snapshot carries the password (an ignored scalar), never `lastSession`/`version`/`keyTourPending`. It is + saved through the funnel. A deletion undone leaves the closing review; redone, it rejoins it. F10's on/off switch is + recorded under `packages` too (`SetPackageEnabled` → `PackageEdits`, saved through `SavePackageChange`, which re-syncs + the packages; the cell F10 list takes ⌃Z/⌃Y itself, `StepPackageHistory`). Cells: ⌃Z / ⌃Y (⌃⇧Z + where reported) only while navigating, the note in the header in place of the verbs until the next key, `⌃Z undo` a + low-drop ladder key (F10's list header too, while a package is listed). Window: a tunnel handler leaves the chord to a + `TextBox` only while it has typing **not yet committed** — every change landing empties the focused box's own history + (`ForgetTyping`), so after ⏎ or with a value the page put there Ctrl+Z is the section's, and a step taken from a + focused box refreshes that box too (`Rewritable`, `ListEditorView.Refresh(focusedToo)`). The + footer reads `Undo Ctrl+Z · Redo …` (the mockup's) and shows the note on the left. + - Not done: the window is not modal. Screenshots come from a real window under Xvfb, driven with XTest + (`ux-wave4/native-settings-*.png`, `ux-wave6/settings-themes-window-*.png`, `ux-wave7/lists-*.png`). +- **In the window, ⌃P is a native palette over the window, listing the cell palette's rows** (`ClientHost.NativePalette` + → `INativePalette`; Client `PaletteSession`; Avalonia `PaletteOverlay`, `PalettePresenter` on the driver; + `SharpMUTermApp.NativePalette.cs`). The terminal, the snapshot, the tests and the parity harness pass null. Decisions + not to relitigate: + - **One funnel**: `ToggleMenu` (⌃P, the header ☰ link) and `OpenMenu` (the native header's search box and brand + through `ActivateChromeTarget`, the pointer menu's last entry *More commands…*, the `menu*` snapshot views) ask + `TryOpenNativePalette` first. ⌃P is a toggle in both. The composer refusal is `ToggleMenu`'s, so it holds for both. + `PaletteIsOpen` (either palette) feeds `AnyOverlayOpen`, `OpenOverlayName`, `MenuIsOpen` and the header's caret. + - **The session is captured on the client's thread** — catalog, recents, suggestions, `acting on`, dark or light, + an opening query — so the window filters with `CommandPaletteModel.Build` on its own thread and locks nothing. + The rows are therefore the cell palette's for the same query (`NativePaletteTests.TheRowsAreTheModelsForTheSameQuery`). + - **Running goes back through the session**: `Run` closes, then the client runs `RunFromPalette` on its own thread, + so recents are recorded exactly as from the cells. A session runs once and closes once; a session an earlier + opening left behind is ignored when it reports closing. + - **It is an overlay, not a window**: `PalettePresenter.Attach(root, refocus)` from `MainWindow`, and the overlay is + added as the root panel's *last* child when it opens, so it lies over the cells, `DesktopLayer`'s panels and the + native chrome. A scrim covers the rest; a press on it closes. Closing gives the keyboard back to `TerminalView`, + which is the client's command line by the focus pin. Fluent is applied to the card alone (`ThemeVariantScope`), + in the client's variant. + - **Keys are words** (`NativeKeys.Words`, the rule every native surface follows — see below). A sequence (`⌃B |`) + is one chip per key. + - **Hover selects on movement only** (`PointerMoved`, not `PointerEntered`), so the row that opens under a resting + pointer is not chosen. ↑↓ wrap as in the cells; PgUp/PgDn stop at the ends. + - Screenshots from a real window under Xvfb, driven with XTest (`ux-wave5/native-palette-*.png`). +- **In the window, the composer is a native editor: F1 over the cells, and the docked panel over its pane** + (`ClientHost.NativeComposer` → `INativeComposer`; Client `ComposerModel`, `ComposerMarks`, `ComposerSession`; + Avalonia `ComposerView`, `ComposerEditor`, `ComposerOverlay`, `ComposerPresenter` on the driver; + `SharpMUTermApp.NativeComposer.cs`). A `TextBox`: wrapping, undo and redo, selection, IME, paste and the platform's + caret, with a monospace face by default and a proportional one from its right-click menu (for the run). Decisions + not to relitigate: + - **One model, both hosts.** `ComposerModel` (target, draft, escaping, MOO edit, dictionary, marks as ranges, the + key row) is what the cell F1 (`ComposeOverlay.Render(model)`), the cell panel (`DockedComposer.Render(model)`) and + the native views draw. `ComposeOverlay.ModelFor` is the F1 model in both hosts; `DescribeDocked` is the panel's, + recorded as drawn (`SurfaceCatalog.Compose`, `PanelIds.Composer`). The marks are `SpellChecker.Misspellings` per + line, the call the cell highlighter makes, so both hosts mark the same words. + - **F1 goes through one funnel** (`OpenComposer`/`CloseComposer`): the key, ⌃P, a MOO edit arriving and the demo + views. With an available host editor it opens there and the cell modal is never built; `ComposerOpen` (either) + feeds every refusal the cell modal fed (`AnyOverlayOpen`, the quit facts, `MainWindowInFront`, the panes being + covered). Closing keeps the draft through `KeepComposeDraft`, exactly as the cell window's `Closed` does, so a send + still closes first and forgets after. `Available` is false until the main window attaches the presenter, so + `--render-png`, the parity harness and any test without a window get the cells. + - **Every gesture goes back through the session**, on the client's thread (`OnUiThread`): ⌃S to `SendComposed` / + `SendDocked` (a MOO edit saves its lines), ⌥L, ⌥W (the word at the caret, `ComposerMarks.WordAt`), Esc (F1 closes + keeping the draft; the panel releases to the command line), a click (the panel arms, `ArmComposer`). A session the + client has ended passes nothing on. The window never builds a command. + - **The draft is the client's; the editor keeps its own caret.** The host hands its text back as it changes; the + client's text replaces the editor's only when `ComposerModel.Revision` moved — a send clearing it, F1 closing over + the panel, a key the cells took. `DockedComposer.SetFromHost` writes without moving it. + - **The docked editor lies over its cells, which stay the draft** (`CellsUnderNative`): it rides the panel channel + (`PanelOverlay.Composer`/`ComposerSession`) and `DesktopLayer` places it like any panel. It takes the keyboard when + the model says armed and gives it to `TerminalView` when it says not. It hands the client the chords it has no + use for (`ComposerView.ReachesTheClient`: F-keys, ⌃P, ⌃O, ⌃↑/↓, ⌃←/→, ⌥J/K, ⌥digits) through the driver, as its + cells would receive them; ⌃A/C/X/V/Z/Y, word movement and typing stay the editor's. While F1 is open natively the + panels are not published, so F1 is never under a docked editor. + - **F1 is an overlay over the cell area, under the desktop layer**, so the native header and sidebar stay (inert, as + under a modal) and a toast is still seen over it. ⌥⏎ is the window's (it maximises); ⏎, ⌃⏎ and ⇧⏎ are newlines. + ⌃Q in F1 goes to the client and leaves F1 open: the native quit prompt is drawn in front of it, and Stay comes back + to the post. (A host with the native F1 but no native modals closes F1 first, keeping the draft, so the cell prompt + is not hidden — `ToggleQuit`.) + - **The misspelling is a real wavy line**, drawn by a layer over the editor (`ComposerView.MarkLayer`) from the + editor's own text layout (`TextLayout.HitTestTextRange`), so it follows wrapping and scrolling. The right-click menu + offers WeCantSpell's suggestions (`SpellChecker.Suggest`, six at most), Add to dictionary, the editor's edits and the + face. Its colour is the settings screens' warning, which the cell editor marks in (`ComposerInk`). + - **Parity**: `HostParityTests` builds both drivers with no attached presenter, so the cells it compares are the cell + composer's in both. The native side is held by `SurfaceParityTests` instead — F1's model equals the cell window's + for `compose`, `compose-literal` and `moo-edit`, the panel's equals the cells' in `panel-composer`, and ⌃S from the + native session puts the same command on a `RecordingTelnetSession` as the cell keys do, as typed and literal, one + line and several paragraphs. `NativeComposerRoutingTests` (Tui) and `NativeComposerTests` (Avalonia, headless) + drive the routing and the editor. + - **The face is the character's** (`CharacterDefinition.ComposerProportional`, false by default with no migration): + the right-click menu's choice goes `ComposerSession.ChooseFace` → `ChooseComposerFace`, which saves through + `PersistConfiguration` (a view preference; `SaveConfiguration` would re-periodise timers), and each composer reads + it live (`ComposerCallbacks.Proportional`), so F1 and the docked panel of one character agree. + - Screenshots from a real window under Xvfb, driven with XTest (`ux-wave7/composer-*.png`). `--demo-config` in the + window opens the installed dictionary read-only. Not done: no find bar, and + the window's ⌥W adds to the run's dictionary in the demo only (a live client writes `user-words.txt` as F1 does). +- **Help is one Client model drawn by both hosts** (`HelpModel`/`HelpSession`, `SlashCommands`, `HelpTopics`, Client; + `HelpSurface`, Tui; `HelpView`, Avalonia; `/help [words]`, ⌃P ▸ *Help*; surface `help`). Rows: how-to pages, then + the slash commands from **`SlashCommands.All`, the one table `--help` prints too** (`ClientUsage`), then every + registry command with a key for this host (`KeyGlyphs.Normalize`) and the host's own chords (`HostKeys`). The search + is `HelpModel.View` in both hosts (every word, in a row or in a page's text). ⏎ on a slash command **places** it on + the command line (`PlaceGrab`) and sends nothing; on a key it runs the registry command; on a how-to it opens the + page (Esc back). `--plain` says the matching rows instead. Native as an `INativeModals` session like ⌃F. Pages are + `HelpTopic.Lines` joined into `Paragraphs` and wrapped by each host. `HelpTests.EverySlashCommand…` ties the table + to the command line's handlers. +- **In the window, the quit prompt, ⌃F, ⌃R, ⌥M and the welcome are native modals** (`ClientHost.NativeModals` → + `INativeModals`; Client `Modals/`: `ModalSession`, `QuitModel`/`QuitSession`, `SearchModel`/`SearchSession`, + `HistoryModel`/`HistorySession`, `LayoutsModel`/`LayoutsSession`, `WelcomeModel`/`WelcomeSession`; Avalonia + `ModalPresenter` on the driver, `ModalOverlay`, `QuitView`, `SearchView`, `HistoryView`, `LayoutsView`, `WelcomeView`, + `ModalParts`; `SharpMUTermApp.NativeModals.cs`). Decisions not to relitigate: + - **One host member for five modals**, not five. What they share is the hard part — stacking (the quit prompt in + front of everything), giving the keyboard back, the theme — and one presenter decides it once. A session per modal + carries its model and the gestures; `Available` is false until the main window attaches it, so `--render-png`, the + parity harness and every test without a window get the cells. + - **The model is the cell modal's.** `QuitPrompt.Render(QuitModel)`, `SearchPrompt.Render(SearchModel)`, + `HistorySearchPrompt.Render(HistoryModel, …)`, `LayoutsListLines(LayoutsModel)` and `WelcomeSurface.Render(WelcomeModel)` + draw the same records the views draw. ⌃F's hits come from one function (`SearchModelFor`, which the cell surface + asks too); ⌃R's rows are `HistoryModel.View` (= `HistorySearch.Match`) in both. + - **Every gesture ends in the cell key's method**: Y → `Quit`; ⏎ on a hit → `GoToSearchHit(model, index)`; ⏎ in ⌃R → + `InsertHistoryEntry`; every ⌥M key and button → `RunLayoutsVerb`, which returns the `LayoutActionResult` both lists + show as their status; the welcome's choices → `RunWelcomeAction`, and its form → `WelcomeSurface.Submit(form)` → + `ConnectFromWelcome` → `ConnectCommand`. The views read keys through the pure prompts' `Interpret`, so a key means + the same in both hosts; ⌥M's ↑↓ stop at the ends as the cell list does. + - **The quit prompt is never refused and always ends in front.** `ModalPresenter` adds it last, moves it back to the + end when anything is added to the root after it (⌃P, F1, a toast), activates the main window over the settings + window, and opens any other modal under it. ⌃Q from inside another modal is forwarded to the client, which opens + the prompt over it; Stay returns to what was open. The client funnels are `ToggleQuit`, `ToggleSearch`, + `ToggleHistorySearch`, `ShowLayouts`, `ShowWelcome`; each closes its own native modal on its chord. + - **Fluent scoped to the card** (the palette's rule), in the client's dark or light variant (`ModalColours.Dark`/ + `Light`, the palette's colours and accent). A press on the scrim is Esc (the quit prompt: Stay). Focus goes back to + what had it when the modal opened if it is still on screen, else to the cells, whose pin puts it on the command line. + - **Parity**: `HostParityTests` has no presenter attached, so it compares the cell modals. `SurfaceParityTests` + holds the native side: the session's model equals the cell modal's for `quit`, `search`, `search-regex`, + `search-all`, `history-search` (and the filtered rows), `layouts`, `welcome` and `welcome-connect`; and quit Y/N, + search ⏎ / ⌥A / ⌥E, history ⏎, layouts apply / save as new / rename / delete, and the welcome's connect and + layout+settings end in the same `Outcome` as the cell keys. `NativeModalRoutingTests` (Tui) and `NativeModalTests` + (Avalonia, headless) drive the routing, the stacking and the views. The `search*` and `history-search` snapshot + views open the native surface when there is one (that is how the window's screenshots are taken): + `history-search-filter` opens ⌃R with its query typed (`HistorySession.InitialQuery`), and `search-landed` lands + through the native search's own close and `GoToSearchHit`, so the cells it leaves match the terminal's. + - Screenshots from a real window under Xvfb, driven with XTest (`ux-wave8/modals-*.png`). +- **The window has one design language: `NativeTokens`** (Avalonia). Planes (card, raised, selected, scrim), edge + and rule, inks (text, dim, header, accent, warn, notice, accent and key inks), radii `RadiusS/M/L` 4/8/12 and + the type ramp `Small/Body/Title/Heading` 11/13/14/18 (`Miniature` 9 only for a theme card's preview). Derived + from the client's `Theme` the `ScreenTheme` way: a canonical Dark (the mockup's: `#171b24` card) and Light (white) set, the + theme background's hue shift added, every ink held to `TextFloor` 4.5:1 on each plane it is drawn on; the + accent is the client's teal, never Fluent's blue. `Apply` sets the Fluent keys on a scope and `ApplySimple` + the Simple theme's (menus). The settings window, list editor, package page, theme cards, menus, palette and + modals all read it; sessions carry the theme (`ModalSession.Theme`, `PaletteSession.Theme`, + `SettingsForm.Theme`). `NativeTokenTests.NoViewWritesALiteralFontSizeOrColour` fails on a literal + `FontSize =` number or `Color.Parse("#` anywhere in the window's assembly outside `NativeTokens.cs`; widen its + allow-list only with a reason. A quiet line is the `Dim` ink, never an opacity (an opacity is not floor-checked). + - **The floating surfaces are the mockup's cards** (wave 19, `ux-mockups/Main.dc.html`). Dark's canon is the mockup's + palette: card `#171b24`, raised `#1d2333`, `Well` `#10131a` (the ground an input sits in), `Edge` `#2c3447`, `Focus` + `#3a4f73`, selected `#1f3a3a`, ink `#d7deec`/`#9aa4b8`, `#0f1620` on the accent. Every ⌃P and modal search box is sunk + (`ModalParts.Sunk`: the well, a rule turning to `Focus`, a magnifier); a key is a cap in the cells' mono + (`ModalParts.Chip`, `NativeTokens.KeyFace`); a selected row is the selected plane plus a `MarkWidth` accent bar on its + left (`ModalParts.Mark`); a modal's key line is a band on the card's foot in the well (`ModalParts.Footer`). Toasts are + rounded on `ShadowSmall` with the severity as an inset bar, still on the client's `NoticePalette` plane; menus take + the palette's row height, keys in the mono and the dim ink, and the popup's own window shows the card's plane in the + rounded corners where it cannot be transparent. + - **The settings window is the palette's modal**: undecorated with its own title bar (title, ✕, Esc, drag to + move), the tokens' card and edge, and a scrim over the main window (`SettingsPresenter.Scrim`; a press on it + closes). Opened from the trigger builder (`SettingsForm.BesideOutput`) it is docked to the main window's right + half with no scrim, so the line the rule came from stays in view. Still a separate OS window, not an overlay. + - **It is laid out as the Desktop-Settings mockup, on page tokens** (`NativeTokens.Page` → `PageTokens`, in + `NativeTokens.Page.cs`: ground, nav, card, rule, field, focus edge, button, popup, inks, link, search mark; the Dark + set is the mockup's hexes, Light mirrors it, other themes get the card's shift, every ink held to `TextFloor` on + every page plane; `PageTokens.Apply` lays them over `Apply` for Fluent). A 56 px header (title, search box with + glass and `N settings`, the scoped section's subject as *Applies to*, ✕), a 232 px nav (accent row for the section + in front, counts while searching, a note at its foot), a 36 px footer, and the page: a 20 px title, a line saying + what it shows (*Showing settings that match “x”. Show all N*, which clears the search; F5/F9's door is a link + there), then **one card per declared group** with its fields as rows (label in the label weight with search words + marked, `● Changed`, help under it; *Reset to default* link and the control on the right). A group whose name is its + only field's label draws no heading, nor does a one-field card while searching (the mockup's result cards; the meter + table's label then heads the card at `CardTitle`). List and package pages put both halves in cards. Still generated + from the declarations; nothing is placed by hand. Fonts are inherited (the window sets no UI family). + **Wave 28 measured it against the mockup at 1440×900** (`ux-wave28/settings-audit/`): the nav is 232 px *inside* its + padding (252 with the window's edge, so the page title sits at x 293 as the mockup's), the search box is 480 px plus + padding 16 px after the title with the mockup's 24-unit glass at 14 px, small text (count, column headings, row help, + `Changed`, reset link, nav note, footer) is `PageSmall` 12, the meter grid is inset 4 px with rows 12 px apart (label + box 32, path boxes 34, padding 8), the received-paths panel is 360 px under the value column with `x = v` and the + focused path box's alternatives marked, and a live value wears its meter's colour (low: `WorkspacePalette.Low`) held on + the card (`PageTokens.OnCard`). A list or package page is sized from the measured title, so the page never scrolls + beside the editor. Left as they are: section titles (the declared names the cells and ⌃P share), the drag handle + (meters have no reorder), the Colour column (the mockup predates meter colours), `412/500` unspaced (the cells' text), + *Open config.json* (no client action), the mark's rounded padding (a `Run` has none). +- **The window lays out on one spacing scale and on device pixels** (`NativeTokens.Spacing.cs`; wave 21B). Insets are + `Space2…Space32` (a 4 px grid plus the mockups' 6/10/14 control half-steps; nothing odd, an odd logical pixel is half a + device pixel at 150%), `PanelInset` (16: the header's brand, the sidebar's title, world names and foot button, every + panel's heading, rows and footer, the quick bar's first button), `ModalInset`, `PageInset`; heights `ControlHeight` 32, + `ControlHeightSmall` 28, `FieldHeight` 38 (the command line's field and the welcome's inputs and Connect). Decisions: + - **Chrome sizes are multiples of 4** (`NativeLayout`: header 44, sidebar 232 + 8, footer 28, the field's strip 40 + + rows), because 4 logical pixels are whole device pixels at 100/125/150/200%: the cells start on a device pixel at + every Windows scale, so a view placed from `CellOrigin` meets them with no blended row. The old 42 px header put + every rule below it on a half pixel at 125%. + - **A drawn hairline is one device pixel, filled**: `NativeTokens.HorizontalRule`/`VerticalRule`/`StrokeRect`/ + `HairlinePen` read the visual's `RenderScaling`; the header's rule, the tab strips, the scroll band, panel headings + (`PanelText.Heading(rule:)`), the search box, key caps and the quick bar's plates use them. `Border` hairlines are + already rounded by layout rounding (on by default) and need nothing. + - **Every panel heading is 44 px** (`PanelText.PanelHeadingHeight`, the docked composer's `Border` too), whatever the + cell height, so the side column's rules line up. Only the character in front wears the sidebar's selected plate; + its window in front is the accent mark. The field sits `NativeLayout.FieldSide` (20) in from the grid as far as the + prompt's padding columns allow (`FieldRect`, edges rounded to device pixels). + - `NativeTokenTests.NoViewWritesALiteralMarginPaddingOrSpacing` fails on a number in a `new Thickness(…)` or a + `Spacing =` outside the tokens (allow-list: the tokens, `TerminalView`, the footer, the toasts, with reasons); + `TheSpacingScaleAndTheChromeSizesAreWholeDevicePixels…` pins the multiples of 4. Not done: the quick bar is still one + cell row (the mockup's is a 56 px strip of 34 px buttons), and panel internals' font sizes are not on the scale. + +- **Wave 23 B (Light window, settings window).** The native header's plane is `WorkspacePalette.NativeHeaderPlane`: the + panels' plane ×1.1 on a dark theme (wave 28; it was the status band), the backdrop on a light one (Light's status band is a lavender that sat beside the neutral + rail and footer as a third grey); `HeaderMuted(theme, plane)` holds the quiet ink on it. Native only — the cell header + keeps its band. `LightCanon`'s `Edge`/`Rule` are `#c4c8d2`/`#dcdfe6`, the dark set's ~1.4:1 step mirrored, so a field on + Light's side column has an edge. The welcome's search and form fields are `ModalParts.Sunk` (Fluent's default text + background is `Raised`, the cards' own plane). Settings: a section with no match shows no count (not a greyed `0`); + page monospace is `NativeTokens.Code` (12); the received-paths panel scrolls past `MeterPathsMaxHeight`; list-editor + combos stretch like the text boxes; a disabled button keeps the button plane with a `Rule` edge. +- **Wave 28 roleplay audit (window only; terminal frames unchanged).** The window measured against `Main.png` at 1440×900 + (the `roleplay-scrolled` view: Roleplay, a drafted post, scrolled back with more arriving). The window's UI text is the + mockups' 13 px: `NativeChromeView.FontSize` and `PanelText.Size` are `Body` (they were 14), the brand `Title`, panel + labels, buttons, the footer and the scrolled-back band `NativeTokens.Caption` (12); the vitals gauges, quick bar and + collapsed bars keep 14 (`Title`), measured last wave. The sidebar is 244 + 8 (the mockups' 252), its rows span the gutter + with 10 px insets, 30 px rows, and a hairline in its last pixel is its edge. On a dark theme the sidebar is the panels' + plane (`WorkspacePalette.NativeRailPlane` = `Surface`) and the header, footer and command-line strip are that ×1.1 + (`NativeHeaderPlane`; `NativeFooter.Plane`, inks re-held to `TextFloor` there); the strip's top edge is the panes' rule. + The header's search box is 320 px with its chord as plain text after the label, the pill 26 px, the Layouts button bare + at rest. Tabs start 12 px in, at 13 px. The composer's game text is the cells' size (`TerminalView.CellTextSize`, not a + share of the row) at the row's pitch. A row's last background run reaches the canvas's right edge (`CellRenderer`), so a + window that is not a whole number of cells wide shows no strip of the theme background. Left as decided or not ours: the + side column's share (a layout property both hosts read), the focused pane's lifted plane and the history plane (the focus + cue), 20 px rows and two-column gutters (cells), the accent bar on the focused tab, `main`, `world`, `Ctrl+Enter`, + *Command palette*, the pane's leftover pixels under the command line. +- **Wave 29 combat and first-run audit (window only; terminal frames unchanged).** Measured against `Desktop-Combat` and + `Desktop-FirstRun` at 1440×900 (`ux-wave29/combat-firstrun-audit/`; the combat mockup rendered without its open menu). + **Under `HostChrome.Strips` a strip panel is not recessed** (`SurfaceToneIn`): the vitals card, quick bar and collapsed + bar are the panels' plane like the map, and a strip draws a hairline on each edge another pane meets + (`PanelOverlay.RuleAbove`/`RuleBelow`, from the whole pane rectangles in `PublishPanels`; `VitalsGauges`/`QuickBarButtons` + `.Seams`). The terminal keeps the recessed step, having no hairline. The vitals card's names are `Caption` and its numbers + the mono face at that size; the quick bar's labels and the collapsed bar's title are `Body` (wave 28's "keep 14" was not + a measurement). The welcome's layout cards centre their words, and its check boxes are 24 px rows (Fluent fixes 32). + Left as they are: the combat mockup has no sidebar or tab strip (the window keeps both, as `Main.png` draws them), panel + headings (the two mockups disagree: one 44 px heading with its context beside the title is decided), the look buttons, + room names and legend, movement in gold and low in `WorkspacePalette.Low`, the map's shades as mixes of the pane's plane + and ink, the collapsed bar running over the field's strip (`BelowGrid`), output starting at the top (cells), the footer's + bright name (the row's model), and the welcome's chips, *Package* tag, `world` noun, third *suggested* state and Fluent's + 20 px check box. +- **Wave 30 map shades and Chat bar (window only; terminal frames unchanged).** `ux-wave30/window-map-and-bars/`. The native + map's shades lean toward the tokens' focus edge (`MapCanvas.Lean`, `DesktopLayer.MapLean`: `NativeTokens.For(theme).Focus`, + `NativeTokens.LeanFor(plane)` in a floating window): a room's fill steps the pane's plane toward the edge itself + (`RoomShade × FillLean`), a rule toward the pane's ink leaned `LeanShare` (0.7) to it (`LeanedRuleShade`), so Dark lands on + `Desktop-Combat`'s `#1d2333`/`#3a4257` and Light/Solarized/tints stay steps of their own plane; with no lean the old neutral + steps. This replaces wave 29's "shades as mixes of plane and ink". The collapsed bar draws only its bottom + `CollapsedBarView.BarHeight` (38, the mockup's) as the bar — rule, words, hover, hand cursor, tooltip and the click — and + the rest of what it covers is the column's plane; `BelowGrid` now also takes the view's leftover pixels under the grid + (under a row), so a panel at the foot of a beside-the-field column meets the footer. Cells and NAWS do not move. Panel + headings were checked against `Main.png` and already match (context beside the title), so nothing changed there. +- **Wave 31 fine pixels (window only; terminal frames unchanged).** `ux-wave31/window-fine-pixels/`, against `Desktop-Combat` + and `Main.png` at 1440×900. **The window's text is lightly hinted** (`NativeTokens.ApplyTextRendering`, set on `MainWindow`, + `SettingsWindow`, `FloatingPanelWindow` and, through a class handler in `App`, every `PopupRoot`): Avalonia's default hinting + drew Inter's capitals 11 rows tall at 13 px (the mockup's are 9–10) and 1.5 px above a line box's middle, so every native + line read a size heavier and every panel heading's words sat high. The metrics are balanced (Inter's capitals centre in its + line box), so no offset is added anywhere. The cells draw their own glyphs and do not change. **Map exits are + `NativeTokens.MapExit` (2) wide** from `NamesFromStep` up, thinner zoomed out, never under a device pixel (`ExitWidth`, + `NativeTokens.LineWidth`), and a level or upright exit is moved ≤ half a device pixel onto the pixel grid (`AlignExit`, + `NativeTokens.LineCentre`), so it is two crisp pixels as the mockup's; `PenAllowance` is 1.5 for that move. The docked + composer's mode chip is the mockup's outline (`OutlinedChip`: 11 px, `RadiusControl`, the panel's rule) and its Command box + has 8 px padding (34 px tall). Left as they are: the header is 44 px with its rule, the mockup's 45 (chrome on the 4 px + grid); the quick bar's three-row strip (60 px, the mockup's 55) and the scrolled-back band's two rows (40, the mockup's 37) + are cells; the pane's leftover pixels under the field (wave 28); the divider row between the composer and who-here + (`HostChrome.Strips` keeps it); the Here heading is every panel's 44 px (the mockup's is 41 without a chip). +- **Every native surface spells a key in words** (`NativeKeys.Words`, Avalonia: `KeyGlyphs.Words` plus the named keys + `⏎` Enter, `⇥`/`↹` Tab, `⇤` Shift+Tab, `⌫` Backspace, `⌦` Delete, `⎋` Esc, `⇞`/`⇟` PgUp/PgDn; arrows stay arrows): + `Ctrl+P`, `Alt+M`, `Ctrl+B b`, never `⌃P`/`⌥M`/`⏎`. The client writes keys in glyphs because the cells draw them + well; whatever it hands a native view — menu keys, a toast's chip and text, palette details and chips, the + composer's key row, modal footers and sentences, settings help, tooltips — goes through `Words` on the way to the + screen. `NativeKeySpellingTests` reads every native surface at run time and scans the Avalonia sources for a glyph + literal outside `NativeKeys`. **The cells keep their glyphs**, the window's cell status row included: parity + compares cells, so that row says `⌥9` beside a toast saying `Alt+9`. +- **Every focusable native control is named, ringed and reached by Tab** (`NativeTokens.Focus.cs`: `FocusRing`, + `FocusRingStyle`, `NamedParts`; `NativeFocusAuditTests`). Decisions not to relitigate: + - **One ring, from the tokens**: `ApplyFocusRing(scope)` (called wherever `Apply` is: palette, modals, settings window, + composer, map inspector) gives every control in the scope `FocusAdorner` = `FocusRing`: `Focus` held to + `FocusRingFloor` 3:1 (WCAG non-text) on every plane (`FocusRingInk`), 2 px, with a card hairline inside so it reads on + the accent too, rounded as the control. Avalonia shows it only for keyboard focus. **It is drawn inside the control's + edge**: the adorner layer clips an adorner to what is visible of its control, so a ring outside was never seen. + - **Overlays cycle** (`KeyboardNavigationMode.Cycle` on `ModalOverlay`, `PaletteOverlay`, `ComposerOverlay`): Tab + never leaves for the cells behind. A list or tab strip is entered once and walked with arrows. + - **A part takes its control's name** (`NamedParts`, a `TemplateApplied` class handler: a number box's text field and + spinner, `Increase …`/`Decrease …`, an editable combo's text): the keyboard lands on the part. List rows are named + from the model (section title, item title, package name). + - **Enter and Space on a focused button are the button's** (`ModalParts.FocusedButtonTakes`, first in every modal's + tunnel key handler); a surface's own Enter (go, run, connect) applies everywhere else. Browse worlds' sort button is + no Tab stop, because Tab is its key. The quit prompt's ←→/Tab move the focus with the ring. + - **A modal re-takes the keyboard once laid out** (`ModalPresenter`, posted at `Loaded`): the welcome's first field + refused the focus before layout, so its keys went to the command line underneath. + - The views that never take the keyboard (header, sidebar, tab strips, toasts, panels) are still named for a screen + reader (`Tabs: Corvid (in front), Chat`). `NativeFocusAuditTests` opens ⌃P, F1, the docked composer, ⌃F, ⌃R, ⌥M, + help, welcome, browse, the dictionary offer, quit and F7/F2/F5/F10 in Dark and Light and fails on a focusable control + with no name (a type name counts as none) or no ring, a Tab stop real Tab presses never reach, Tab leaving the + surface, or no ring drawn. Not tried with a real screen reader. - **Settings screens have no `IPasteTarget` and cannot easily have one.** They are markup rebuilt wholesale every key, with the edited field a buffer in `SettingsSession`. `SettingsOverlay` listens at `IConsoleDriver.Paste` instead. That can't double-fire *only* because no control on @@ -1276,7 +2913,7 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] (`WindowNumberingTests.AWindowOpenedIntoAClosedOnesSlotStillTakesTheLastNumber` is the pin, and it took a four-window fixture to expose: with fewer, both orders agree). Only **placed** windows are numbered — one no pane holds is drawn `closed` and a digit for it would - name nowhere to go. + name nowhere to go — and never a layout panel (`WindowKind.Panel`), which is furniture and has no rail row. - **⌃B 1–⌃B 9 go to a pane.** It was ⌥N until windows took that; it is on the prefix because that is where the rest of the pane keymap lives, and it is kept rather than dropped because the pane numbering does not go away with the chord — move mode badges each pane with it, the move and drag @@ -1439,7 +3076,7 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] draw it in *and* the strip has keyboard focus (which it never has here: focus is pinned to the armed command line). Measured: five captures in a split pane drew two tabs and half of a third. - **The owner prefix goes first, and goes for the whole strip.** A pane full of one character's - captures repeats `Corvid - ` on every tab; it is the cheapest thing there is to lose and the most + captures repeats `Corvid · ` on every tab; it is the cheapest thing there is to lose and the most there is of it, and what it said is still said twice over — by the chip the tab is painted on, and by the `⌁` a window belonging to another character wears. Dropping it alone is often the whole fix. - **Then names share by water-filling, not in proportion.** A cap is lowered until the strip fits and @@ -1531,6 +3168,18 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] chroma distance on the darkened surface: nearest pair Teal/Jade at 7.8, against that test's floor of 6. The yellow-greens were tried and rejected — an olive measured 5.9 from `Ochre`, a lime 6.5 from `Moss` — and `TintStrength` went from 0.75 to 0.85 with the darkening, for the same chroma-is-bounded reason. + **The ten anchors were then re-picked for colour-blind readers** (`ColourVision`, Core: Machado 2009 + dichromat matrices on linear sRGB, CIEDE2000; `TintColourVisionTests`). Searched within 40° of each + name's hue for the set whose closest pair — the ten *and* the untinted plane, all three themes — is + furthest apart under protanopia, deuteranopia and tritanopia, on condition that typical vision's closest + pair got no closer and the Light planes stayed pastel. Worst pair went from 0.28 (deutan, Moss/Ember on + Dark) to 1.99 (deutan, Moss/Ochre on Solarized Dark); typical vision 3.71 → 3.96. The floors are 1.9 + (dichromat) and 3.5 (typical). **That is "side by side they differ", not "alone it is recognised"**, + and it cannot be more: ten planes at one luminance near L* 7, seen on one hue axis, top out around 2.2 + even with the names ignored. So the name is the second cue, where it costs no cell: a tinted character's + rail row and tab tooltips say `pane colour: moss` (`TintLine`), and F5's `tint` row already names it. + **Names are stored, so they were kept**; `Ochre` is now a red ochre (hue 18°) and `Ember` a red, and the + enum's doc says so. Do not add a migration for a hue change — the name is the setting. **It says nothing on a monochrome terminal, deliberately** — the cue that must survive a lost hue is focus, and the rail and the tab title still name the character in words — and it is a **truecolor** cue, which is unacceptable for focus and is why @@ -1553,6 +3202,30 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] used to be `#222226`. A light theme is left alone: darkening it makes a grey theme, and its focus step clamps at white either way. The cost is the pane-over-backdrop step, a ratio, which is fewer L* points a range lower (Dark 4.0 → 2.8); `EveryThemeGetsARaisedSurfaceOverARecessedBackdrop` now measures L*. + - **The built-in Dark theme is the mockups' navy, and its values were solved for, not picked** (`ThemeLibrary.Dark`, + wave 21C). Background `#222835`, StatusBackground `#13161e`, Border `#495573`, Prompt `#3f61a3`, so the derived planes + land on the mockup: pane `#151921`, focused `#212835`, backdrop `#10131a` (the ground), header `#13161e`, armed band + `#233049` (Prompt is whatever lean lands it there), idle band a step below, rule `#2c3446`. Three constraints bound the + choice and each is a test: the armed−idle luma step > 28 (`TheNewStepIsMoreThanTwice…`, which is why the header is so + dark), pane-over-backdrop > 2.5 L* (why the pane is not the mockup's darker `#12161f`), and the colour-vision floors + (why it is not bluer: a bluer untinted plane meets Azure under protanopia, a greyer one meets Rose under deuteranopia). + **Dark's tints are laid over the old grey's hue** (`WorkspacePalette.TintGround`, `#18181b`): the anchors were + searched against it, and a navy residual pulled Moss/Ochre and Teal/Jade under the floor; every other theme, and the + command line's hue-only tint, still uses its own plane. The settings screens and `NativeTokens` were already navy, so + their hue shift treats the Dark background as the reference (`ScreenTheme.ShiftedBackground`) and nothing moved there. + The window's field edge is its fill ×1.6 on a dark fill (`TerminalView.FieldEdge`: `#3a4f73` on `#233049`); the dark + canon's `Rule` is the mockup's `#2c3447`, which the pane hairlines read. A selected sidebar row is one row + (`SidebarModel.Plated`: the character while its own `main` is in front, else that window; `RailWindow.Own`), on a teal + plate. A strip whose tabs share one owner drops the owner prefix (`TabStripModel.OneOwner`, both hosts; a lone tab keeps it). + - **A light theme's backdrop takes a shallower step** (`LightBackdropScale` 0.95, `Backdrop` only — `Recessed`, the + chips' step, is unchanged): the dark ratio off a near-white surface put the rail and status row at mid-grey + `#b7b7b9` (L* 75), which read as disabled. Light's backdrop is `#e6e6e9` now, about 4 L* under the panes. + - **The status row and ⌃P's selected row are small text, held to 4.5:1** (`FrameContrastTests.TheStatusRowAndThe…`). + A notice's inks are held on the backdrop (`NoticeMarkup`, `WorkspacePalette.NoticeMuted`), and every bare `[dim]` + on the row is swapped for that held grey as it is painted (`StatusInk`); ⌃P's band is `Ink on Accent`, both roles, + so a light theme re-inks the pair. The native chrome's secondary ink (`NativeChromeView.SecondaryInk`) is opaque and + held to `NativeTokens.TextFloor`. `FrameContrastTests` also walks 80×24 (F2/F4/search draw one column there, through + `ScreenChrome.Panel` like every other column), and `ScreenThemeTests` checks 80-column Light settings frames. - **F7 ▸ `background transparency (%)` (0, off, by default) is a level only kitty can honour** (`TextSettings.BackgroundTransparency`; `KittyTransparentColours`, `SyncTransparentColours`). A translucent terminal draws translucent only cells on its *default* background (`SGR 49`), at the @@ -1932,8 +3605,92 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] framework is handed `TextReader.Null`, and the pump is started from the loop (`RunLive`'s `ready`), because a key raised before `Run()` has no listener and is lost. The window (`WindowAnnouncer`) uses two hidden live regions: output is polite and notices are assertive, posted to Avalonia's thread. This was built against the - API and has not been tried with NVDA or Orca. Not done: no speech engine of our own, and no per-line - automation peer on `TerminalView`. + API and has not been tried with NVDA or Orca. Not done: no speech engine of our own. + - **The window's output view has an automation peer** (`TerminalViewAutomationPeer`): a read-only `Document` named + `SharpMUTerm output: `, whose Value pattern holds the front window's newest 50 lines, oldest first. Fed by + `IAnnouncer.Reading` (a default-implemented member, so `--plain` ignores it), said on `Activate` and on every line + landing in the front window, quiet or not — read from `PaneLine.Plain`, so chrome rows are left out and it is the + text ⌃F searches. A change raises the Value property's change. Avalonia 12 has no text pattern (`ITextProvider`), + so this is a value, not a caret-navigable text range. Pinned through Avalonia's headless peers + (`OutputAutomationTests`); **not tried with NVDA, Narrator, VoiceOver or Orca.** +- **`telnet://` and `telnets://` links connect, and do nothing else** (`TelnetLink`, `ConnectEndpoint`, Core; + `SharpMUTermApp.OpenLink`; `LinkHandoff`, `ClientOptions.NarrowToLink`, Tui). A link is server text. Decisions not to + relitigate: + - **One reader with `/connect`** (`ConnectEndpoint.Parse`): a link reaches no host `/connect host port` would refuse. + Default ports are telnet's 23 and telnets' 992, not `/connect`'s 4000. Refused, not tidied: user information + (it would be a login the link chose), any path/query/fragment past a lone `/`, any control character, space, + non-ASCII, `%` or `\`, a port not 1–65535 in plain digits, an unbracketed IPv6 address, over 512 characters. + - **Matched on the endpoint, never a name.** A world already configured for the same host, port and TLS is dialled; + otherwise one is added with **no character**, so nothing logs in. An existing world logs in as its own + configuration says — that is the player's world, not the link's choice. + - **`--open-link ` drops every other argument** before anything reads them. Windows builds the handler's + command line by pasting the link into quotes, and a link holding a quote could add `--render-png` and write a file. + - **A second launch hands its link to the first** over a Unix domain socket (`$XDG_RUNTIME_DIR/sharpmuterm/links.sock`, + else beside `config.json`; `0600`), one line, acknowledged with `ok`, and exits. The receiver re-reads it through + `OpenLink(string)`. Nobody listening, or a stale socket: the launch runs its own client and listens. Only the live + entry point listens (`RunLive`); the window's `Program` and the terminal's hand off before building anything. + Verified between two `--plain` clients and two windows under Xvfb on Linux; Windows' AF_UNIX path is untried. + - **Registration is the package's, never the program's** — the `.desktop` file's `MimeType=` and an unchecked Inno + Setup task (`HKA\Software\Classes\telnet[s]`). See `docs/PACKAGING.md`. +- **Passwords go to the OS credential store, with `secrets.json` behind it** (`ICredentialStore`, `CredentialVault`, + Core; `CredentialStores` in `Hosting/`). The `passwordRef` GUID is unchanged and is the key in both places. + - **One vault per run, from the live entry points only** (`Program.LiveVault`, `ClientLaunch`): a snapshot, a test + and the demo window never make one, so no test reads or writes a real keychain — `CredentialVaultTests` uses a fake. + `ConfigurationStore.Load/Save` without a vault are `secrets.json` alone, exactly as before. + - **The vault remembers what the store holds**, so a save that changed nothing spawns no `secret-tool` (saves happen + on every committed edit). A store that does not answer (`CredentialLookup.Unavailable`: no Secret Service over ssh) + **keeps the reference through a save** — otherwise the first save of such a run would forget every password. + - Migration is at load: a `secrets.json` entry the store accepts is moved and the file rewritten without it; one it + refuses stays. A changed password the store refuses goes to the file under a **new** reference, because the store, + read first, would otherwise win with the old one. + - Windows: `CredWriteW/CredReadW/CredDeleteW`, generic credentials `SharpMUTerm/` (built, never run). Linux: + `secret-tool` with `application=sharpmuterm ref=`, password on stdin (exercised against gnome-keyring on a + private D-Bus). macOS: `security`, the write through `security -i` on stdin so the password is not on a command + line (built, never run). Each call is bounded at 30 s so an unanswered unlock prompt does not hold the start. +- **The title says who is in front** (`WindowTitle`, Client; `SharpMUTermApp.Title.cs`; `ClientHost.Title`). + `Character@World — SharpMUTerm`, worked out from the header's own `HeaderModel` whenever the header is built, so the + two cannot disagree (a command-line world that is not in the configuration names no world in either). Nothing is + written before `Run` (`StartTitle`). The terminal (`TerminalTitle`) pushes the title stack (`CSI 22;2t`), writes + OSC 2 through the `?1004` raw write, and pops it after the loop; the window sets `Window.Title`. Control + characters are stripped twice (names, then the sequence). Not a surface in `SurfaceCatalog`: nothing draws it in + cells and it hands nothing back. +- **Attention while the player is elsewhere is a host member** (`ClientHost.Attention` → `IAttention`, `Hosting/`; + Core `AttentionMatcher`, `AttentionThrottle`; `SharpMUTermApp.Attention.cs`). A trigger's `alert` flag, or on a world + with F5 ▸ `alert on` a line naming the character (whole word, not the line's first) or paging/telling/whispering + them (`WorldSession.AttentionRequested`, raised after the line is shown, never for a prompt or a gagged line). The + client asks only with F7 ▸ `alert when away` on, the host not in front, and one per 10 s. The window + (`WindowAttention`, `DesktopAlert`, which decides through an `IDesktopNative` seam tests fake) knows its focus. + Windows: `FlashWindowEx`, plus a toast only where the installer registered the AUMID `SharpMUTerm.Window` + (`HKA\Software\Classes\AppUserModelId\…`; the shortcuts carry it too; the process adopts it in `Program`), + shown by `powershell.exe -EncodedCommand` reaching WinRT (`net10.0` has no WinRT), the text base64 inside a fixed + script; built, never run on Windows. macOS: `/usr/bin/osascript`, argv; never run. Linux: `notify-send`, and on an + `XID` handle the urgency hint (`X11Urgency`: `WM_HINTS` UrgencyHint and `_NET_WM_STATE_DEMANDS_ATTENTION` via a + root client message, own Xlib connection, 64-bit only), taken back on `Activated`. Seen under Xvfb + openbox (a + page from a fake server sets both, activation clears both); Avalonia has no Wayland back end, so Wayland is + XWayland's mapping of the hint, untried. **Page alerts are on for a new world, name alerts off** + (`WorldDefinition.AlertOnPage = true`, `AlertOnName` false and still covering pages; F5 ▸ `alert on`: off / pages / + name & pages): a page is addressed to the player, a name comes up in every scene. A saved world lacking the field + gets `false` at load (`ConfigurationMigrator.AdoptPageAlerts`, every load, no schema step), so nobody's existing + world changes. A terminal cannot tell focus-out, so `TerminalAttention.Focused` is null: + kitty gets OSC 99 with `o=unfocused` (kitty filters), WezTerm/Ghostty/foot OSC 777, anything else BEL, and those last + two only after 30 s with no input (`AwayAfter`). Text is stripped of control characters and base64'd for OSC 99. + While a host that knows its focus is behind, the title leads with the count of lines that arrived (`(3) …`, + `WindowTitle.WithUnread`). Null in tests, snapshots and the parity harness. +- **A dropped connection is dialled again only where F5 ▸ `reconnect` says so** (`AutoReconnect`, Core, one per + session; `SharpMUTermApp.Reconnect.cs`). `WorldSession.LastClose` says who closed: `Player` (Disconnect, quit, + dispose) never reconnects; `Failed` (an error) does; `Server` (orderly close) does **unless a command was sent in + the 10 s before** (`LogoutWindow`) — that is a `QUIT`, and dialling back would log the player in again. Waits 5, 15, + 30, 60, 60 s, then gives up; a connection that lasted 60 s (`StableAfter`) starts the count over. A dial is + `ConnectAsync`, so auto-login applies exactly as by hand. Each try prints a line and a notice (action: Stop); the + status row reads `reconnecting in 12 s · ⌥R now` (two-digit field) once the notice has gone, ticked by a one-shot + timer re-armed each second. ⌥R dials at once, ⌥D and ⌃P ▸ *Stop reconnecting* (offered only while one waits) cancel. + The `reconnect` view fakes it with the same writers (`ReconnectLine`, `ReconnectState`). +- **Command stacking is per world and off by default** (`WorldDefinition.CommandSeparator`, F5 ▸ `command stacking`: + off, `;`, `|`, `;;`; `TypedCommands`, Core; `WorldSession.SendTypedAsync`). Off because `;` is a MUSH's own + character. Only a line typed on the command line (and the send group) is split: the composer, a grab, a script's + `world.send`, a macro and a raw send never are. `\` before the separator keeps it; pieces are trimmed and empties + dropped; `#N cmd` repeats (1–100) within a piece; a line past 100 commands, or a repeat out of range, sends nothing + and says why. Each piece goes through the alias engine; the typed line is echoed once. - **Discord presence is a host member and a per-world opt-in** (`ClientHost.Presence`, a lazy `Func?`; `GamePresence`/`DiscordIpc`, Core; F5 ▸ `discord`, off by default). The world asks for `External.Discord 1`. `WorldSession.GamePresence` folds `Info` (application id: digits only) and `Status` @@ -1943,6 +3700,30 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `DiscordIpc.Show` never blocks: a worker sends only the newest value, reads and discards each reply (bounded in size and time), and on a failed connect does not retry until the next update. The application id is the game's; this client registers none. The snapshot and every test host have `Presence = null`. +- **The trigger builder makes a plain-language rule from selected output** (`TriggerMatchType`, + `TriggerPatterns`, `TriggerSuggestion`, `TriggerPreview`, Core; `SharpMUTermApp.TriggerBuilder.cs`; ⌃P + `term:trigger-from-selection`; the `trigger-builder` view). Decisions not to relitigate: + - **Every match kind compiles to one regex** (`TriggerPatterns.ToRegex`), so the engine, captures and the + timeout are unchanged. `Regex` is the enum's first member and so the default: a rule with no `matchType` + means what it always meant, and the schema version did not move. A `*` wildcard is a capture (lazy, the + last one greedy) and matches anywhere in the line, like *contains*; `\*` is a literal asterisk. + - **The suggestion guesses three things and nothing else**: numbers, the inside of double quotes, and the + GMCP player names `CompletionIndex.Names()` holds, as whole words. A line with nothing variable, or only + variable, becomes *contains the text*, never a pattern of bare `*`. + - **The rule is made at once, then F2 opens on it with the pattern field open** (`SettingsSession.OpenField`). + It has no actions, so it changes nothing until given one. It goes in the selection window's character's + first listed set, else the first set, else a new `My triggers` set listed on that character. Package + sets are never written to. + - **The live test is `OutputSearch`'s corpus and safeguards**: every window a pane holds (focused window + last, so its lines count as newest), the newest 2,000, a 100 ms match timeout, and an invalid pattern as a + muted state. Unlike ⌃F a zero-width match counts, because the engine fires on one. It always draws four + rows so typing never moves the rows below. + - **The match kind is field 10, appended**, though it is drawn on the `match pattern` heading row: that + keeps every ordinal and costs no row in a pane that already fills a 24-row screen. Switching *to* a + regex rewrites the pattern as the regex it meant, so the rule keeps matching what it matched + (`BuiltInSettings.SetMatchType`, the one both surfaces call). + - **In the window the rule opens in the native F2** (`TryOpenNativeSettings(Section("triggers"), trigger)`), selected, + with its live test; elsewhere the cell F2 opens with the pattern being typed. - **The send group is session keys in memory** (`SendGroup`, Core; `SharpMUTermApp.SendGroup.cs`; ⌃P `group:` ids). It is never saved, because a saved group would be a standing order to send one line to several worlds. It is offered once two characters are open. Members are marked on the rail by swapping the dot for ◆/◇ in @@ -1950,8 +3731,14 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] each member's `SendUserInputAsync`, so aliases and echo apply per character, once each. A disconnected member gets nothing and is named in the notice. A closed session leaves the group. `world.sendTo(name, cmd)` resolves through `SendGroup.Find`: an exact `World.Character` key, else a unique character name. It refuses an - ambiguous or disconnected name and sends raw, like `world.send`. Not done: no ⌃P chord, and no rail gesture - to toggle membership. + ambiguous or disconnected name and sends raw, like `world.send`. ⌃P ▸ *Send to group* is ⌥S (`BuiltInCommands.SendGroupKey`, + in `MacroKeys.AppShortcuts`); `g` on a row while ⌥C holds the sidebar toggles that row's character and keeps the keys + there (`ToggleGroupAtRailCursor`). +- **⌃P ▸ *Recent alerts* lists every character's pages and alerts this run** (`SharpMUTermApp.RecentAlerts.cs`; kept from + `AttentionRequested` whether or not the host was asked, 20, newest first, in memory, never saved). The entry opens the + palette on `alert ` (`AlertQuery`), the only time the `alert:go:` family lists anything (`CommandContext.Alerts`), and + an entry goes through `CommandIds.Character`, the id a ⌃P *Switch to …* runs. An id no longer kept refuses. The + `menu-alerts` view. - **NEW-ENVIRON is answered as MNES** (`TelnetSessionOptions.MnesIdentity`; the library's `NewEnvironProtocol`). The variables are `CLIENT_NAME`, `CLIENT_VERSION`, `TERMINAL_TYPE` and `MTTS`, read from the same TTYPE list, so the two channels cannot disagree (`MTTS 2845`, which now includes MNES 512). It answers a server's `DO` and @@ -1964,6 +3751,42 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] so a late offer cannot log in twice. The on-connect commands go after whichever login was sent. The password goes only to `SendGmcpAsync`; `Char.Login.Result` is printed, bounded to one line. Not done: a server that never negotiates GMCP still costs the five seconds, because `TelnetSession` raises nothing on a refusal. +- **The script console is one per character, and a line runs on the UI thread** (`ScriptHost.EvaluateConsole`, + Scripting; `ScriptConsoleModel`/`ScriptConsoleLog`/`ScriptConsoleSession`, Client; `ScriptConsoleSurface`, + `SharpMUTermApp.ScriptConsole.cs`, Tui; `ScriptConsoleView`, Avalonia; ⌃P `world:script-console`; the `script-console` + view). A line is tried as `return ` first, then as a statement, in a coroutine under the host's `_gate` with an + instruction budget (2,000,000) and a time budget (500 ms), so a loop typed there cannot hang the screen; a Lua function + called *inside* a library call cannot be preempted. Files load under their own chunk name, so an error says + `main.lua:(3,…)`. Lines and history are in memory for the run (`SessionScripts.Printed`, `ReportScript`), never saved. + The prompt exists only with a scripts root whose folder exists and a window with a character; otherwise the model's + `Unavailable` says which. Every gesture ends in `RunConsoleLine`/`DeleteStoreKey`/`ReloadConsoleScripts` (surface + `script-console`, parity-probed). The demo scene has its own sandbox (`_demoScripts`), so the snapshot's prompt runs. + - **An error row naming `file.lua:(N,…)` is a link to that line** (`ScriptConsoleModel.ErrorAt`, `ScriptErrors.At`): in + cells `[link=mux:script-error:N]`, N an index into the lines drawn, answered by the console's own `ListOverlay` link + handler (`Open(link:)`), never the panes'; in the window an underlined row (`ScriptConsoleSession.OpenError`). Both end + in `OpenConsoleError`: the file is matched against `ScriptFileRefs()`, the console closes, and `OpenScriptAt` (⌃P's + `Script …` path) opens it with the caret and `ErrorLine` on N; Esc returns to the console. The demo's error is one + constant, `DemoScriptError`, in the console and the `script-edit` header. + - **The cell console is sized to what it holds when it opens** (`ScriptConsoleSurface.RowsFor`, at least `MinBodyRows` + of log), so its log is not drawn under blank padding; then the size is fixed and the log scrolls, newest above the prompt. +- **GMCP channels go to a window per channel through `SpawnLine`** (`ChannelCapture`, Core; + `WorldDefinition.CaptureChannels`, F5 ▸ `channels`: off/copy/move, off by default). `Comm.Channel.Text`'s `channel` + names the window (letters, digits, space, `-`, `_`, 24 at most); its `text` is parsed through `AnsiParser`. `move` + gags the main-window echo by exact text, only when it arrives within 3 s of the GMCP (32 pending at most). An echo + that arrives *before* its GMCP is caught when both are in one socket read: in `move` a line no pending GMCP expects + is held (`WorldSession.EchoHold`, ≤250 ms, under `_ingestGate`) until the read ends (`ITelnetSession.ReceiveBatchEnded`, + raised after each `InterpretByteArrayAsync`), a channel message, a prompt, a parser swap or a disconnect, then + released in order, so nothing is reordered. A GMCP in a *later* read than its echo still leaves the copy in main. +- **Highlighted names are an ingest-time restyle** (`NameHighlights`, Core; `WorldDefinition.HighlightNames`, F5 ▸ + `names`, `TextRule.NameHighlights`). `Name=colour` from `HighlightColours`, gold by default, 64 names; one whole-word, + case-insensitive regex with a 50 ms timeout, applied before emoji and links and to captured channel lines. Only the + foreground is set, so the formatter's floor still applies. Like `join wraps`, it changes the next line, never history. +- **A scene log is the focused window's lines into a file of its own** (`SceneLog`/`ScenePoses`, Core; + `SharpMUTermApp.SceneLog.cs`; ⌃P `term:scene-on`/`term:scene-off`; F7 ▸ Scene log: HTML or plain, poses or every line). + Under `/scenes/`, `scene--`, never overwriting, through `HtmlLogSink`/`PlainTextLogSink`. Fed at + `OnLine`/`OnSpawnLine`, the restore log's seam, so chrome is never recorded. A pose is a line that starts with a + present name or has the say-shape; present is the room roster plus the character, named in the header. **No log root, + no scene log**: refused out loud, the `logRoot` gate. - **Lua's `store.*` is one JSON file per character** (`ScriptStore`, Scripting; `script-data/.json` beside `config.json`, `0600`). The `scriptStoreRoot:` constructor parameter is null by default, the `save:`/`logRoot:` family, but the host's store never is: with no folder it is memory-only by construction, the @@ -2082,9 +3905,10 @@ Planned solution layout: |---|---| | `SharpMUTerm.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) | | `SharpMUTerm.Graphics` | Kitty/Sixel encoders, capability probe, half-block fallback, `InlineImagePolicy` (no UI deps) | +| `SharpMUTerm.Client` | The shared client shell: the contribution registry and (later) view-models both hosts read. References Core only — no SharpConsoleUI, no Avalonia | | `SharpMUTerm.Scripting` | MoonSharp host + scripting API | | `SharpMUTerm.Tui` | SharpConsoleUI application | -| `*.Tests` (Core, Graphics, Scripting, Web, Tui) | TUnit | +| `*.Tests` (Core, Client, Graphics, Scripting, Web, Tui, Avalonia) | TUnit | ## Milestone M1 — first task (delivered) @@ -2099,7 +3923,7 @@ Kept for context; **M1 is done** (see *Repository state* above). As originally s ## Verification -- Primary signal: `dotnet build SharpMUTerm.slnx` plus all six suites (see *Building and testing*). +- Primary signal: `dotnet build SharpMUTerm.slnx` plus all seven suites (see *Building and testing*). Keep coverage in `SharpMUTerm.Core.Tests` — ANSI/SGR parser, telnet round-trips, engines. - **The TUI is verifiable headlessly** via the snapshot pipeline above; a claim about layout or chrome should be backed by a rendered frame you actually looked at, not by reading the markup. diff --git a/Directory.Packages.props b/Directory.Packages.props index 33fb2cc3..aaef7849 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,6 +69,7 @@ + @@ -87,6 +88,8 @@ + + diff --git a/SharpMUTerm.slnx b/SharpMUTerm.slnx index c303b035..4bb81efd 100644 --- a/SharpMUTerm.slnx +++ b/SharpMUTerm.slnx @@ -2,6 +2,7 @@ + @@ -10,6 +11,7 @@ + diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 627c2166..c4e55c55 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -56,6 +56,47 @@ dotnet publish src/SharpMUTerm.Avalonia -p:PublishProfile=linux-x64 -o out/linux Both use `packaging/icons/sharpmuterm.png` / `.ico`; the `.ico` is also each executable's Windows icon. +### `telnet://` links + +Both packages can make `telnet://` and `telnets://` links open the window. The program never registers itself; +the package does, and only where the player agreed to it: + +- **Linux** — `sharpmuterm.desktop` declares `MimeType=x-scheme-handler/telnet;x-scheme-handler/telnets;` and runs + `sharpmuterm-window --open-link %U`. It takes effect once the desktop integrates the AppImage (AppImageLauncher, + appimaged), or by hand: copy the desktop file to `~/.local/share/applications/`, point its `Exec=` at the AppImage, + and run `xdg-mime default sharpmuterm.desktop x-scheme-handler/telnet x-scheme-handler/telnets`. +- **Windows** — the installer's *Open telnet:// and telnets:// links in SharpMUTerm* task (unchecked by default) + writes `Software\Classes\telnet` and `…\telnets` under `HKA` (the current user in the default per-user install) + with `"…\sharpmuterm-window.exe" --open-link "%1"`. Uninstalling removes both keys. + +`--open-link` is why the handler can be trusted with a link: with it on the command line, the client reads the one +argument after it and drops every other (`ClientOptions.NarrowToLink`), so a link holding a quote cannot add +arguments of its own. The link itself is read as server text (`TelnetLink`): host, port and TLS only — no user +name or password, no path, no command. A running client is handed the link over a socket in +`$XDG_RUNTIME_DIR/sharpmuterm/` (or beside `config.json`), and the new launch exits. + +### Alerts and the AppUserModelID + +The window takes the AppUserModelID `SharpMUTerm.Window` at start (`SetCurrentProcessExplicitAppUserModelID`), and +the installer gives its Start menu and desktop shortcuts the same id and writes +`Software\Classes\AppUserModelId\SharpMUTerm.Window` (`DisplayName`, and `IconUri` pointing at the installed +`sharpmuterm.png`) under `HKA`, removed on uninstall. That key is what Windows needs before it shows a toast under the +id, and its presence is how the window knows it may: an alert then shows a toast (through Windows PowerShell 5.1's +WinRT `ToastNotificationManager`, the text base64-encoded inside the script) as well as flashing the taskbar. A copy +run from the `.zip` has no key and only flashes. The program never writes the key itself. **Built, never run on +Windows**: neither the toast nor the shortcut's id has been seen on a desktop. + +Linux needs nothing from the package: `notify-send` and, on X11, the window's urgency hint. macOS uses +`/usr/bin/osascript`, which files the notification under Script Editor and asks once whether to allow it. + +### Passwords + +Character passwords go to the system's credential store when there is one that answers: Windows Credential Manager +(built in), the Secret Service through libsecret's `secret-tool` on Linux (`libsecret-tools` on Debian and Ubuntu, +`libsecret` on Fedora and Arch — recommend it, do not require it), and the login Keychain through `security` on +macOS. Without one, or for a password it refuses, they stay in `secrets.json` beside `config.json`, as before. +Existing `secrets.json` entries move into the store the first time the client starts with one. + ## CI / releases `.github/workflows/release.yml` runs on `v*` tags (and manual dispatch). For `linux-x64` and `win-x64` it diff --git a/packaging/linux/sharpmuterm.desktop b/packaging/linux/sharpmuterm.desktop index 3893a62e..5aa262f7 100644 --- a/packaging/linux/sharpmuterm.desktop +++ b/packaging/linux/sharpmuterm.desktop @@ -3,9 +3,13 @@ Type=Application Name=SharpMUTerm GenericName=MU* Client Comment=A MUSH, MUCK and MUD client -Exec=sharpmuterm-window %U +Exec=sharpmuterm-window --open-link %U Icon=sharpmuterm Terminal=false Categories=Game;RolePlaying; Keywords=MUD;MUSH;MUCK;MU*;telnet; StartupWMClass=sharpmuterm-window +# telnet:// and telnets:// links open the client. Registered only when the desktop integrates the AppImage +# (AppImageLauncher, appimaged, or `xdg-mime default sharpmuterm.desktop x-scheme-handler/telnet`); the program never +# registers itself. +MimeType=x-scheme-handler/telnet;x-scheme-handler/telnets; diff --git a/packaging/windows/sharpmuterm.iss b/packaging/windows/sharpmuterm.iss index a29c3d75..82aabf82 100644 --- a/packaging/windows/sharpmuterm.iss +++ b/packaging/windows/sharpmuterm.iss @@ -45,15 +45,37 @@ WizardStyle=modern [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "addtopath"; Description: "Add the install folder to PATH, so sharpmuterm runs from any terminal"; GroupDescription: "Terminal:" +Name: "telnetlinks"; Description: "Open telnet:// and telnets:// links in SharpMUTerm"; GroupDescription: "Links:"; Flags: unchecked [Files] Source: "{#PublishDir}\sharpmuterm-window.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PublishDir}\sharpmuterm.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\..\LICENSE"; DestDir: "{app}"; DestName: "LICENSE.txt"; Flags: ignoreversion +Source: "..\icons\sharpmuterm.png"; DestDir: "{app}"; Flags: ignoreversion +; AppUserModelID: the window takes the same id at start (WindowsAppId in src/SharpMUTerm.Avalonia/DesktopAlert.cs), so +; the taskbar groups it with this shortcut and an alert's toast is shown under it. [Icons] -Name: "{autoprograms}\SharpMUTerm"; Filename: "{app}\sharpmuterm-window.exe" -Name: "{autodesktop}\SharpMUTerm"; Filename: "{app}\sharpmuterm-window.exe"; Tasks: desktopicon +Name: "{autoprograms}\SharpMUTerm"; Filename: "{app}\sharpmuterm-window.exe"; AppUserModelID: "SharpMUTerm.Window" +Name: "{autodesktop}\SharpMUTerm"; Filename: "{app}\sharpmuterm-window.exe"; AppUserModelID: "SharpMUTerm.Window"; Tasks: desktopicon + +; telnet:// and telnets:// links, for this user (HKA is HKCU in the default per-user install). Only with the task above; +; the program never registers itself. --open-link makes the client read the link and nothing else, so a link holding a +; quote cannot add arguments of its own to the command line Windows builds from this template. +[Registry] +Root: HKA; Subkey: "Software\Classes\telnet"; ValueType: string; ValueName: ""; ValueData: "URL:Telnet Protocol"; Flags: uninsdeletekey; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnet"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnet\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: """{app}\sharpmuterm-window.exe"",0"; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnet\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\sharpmuterm-window.exe"" --open-link ""%1"""; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnets"; ValueType: string; ValueName: ""; ValueData: "URL:Telnet over TLS Protocol"; Flags: uninsdeletekey; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnets"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnets\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: """{app}\sharpmuterm-window.exe"",0"; Tasks: telnetlinks +Root: HKA; Subkey: "Software\Classes\telnets\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\sharpmuterm-window.exe"" --open-link ""%1"""; Tasks: telnetlinks + +; The AppUserModelID's name and icon for toasts. Always written: its presence is what tells the window a toast can be +; shown (a portable copy has none and only flashes the taskbar). +Root: HKA; Subkey: "Software\Classes\AppUserModelId\SharpMUTerm.Window"; ValueType: string; ValueName: "DisplayName"; ValueData: "SharpMUTerm"; Flags: uninsdeletekey +Root: HKA; Subkey: "Software\Classes\AppUserModelId\SharpMUTerm.Window"; ValueType: string; ValueName: "IconUri"; ValueData: "{app}\sharpmuterm.png" [Run] Filename: "{app}\sharpmuterm-window.exe"; Description: "{cm:LaunchProgram,SharpMUTerm}"; Flags: nowait postinstall skipifsilent diff --git a/src/SharpMUTerm.Avalonia/App.cs b/src/SharpMUTerm.Avalonia/App.cs index d0ba9db1..77a1149d 100644 --- a/src/SharpMUTerm.Avalonia/App.cs +++ b/src/SharpMUTerm.Avalonia/App.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Primitives; using Avalonia.Themes.Simple; namespace SharpMUTerm.Avalonia; @@ -19,6 +20,9 @@ public override void OnFrameworkInitializationCompleted() // The client decides when the program ends: closing the window asks it to quit, and it is the // client's loop ending that shuts the application down. desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + // A menu's or a tooltip's popup is a top level of its own, so it does not inherit the windows' light hinting. + Control.LoadedEvent.AddClassHandler((root, _) => NativeTokens.ApplyTextRendering(root)); desktop.MainWindow = new MainWindow(Options, desktop); } diff --git a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs index 23f46530..45f4637e 100644 --- a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs +++ b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs @@ -70,6 +70,33 @@ public AvaloniaConsoleDriver(int columns, int rows) /// The window's screen-reader regions; the window adds them beside the view. public WindowAnnouncer Announcer { get; } = new(); + /// The window's native settings window, which the main window owns (). + public SettingsPresenter Settings { get; } = new(); + + /// The window's native ⌃P, drawn over everything in the main window (). + public PalettePresenter Palette { get; } = new(); + + /// The window's native F1, drawn over the cells in the main window (). + public ComposerPresenter Composer { get; } = new(); + + /// + /// The window's native quit prompt, ⌃F, ⌃R, ⌥M and welcome, drawn over everything in the main window, the quit prompt + /// always in front (). + /// + public ModalPresenter Modals { get; } = new(); + + /// The panels popped out into windows of their own (). + public FloatingPanelPresenter Floating { get; } = new(); + + /// The window's title for what is in front, which the main window shows (). + public WindowTitleBar TitleBar { get; } = new(); + + /// The window's alerts while it is behind another (). + public WindowAttention Attention { get; } = new(); + + /// The desktop's file picker, for an import from another client (). + public WindowFilePicker FilePicker { get; } = new(); + public bool IsRunning { get; private set; } // ── IConsoleDriver: output ──────────────────────────────────────────────────────────────── @@ -90,7 +117,19 @@ public void Stop() public void Flush() { - Volatile.Write(ref _frame, _grid.Snapshot(_caret)); + // The band the client said while this frame was painted (PostBufferPaint runs before the flush, on this thread). + var frame = _grid.Snapshot(_caret); + if (Pointer.Band is { } band && band.Top + band.Rows <= frame.Height) + { + frame = frame.WithBand(band); + } + + if (Pointer.Dividers is { Count: > 0 } dividers) + { + frame = frame.WithDividers(dividers); + } + + Volatile.Write(ref _frame, frame); Interlocked.Increment(ref _frames); FrameReady?.Invoke(); } @@ -156,3 +195,27 @@ public void Resize(int columns, int rows) public void Dispose() => Pictures.Dispose(); } + +/// +/// The window's half of : the client hands it Character@World — SharpMUTerm on its own +/// thread, and the main window, which subscribes, puts it on the title bar and the taskbar entry on Avalonia's. +/// +internal sealed class WindowTitleBar : IWindowTitle +{ + /// The last title, or null before the client has said one. + public string? Current { get; private set; } + + /// Raised on the client's thread with each new title. + public event Action? Changed; + + public void Show(string title) + { + Current = title; + Changed?.Invoke(title); + } + + /// A window's title goes with the window; there is nothing to give back. + public void Restore() + { + } +} diff --git a/src/SharpMUTerm.Avalonia/BundledFonts.cs b/src/SharpMUTerm.Avalonia/BundledFonts.cs new file mode 100644 index 00000000..e3a59c38 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/BundledFonts.cs @@ -0,0 +1,109 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Fonts; + +namespace SharpMUTerm.Avalonia; + +/// +/// The faces this program carries (Fonts/, embedded): Inter for every native view and JetBrains Mono for game +/// text, registered with Avalonia's font manager so a view naming either finds the bundled face on every OS — before +/// this, "JetBrains Mono" resolved only where it happened to be installed, and the UI face was whatever the platform's +/// default was (Segoe UI, Cantarell, San Francisco), so the window looked different on each. +/// +/// Read from the assembly's manifest resources, the same streams reads for the cells, so each +/// file is carried once. The bare family names are mapped onto the bundle (), so a view that says +/// "JetBrains Mono" or "Inter" gets the bundled face and an installed copy cannot replace it. +/// +/// +/// +/// The faces are made once per process and shared by every collection. A font manager disposes its collections, +/// and the base collection disposes every face it holds; the headless tests set up a fresh application, and so a fresh +/// font manager and collection, for every test, so the faces were read, made and disposed once per test, hundreds of +/// times a run, and now and then one test failed to find Inter. Shared and never disposed, the eight faces are made +/// once; the window has one font manager, so nothing changes there. +/// +internal sealed class BundledFonts : FontCollectionBase, IDisposable +{ + private static readonly object Gate = new(); + private static GlyphTypeface[]? s_faces; + + /// The collection's key: families in it are named fonts:SharpMUTerm#Inter. + public static readonly Uri CollectionKey = new("fonts:SharpMUTerm"); + + /// The bundled UI face. + public const string Ui = "fonts:SharpMUTerm#Inter"; + + /// The bundled text face, the cells' own. + public const string Mono = "fonts:SharpMUTerm#JetBrains Mono"; + + /// Every face that is registered: the four Inter weights the views use, and JetBrains Mono's four. + internal static readonly string[] Files = + [ + "Inter-Regular.ttf", "Inter-Medium.ttf", "Inter-SemiBold.ttf", "Inter-Bold.ttf", + "JetBrainsMono-Regular.ttf", "JetBrainsMono-Bold.ttf", "JetBrainsMono-Italic.ttf", "JetBrainsMono-BoldItalic.ttf", + ]; + + public override Uri Key => CollectionKey; + + /// How many faces were loaded. Internal for tests: a packaging slip that drops a file shows here. + internal int Loaded { get; } + + public BundledFonts() + { + GlyphTypeface[] shared; + lock (Gate) + { + if (s_faces is null) + { + var faces = new List(); + foreach (var file in Files) + { + using var stream = typeof(BundledFonts).Assembly.GetManifestResourceStream($"Fonts.{file}"); + if (stream is not null && TryAddGlyphTypeface(stream, out var face)) + { + faces.Add(face); + } + } + + s_faces = [.. faces]; + Loaded = s_faces.Length; + return; + } + + shared = s_faces; + } + + foreach (var face in shared) + { + if (TryAddGlyphTypeface(face)) + { + Loaded++; + } + } + } + + /// The shared faces outlive any one font manager, so a manager disposing this collection disposes none of them. + void IDisposable.Dispose() + { + } + + /// + /// Inter as the default family (what resolves to, so every view that names none + /// is Inter), and the bare names mapped onto the bundle. + /// + public static FontManagerOptions Options => new() + { + DefaultFamilyName = Ui, + FontFamilyMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Inter"] = new FontFamily(Ui), + ["JetBrains Mono"] = new FontFamily(Mono), + }, + }; + + /// Registers the bundle and the options on an app being built: the window's and the headless tests' alike. + public static AppBuilder Use(AppBuilder builder) => + builder + .ConfigureFonts(manager => manager.AddFontCollection(new BundledFonts())) + .With(Options); +} diff --git a/src/SharpMUTerm.Avalonia/CellFonts.cs b/src/SharpMUTerm.Avalonia/CellFonts.cs index 65959ba3..1d67c420 100644 --- a/src/SharpMUTerm.Avalonia/CellFonts.cs +++ b/src/SharpMUTerm.Avalonia/CellFonts.cs @@ -16,24 +16,48 @@ namespace SharpMUTerm.Avalonia; internal sealed class CellFonts : IDisposable { /// - /// Families tried in order when none is named. Nerd Font builds first, because the client's chrome is drawn - /// in Nerd Font icons and a face that has them needs no fallback for a single cell of the header. + /// Installed Nerd Font families, asked by name for an icon in the Private Use Area the bundled symbols lack. No + /// longer the text face: the bundled JetBrains Mono is the default on every OS, so the window looks the same on + /// Windows, Linux and macOS; an installed face draws text only when --font names it. /// public static readonly string[] PreferredFamilies = [ "CaskaydiaMono Nerd Font", "CaskaydiaCove Nerd Font Mono", "CaskaydiaCove Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMono Nerd Font", "FiraCode Nerd Font Mono", - "Cascadia Mono", "Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", "Menlo", + ]; + + /// + /// Installed faces tried only when the bundled JetBrains Mono is missing from the build (a packaging slip). + /// + private static readonly string[] LastResortFamilies = + [ "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Courier New", ]; + /// The bundled faces (Fonts/), loaded once and never disposed: every grid shares them. + private static readonly Lazy BundledRegular = new(() => Bundled("JetBrainsMono-Regular.ttf")); + private static readonly Lazy BundledBold = new(() => Bundled("JetBrainsMono-Bold.ttf")); + private static readonly Lazy BundledItalic = new(() => Bundled("JetBrainsMono-Italic.ttf")); + private static readonly Lazy BundledBoldItalic = new(() => Bundled("JetBrainsMono-BoldItalic.ttf")); + private static readonly Lazy BundledSymbols = new(() => Bundled("SymbolsNerdFontMono-Regular.ttf")); + + /// The family name the bundled text face reports. + internal const string BundledFamily = "JetBrains Mono"; + private readonly ConcurrentDictionary<(int CodePoint, bool Bold, bool Italic), SKFont> _fonts = new(); private readonly SKTypeface _regular; private readonly SKTypeface _bold; private readonly SKTypeface _italic; private readonly SKTypeface _boldItalic; - public CellFonts(float sizePixels, string? family = null) + /// The text size, in device pixels. + /// An installed family --font named, or null for the bundled face. + /// + /// The row height as a multiple of the face's own ascent-to-descent height (--line-height, at least 1). The + /// extra is split above and below the text, so a box or a picture still meets the row above and below it: the + /// cell is taller, never gapped. + /// + public CellFonts(float sizePixels, string? family = null, float lineHeight = 1f) { Size = sizePixels; _regular = Match(family, SKFontStyle.Normal) ?? SKTypeface.Default; @@ -46,8 +70,9 @@ public CellFonts(float sizePixels, string? family = null) CellWidth = Math.Max(1, (int)Math.Ceiling(probe.MeasureText("M"))); // Ascent to descent, without the face's line gap: a terminal's rows sit flush, and a box or a picture // drawn down a column of cells must not be striped by the gap between them. - CellHeight = Math.Max(1, (int)Math.Ceiling(metrics.Descent - metrics.Ascent)); - Baseline = (float)Math.Round(-metrics.Ascent); + var text = metrics.Descent - metrics.Ascent; + CellHeight = Math.Max(1, (int)Math.Ceiling(text * Math.Max(1f, lineHeight))); + Baseline = (float)Math.Round(-metrics.Ascent + ((CellHeight - text) / 2f)); UnderlineOffset = metrics.UnderlinePosition is { } u and > 0 ? u : sizePixels / 10f; StrokeWidth = Math.Max(1f, metrics.UnderlineThickness is { } t and > 0 ? t : sizePixels / 14f); } @@ -132,6 +157,12 @@ private SKFont Create(int codePoint, bool bold, bool italic) return null; } + // The bundled symbols first, so every OS draws the same icon; an installed Nerd Font only for one they lack. + if (BundledSymbols.Value is { } bundled && bundled.ContainsGlyph(codePoint)) + { + return bundled; + } + foreach (var name in SymbolFamilies) { var face = SKFontManager.Default.MatchFamily(name); @@ -144,14 +175,56 @@ private SKFont Create(int codePoint, bool bold, bool italic) return null; } + /// Whether the bundled faces are in this build, for a test to hold the packaging to. + internal static bool HasBundledFaces => BundledRegular.Value is not null && BundledSymbols.Value is not null; + + private static SKTypeface? Bundled(string file) + { + using var stream = typeof(CellFonts).Assembly.GetManifestResourceStream($"Fonts.{file}"); + if (stream is null) + { + return null; + } + + using var data = SKData.Create(stream); + return data is null ? null : SKTypeface.FromData(data); + } + + private static SKTypeface? BundledText(SKFontStyle style) => (style.Weight >= (int)SKFontStyleWeight.SemiBold, style.Slant != SKFontStyleSlant.Upright) switch + { + (true, true) => BundledBoldItalic.Value, + (true, false) => BundledBold.Value, + (false, true) => BundledItalic.Value, + _ => BundledRegular.Value, + }; + private static readonly string[] SymbolFamilies = [ - "Symbols Nerd Font Mono", "Symbols Nerd Font", .. PreferredFamilies.Where(f => f.Contains("Nerd Font", StringComparison.Ordinal)), + "Symbols Nerd Font Mono", "Symbols Nerd Font", .. PreferredFamilies, ]; private static SKTypeface? Match(string? family, SKFontStyle style) { - foreach (var name in family is null ? PreferredFamilies : [family, .. PreferredFamilies]) + // A family --font named, if it is installed and is not the bundled face itself. + if (family is not null && !string.Equals(family, BundledFamily, StringComparison.OrdinalIgnoreCase)) + { + var named = SKFontManager.Default.MatchFamily(family, style); + if (named is not null && string.Equals(named.FamilyName, family, StringComparison.OrdinalIgnoreCase)) + { + return named; + } + + named?.Dispose(); + } + + // The bundled face is asked for by its own name, so a family that resolves to it (the regular face's, + // when bold is matched after it) gets the bundled weights rather than a system fallback. + if (BundledText(style) is { } bundled) + { + return bundled; + } + + foreach (var name in LastResortFamilies) { var face = SKFontManager.Default.MatchFamily(name, style); if (face is not null && string.Equals(face.FamilyName, name, StringComparison.OrdinalIgnoreCase)) diff --git a/src/SharpMUTerm.Avalonia/CellGrid.cs b/src/SharpMUTerm.Avalonia/CellGrid.cs index acf02aef..082574cf 100644 --- a/src/SharpMUTerm.Avalonia/CellGrid.cs +++ b/src/SharpMUTerm.Avalonia/CellGrid.cs @@ -33,8 +33,27 @@ internal sealed class ScreenFrame(int width, int height, GridCell[] cells, Caret public CaretState Caret { get; } = caret; + /// + /// The command line's rows as the client said they were when this frame was painted, or null with no field to draw + /// (). Carried by the frame so the field never sits around rows of another frame. + /// + public SharpMUTerm.Tui.InputBand? Band { get; init; } + + /// + /// The pane dividers as the client said they were when this frame was painted: the window draws their cells as the + /// two panes' planes meeting at a hairline rather than as a band of the rule's colour. + /// + public IReadOnlyList Dividers { get; init; } = []; + public GridCell this[int x, int y] => cells[(y * Width) + x]; + /// This frame carrying : the same cells, not copied. + public ScreenFrame WithBand(SharpMUTerm.Tui.InputBand band) => new(Width, Height, cells, Caret) { Band = band, Dividers = Dividers }; + + /// This frame carrying : the same cells, not copied. + public ScreenFrame WithDividers(IReadOnlyList dividers) => + new(Width, Height, cells, Caret) { Band = Band, Dividers = dividers }; + public static ScreenFrame Empty(int width, int height) => new(width, height, Enumerable.Repeat(GridCell.Blank, width * height).ToArray(), default); diff --git a/src/SharpMUTerm.Avalonia/CellRenderer.cs b/src/SharpMUTerm.Avalonia/CellRenderer.cs index 6c332c04..175193d6 100644 --- a/src/SharpMUTerm.Avalonia/CellRenderer.cs +++ b/src/SharpMUTerm.Avalonia/CellRenderer.cs @@ -42,7 +42,10 @@ public void Render(SKCanvas canvas, ScreenFrame frame, PictureStore pictures, bo using var fill = new SKPaint { IsAntialias = false, Style = SKPaintStyle.Fill }; - // Backgrounds, as runs: one rectangle per stretch of a row sharing a colour. + // Backgrounds, as runs: one rectangle per stretch of a row sharing a colour. A row's last run is carried on to the + // canvas's right edge: a window is rarely a whole number of cells wide, and the pixels past the last column would + // otherwise be a strip of the default background beside whatever pane ends there. + var right = canvas.LocalClipBounds.Right; for (var y = 0; y < frame.Height; y++) { var start = 0; @@ -58,7 +61,8 @@ public void Render(SKCanvas canvas, ScreenFrame frame, PictureStore pictures, bo if (current != DefaultBackground) { fill.Color = current; - canvas.DrawRect(start * cw, y * ch, (x - start) * cw, ch, fill); + var width = x == frame.Width ? Math.Max((x - start) * cw, right - (start * cw)) : (x - start) * cw; + canvas.DrawRect(start * cw, y * ch, width, ch, fill); } start = x; @@ -239,14 +243,14 @@ private void DrawCaret(SKCanvas canvas, ScreenFrame frame) } } - private SKColor Background(GridCell cell) + internal SKColor Background(GridCell cell) { var colour = (cell.Decorations & TextDecoration.Invert) != 0 ? cell.Foreground : cell.Background; var fallback = (cell.Decorations & TextDecoration.Invert) != 0 ? DefaultForeground : DefaultBackground; return Resolve(colour, fallback); } - private SKColor Foreground(GridCell cell) + internal SKColor Foreground(GridCell cell) { var inverted = (cell.Decorations & TextDecoration.Invert) != 0; var colour = Resolve(inverted ? cell.Background : cell.Foreground, inverted ? DefaultBackground : DefaultForeground); @@ -267,6 +271,12 @@ private SKColor Foreground(GridCell cell) /// plane handed to the terminal's background (F7's transparency fallback), which a window has no /// translucency behind to show — so it is the default background too. /// + /// A cell's background colour as drawn, for a native view laid over the cells (the command line's field). + public SKColor Background(Color colour) => Resolve(colour, DefaultBackground); + + /// A cell's ink as drawn, before any dimming. + public SKColor Foreground(Color colour) => Resolve(colour, DefaultForeground); + private static SKColor Resolve(Color colour, SKColor fallback) => colour.IsDefault || colour.A == 0 ? fallback : new SKColor(colour.R, colour.G, colour.B); } diff --git a/src/SharpMUTerm.Avalonia/ClientLaunch.cs b/src/SharpMUTerm.Avalonia/ClientLaunch.cs index 10f3d0b8..e1047361 100644 --- a/src/SharpMUTerm.Avalonia/ClientLaunch.cs +++ b/src/SharpMUTerm.Avalonia/ClientLaunch.cs @@ -1,4 +1,5 @@ using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Text; using SharpMUTerm.Tui; namespace SharpMUTerm.Avalonia; @@ -14,6 +15,10 @@ namespace SharpMUTerm.Avalonia; /// internal static class ClientLaunch { + // The live client's passwords: the OS credential store first, secrets.json behind it. Made by the live load and + // handed to the live run, whose saves need what the load learned; the demo has none and touches neither. + private static CredentialVault? _vault; + public static AppConfiguration LoadConfiguration(WindowOptions options, List notices) { if (options.Demo) @@ -25,7 +30,8 @@ public static AppConfiguration LoadConfiguration(WindowOptions options, ListRuns the client on until it quits; blocks the calling thread. @@ -37,13 +43,17 @@ public static int Run(WindowOptions options, AppConfiguration config, IReadOnlyL return app.Run([]); } - return Tui.Program.RunLive(options.ClientArgs, config, notices, WindowHost.Create(driver, clipboard)); + return Tui.Program.RunLive(options.ClientArgs, config, notices, WindowHost.Create(driver, clipboard, options.Chrome), vault: _vault); } /// The demo client, with its --view scene laid out and not yet running. public static SharpMUTermApp Demo(WindowOptions options, AppConfiguration config, AvaloniaConsoleDriver driver, Action? clipboard) { - var app = new SharpMUTermApp(config, WindowHost.Create(driver, clipboard)); + // The installed dictionary, read-only: the demo's ⌥W adds a word for the run and writes no user-words file. + var app = new SharpMUTermApp( + config, + WindowHost.Create(driver, clipboard, options.Chrome), + spelling: () => SpellChecker.Find(SpellChecker.SearchFolders(null), SpellChecker.PreferredLanguage(), null)); // The snapshot's scene builder is the demo: it pours the demo output in and opens the named view. What // it returns is the headless driver's capture, which on this driver is empty and not wanted. It is queued diff --git a/src/SharpMUTerm.Avalonia/CollapsedBarView.cs b/src/SharpMUTerm.Avalonia/CollapsedBarView.cs new file mode 100644 index 00000000..87cc1dc1 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/CollapsedBarView.cs @@ -0,0 +1,167 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using SharpMUTerm.Client.Panels; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// A collapsed panel's bar, over the pane whose cells draw the same : a rule along its +/// top, the panel's name, its unread count in the accent, and the note at the right — the mockups' Chat 2 new ····· +/// collapsed in combat footer of a side column. A left click anywhere on it is , the +/// method the cell row's link ends in; the window builds no command. A right click is the client's, on the cell under it. +/// It never takes the keyboard. The bar is the mockup's at the foot of what it covers (its cell +/// row and the field reserve under it, DesktopLayer.BelowGrid); anything above that is the column's plane, carrying +/// the panel above it down to the bar, and takes no click. +/// +internal sealed class CollapsedBarView : Control, IEdgeRules +{ + /// The rule along the bar's top (). + public IEnumerable EdgeRules(Size size) + { + yield return new EdgeRule(NativeTokens.HorizontalRule(this, NativeTokens.Snap(this, Math.Max(0, size.Height - BarHeight)), 0, size.Width), PanelText.Rule(_text, _plane)); + } + + private readonly TerminalView _view; + private CollapsedPanelModel _model = new(string.Empty, 0, string.Empty); + private Color _plane = Colors.Black; + private Color _text = Colors.White; + private Color _accent = Colors.Teal; + private Action? _expand; + private bool _hot; + + public CollapsedBarView(TerminalView view) + { + _view = view; + ClipToBounds = true; + Focusable = false; + } + + /// + /// The bar's height: Desktop-Combat's footer of the side column, 10 px either side of a 13 px line and its rule + /// (the command line field's height, ). + /// + internal const double BarHeight = NativeTokens.FieldHeight; + + /// Where the bar starts, on a device pixel: the foot of the control, or its top when it is shorter. Internal for tests. + internal double BarTop => NativeTokens.Snap(this, Math.Max(0, Bounds.Height - BarHeight)); + + private bool InBar(Point at) => at.Y >= BarTop && new Rect(Bounds.Size).Contains(at); + + private void SetHot(bool hot) + { + if (hot == _hot) + { + return; + } + + _hot = hot; + Cursor = hot ? new Cursor(StandardCursorType.Hand) : null; + ToolTip.SetTip(this, hot ? CollapsedPanelModel.ExpandHint : null); + InvalidateVisual(); + } + + /// What it is showing. Internal for tests. + internal CollapsedPanelModel Model => _model; + + public void Show(CollapsedPanelModel model, PanelInk ink, Color plane, Color text, Action? expand) + { + _expand = expand; + var accent = PanelText.Parse(ink.Bar); + if (model == _model && plane == _plane && text == _text && accent == _accent) + { + return; + } + + _model = model; + _plane = plane; + _text = text; + _accent = accent; + InvalidateVisual(); + } + + /// Clicks the bar as a left press and release would. Internal for tests. + internal void PerformClick() => _expand?.Invoke(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + SetHot(InBar(e.GetPosition(this))); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + SetHot(false); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + e.Handled = true; + SetHot(InBar(e.GetPosition(this))); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + e.Handled = true; + if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed) + { + _view.ForwardRightClick(e.GetPosition(_view), e.KeyModifiers); + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + e.Handled = true; + if (e.InitialPressMouseButton == MouseButton.Left && InBar(e.GetPosition(this))) + { + _expand?.Invoke(); + } + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + e.Handled = true; + } + + public override void Render(DrawingContext context) + { + var whole = new Rect(Bounds.Size); + var top = BarTop; + var bounds = new Rect(0, top, whole.Width, whole.Height - top); + context.FillRectangle(new SolidColorBrush(_plane), whole); + if (_hot) + { + context.FillRectangle(new SolidColorBrush(PanelText.Mix(_plane, _text, 0.05)), bounds); + } + + context.FillRectangle( + new SolidColorBrush(PanelText.Rule(_text, _plane)), + NativeTokens.HorizontalRule(this, top, 0, bounds.Width)); + if (bounds.Width < 8) + { + return; + } + + var middle = top + (bounds.Height / 2); + var size = NativeTokens.Body; // the mockup's 13 px bar, as the rest of the side column + var x = PanelText.Pad; + var note = PanelText.Measure(_model.Note, NativeTokens.Caption, false); + var right = bounds.Width - PanelText.Pad; + var room = Math.Max(1, right - x - note - NativeTokens.Space16); + x += PanelText.Draw(context, _model.Title, x, middle, size, _text, true, room); + if (_model.UnreadText.Length > 0) + { + x += NativeTokens.Space8; + PanelText.Draw(context, _model.UnreadText, x, middle, size, _accent, true, Math.Max(1, right - x - note - NativeTokens.Space8)); + } + + PanelText.Draw(context, _model.Note, right, middle, NativeTokens.Caption, PanelText.Dim(_text, _plane), false, note + 1, alignRight: true); + } +} diff --git a/src/SharpMUTerm.Avalonia/ComposerEditor.cs b/src/SharpMUTerm.Avalonia/ComposerEditor.cs new file mode 100644 index 00000000..ab1f2457 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/ComposerEditor.cs @@ -0,0 +1,261 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using SharpMUTerm.Client.Composer; + +namespace SharpMUTerm.Avalonia; + +/// +/// The composer's text: a native — wrapping, undo and redo, selection, the platform's IME and +/// paste — that draws a wavy underline under each word the dictionary does not know. The underline is drawn over the +/// text from the box's own layout (), so it follows wrapping, scrolling and the +/// face exactly; the ranges are , the function the client marks its cells by. +/// +/// Lines are broken by \n whatever the platform (), so an offset into +/// is an offset into the client's buffer and a mark lands on the word it names. +/// +/// +internal sealed class ComposerEditor : TextBox +{ + private TextPresenter? _presenter; + private ScrollViewer? _scroller; + private IReadOnlyList _marks = []; + + public ComposerEditor() + { + AcceptsReturn = true; + AcceptsTab = false; + TextWrapping = TextWrapping.Wrap; + NewLine = "\n"; + BorderThickness = new Thickness(0); + Padding = new Thickness(NativeTokens.Space12, NativeTokens.Space10); + MinHeight = 0; + ClipToBounds = true; + } + + /// The TextBox's own look: this is a text box, not a new control to style. + protected override Type StyleKeyOverride => typeof(TextBox); + + /// The wavy line's colour. + public Color MarkColour { get; set; } = Color.FromRgb(0xff, 0x6b, 0x6b); + + /// The words underlined now, as ranges of . + public IReadOnlyList Marks + { + get => _marks; + set + { + if (!_marks.SequenceEqual(value)) + { + _marks = value; + MarksMoved?.Invoke(); + } + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + if (_scroller is not null) + { + _scroller.ScrollChanged -= OnScrolled; + } + + _presenter = e.NameScope.Find("PART_TextPresenter"); + _scroller = e.NameScope.Find("PART_ScrollViewer"); + if (_scroller is not null) + { + _scroller.ScrollChanged += OnScrolled; + } + + if (_presenter is not null) + { + _presenter.LayoutUpdated += (_, _) => MarksMoved?.Invoke(); + } + } + + private void OnScrolled(object? sender, ScrollChangedEventArgs e) => MarksMoved?.Invoke(); + + /// Raised when the marks, or where the text lies, changed: the layer drawing the lines repaints. + public event Action? MarksMoved; + + /// The box's own text layout, for a test and the context menu; null before the template is applied. + internal TextPresenter? Presenter => _presenter; + + /// The offset into at a point in this control's coordinates, or null. + internal int? OffsetAt(Point point) + { + if (_presenter is not { TextLayout: { } layout } presenter || this.TranslatePoint(point, presenter) is not { } at) + { + return null; + } + + var hit = layout.HitTestPoint(at); + return Math.Clamp(hit.TextPosition + (hit.IsTrailing ? 1 : 0), 0, (Text ?? string.Empty).Length); + } + + /// + /// The rectangles each mark covers, in this control's coordinates, clipped to what is in view: one per line a + /// wrapped word lands on. Empty before the template is applied. + /// + public IReadOnlyList MarkRects() + { + if (_presenter is not { } presenter || _marks.Count == 0 || presenter.TextLayout is not { } layout) + { + return []; + } + + var text = Text ?? string.Empty; + var view = new Rect(Bounds.Size); + var rects = new List(); + foreach (var mark in _marks) + { + if (mark.Start < 0 || mark.Start + mark.Length > text.Length || mark.Length <= 0) + { + continue; + } + + foreach (var hit in layout.HitTestTextRange(mark.Start, mark.Length)) + { + if (presenter.TranslatePoint(hit.TopLeft, this) is { } at) + { + var rect = new Rect(at, hit.Size); + if (rect.Intersects(view)) + { + rects.Add(rect); + } + } + } + } + + return rects; + } + + /// + /// The line a script's last load error named, from 1, or 0: , the field the cell + /// editor marks too. Drawn as a band over the line by and in the gutter. + /// + public int ErrorLine + { + get => _errorLine; + set + { + if (_errorLine != value) + { + _errorLine = value; + MarksMoved?.Invoke(); + } + } + } + + private int _errorLine; + + /// The band's colour (the warning ink, mostly transparent so the text under it reads). + public Color ErrorColour { get; set; } = Color.FromRgb(0xff, 0x6b, 0x6b); + + /// + /// Each source line (from 1) in view, with the top and bottom of all the rows it wraps onto, in this control's + /// coordinates: what the gutter numbers and the error band covers. Read off the box's own layout, so it follows + /// wrapping, scrolling and the face. Empty before the template is applied. + /// + public IReadOnlyList<(int Line, double Top, double Bottom)> LineSpans() + { + if (_presenter is not { TextLayout: { } layout } presenter) + { + return []; + } + + var text = Text ?? string.Empty; + var spans = new List<(int, double, double)>(); + var start = 0; + var line = 1; + var height = Bounds.Height; + while (start <= text.Length) + { + var end = text.IndexOf('\n', start); + var stop = end < 0 ? text.Length : end; + var first = layout.HitTestTextPosition(start); + var last = layout.HitTestTextPosition(stop); + if (presenter.TranslatePoint(first.TopLeft, this) is { } top && presenter.TranslatePoint(last.BottomLeft, this) is { } bottom) + { + if (top.Y > height) + { + break; + } + + if (bottom.Y >= 0) + { + spans.Add((line, top.Y, Math.Max(bottom.Y, top.Y + first.Height))); + } + } + + if (end < 0) + { + break; + } + + start = end + 1; + line++; + } + + return spans; + } + + /// + /// Draws the wavy lines onto . A paints its text and its own + /// background in children of its template, which are drawn after anything the box itself draws, so the lines are + /// drawn by a layer lying over the editor (), which calls this. + /// + public void DrawMarks(DrawingContext context, Visual surface) + { + if (_errorLine > 0) + { + foreach (var (line, top, bottom) in LineSpans()) + { + if (line == _errorLine && this.TranslatePoint(new Point(0, top), surface) is { } at) + { + var band = Color.FromArgb(0x3a, ErrorColour.R, ErrorColour.G, ErrorColour.B); + context.FillRectangle(new SolidColorBrush(band), new Rect(at.X, at.Y, Bounds.Width, bottom - top)); + } + } + } + + var rects = MarkRects(); + if (rects.Count == 0) + { + return; + } + + var pen = new Pen(new SolidColorBrush(MarkColour), 1.2, lineCap: PenLineCap.Round); + foreach (var rect in rects) + { + if (this.TranslatePoint(rect.BottomLeft, surface) is not { } origin) + { + continue; + } + + var y = origin.Y - 1.5; + var geometry = new StreamGeometry(); + using (var g = geometry.Open()) + { + const double step = 2.0; + var x = origin.X; + var end = origin.X + rect.Width; + g.BeginFigure(new Point(x, y), false); + var up = true; + while (x < end) + { + x = Math.Min(end, x + step); + g.LineTo(new Point(x, up ? y - 1.6 : y)); + up = !up; + } + + g.EndFigure(false); + } + + context.DrawGeometry(null, pen, geometry); + } + } +} diff --git a/src/SharpMUTerm.Avalonia/ComposerOverlay.cs b/src/SharpMUTerm.Avalonia/ComposerOverlay.cs new file mode 100644 index 00000000..5e6d5dbd --- /dev/null +++ b/src/SharpMUTerm.Avalonia/ComposerOverlay.cs @@ -0,0 +1,70 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Client.Composer; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// F1 drawn by the window: a over the whole cell area, in the theme's own colours, in place +/// of the cell modal. The native header and sidebar stay where they are (inert while it is up, as under any modal); +/// the toasts are drawn over it, so a refusal is still seen. It follows its : the client +/// publishes a new model when the escaping flips or a word is added, and that is all it ever redraws from. +/// +internal sealed class ComposerOverlay : Border +{ + private readonly TerminalView _view; + private readonly ComposerSession _session; + private readonly PanelInk _ink; + private readonly ComposerView _composer; + + public ComposerOverlay(TerminalView view, ComposerSession session, PanelInk ink) + { + _view = view; + _session = session; + _ink = ink; + Focusable = false; + + // Tab goes round the surface and never out of it to the cells behind it. + KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle); + Margin = view.Margin; + _composer = new ComposerView(view, fullScreen: true); + Child = _composer; + _session.Changed += OnChanged; + _view.PropertyChanged += OnViewChanged; + Show(_session.Model); + } + + /// The session it draws. Internal for tests. + internal ComposerSession Session => _session; + + /// The editor and its bands. Internal for tests. + internal ComposerView Composer => _composer; + + /// Stops following the session and the view; the presenter calls it when the overlay goes. + internal void Detach() + { + _session.Changed -= OnChanged; + _view.PropertyChanged -= OnViewChanged; + } + + private void OnChanged(ComposerModel model) => Dispatcher.UIThread.Post(() => Show(model)); + + private void OnViewChanged(object? sender, global::Avalonia.AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == MarginProperty) + { + Margin = _view.Margin; + } + } + + private void Show(ComposerModel model) + { + var plane = DesktopLayer.Colour(_view.Renderer.DefaultBackground); + var text = DesktopLayer.Colour(_view.Renderer.DefaultForeground); + Background = new SolidColorBrush(plane); + _composer.Show(model, _session, _ink, plane, text); + } +} diff --git a/src/SharpMUTerm.Avalonia/ComposerPresenter.cs b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs new file mode 100644 index 00000000..a1f7a80c --- /dev/null +++ b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs @@ -0,0 +1,90 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using SharpMUTerm.Client.Composer; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window host's : F1 as a over the cell area, under +/// the desktop layer (so the toasts are drawn over it) and over the cells. The client calls in on its own thread, so +/// everything here is posted to the window's. A host never attached — --render-png, a test that builds no +/// window — is not , and the client opens its cell composer instead. +/// +internal sealed class ComposerPresenter : INativeComposer +{ + private volatile bool _attached; + private Panel? _root; + private Control? _below; + private TerminalView? _view; + private Action? _refocus; + private ComposerOverlay? _overlay; + + /// The open F1, for a test; read on the window's thread. + public ComposerOverlay? Overlay => _overlay; + + public bool Available => _attached; + + /// + /// Where F1 is drawn: in , just under (the desktop layer), over the + /// cells of ; and what takes the keyboard back when it closes. On the window's thread. + /// + public void Attach(Panel root, Control below, TerminalView view, Action refocus) + { + _root = root; + _below = below; + _view = view; + _refocus = refocus; + _attached = true; + } + + public void Open(ComposerSession session, PanelInk ink) => Dispatcher.UIThread.Post(() => OpenNow(session, ink)); + + public void Close() => Dispatcher.UIThread.Post(CloseNow); + + /// Shows F1 for now, replacing one already open; on the window's thread. + internal ComposerOverlay? OpenNow(ComposerSession session, PanelInk ink) + { + if (_overlay is { } open) + { + Remove(open, refocus: false); + } + + if (_root is not { } root || _view is not { } view || session.IsEnded) + { + return null; + } + + var overlay = new ComposerOverlay(view, session, ink); + _overlay = overlay; + var at = _below is not null ? root.Children.IndexOf(_below) : -1; + root.Children.Insert(at < 0 ? root.Children.Count : at, overlay); + overlay.Composer.Editor.Focus(); + return overlay; + } + + /// Takes F1 down now; on the window's thread. + internal void CloseNow() + { + if (_overlay is { } open) + { + Remove(open, refocus: true); + } + } + + private void Remove(ComposerOverlay overlay, bool refocus) + { + if (!ReferenceEquals(_overlay, overlay)) + { + return; + } + + _overlay = null; + overlay.Detach(); + _root?.Children.Remove(overlay); + if (refocus) + { + _refocus?.Invoke(); + } + } +} diff --git a/src/SharpMUTerm.Avalonia/ComposerView.cs b/src/SharpMUTerm.Avalonia/ComposerView.cs new file mode 100644 index 00000000..f9cec255 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/ComposerView.cs @@ -0,0 +1,1276 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Client.Composer; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// A composer drawn by the window: a header (what it is, who it goes to, the escaping mode, the dictionary), a native +/// editor (), and the key row — all read off the client's , the +/// model the cell composer is drawn from. Used for the docked panel, over its pane's cells, and for F1, over the whole +/// cell area (). +/// +/// Every gesture goes back through the , so it ends in the client method the cell +/// composer's key ends in: ⌃S sends (or saves a MOO edit), ⌥L flips the escaping, ⌥W adds the word at the caret, Esc +/// releases a docked composer to the command line or closes F1 keeping the draft. The text the editor holds is handed +/// back as it changes; the client's text replaces it only when the model's revision moved, so typing here never has +/// its caret or its undo taken away by its own echo. +/// +/// +/// Arming is the client's one fact. A click in a docked editor asks to arm it, and the editor takes the keyboard +/// when the model says it is armed and gives it back to the cells when the model says it is not. ⌥⏎ is the window's +/// (it maximises), so a newline is ⏎, ⌃⏎ or ⇧⏎ here. Chords the editor has no use for — ⌃P, ⌃O, ⌃↑/⌃↓, ⌥J, the +/// function keys — reach the client from a docked editor exactly as they would from its cells. +/// +/// +internal sealed class ComposerView : Border, IEdgeRules +{ + /// The docked heading's rule: every panel's, its border's last device pixel. + public IEnumerable EdgeRules(Size size) + { + if (!_fullScreen && _header.IsVisible) + { + yield return PanelText.HeadingRule(this, size.Width, _plane, _text); + } + } + + private readonly TerminalView _view; + private readonly bool _fullScreen; + private readonly ComposerEditor _editor; + private readonly MarkLayer _marks; + private readonly LineGutter _gutter; + private readonly Border _header; + private readonly TextBlock _title; + + /// + /// "Click to write a post", in the UI face over the empty idle editor. The editor's own placeholder would be in the + /// editor's face, the post's monospace; a hint is the panel speaking, not the post. Never hit: a click goes to the editor. + /// + private readonly TextBlock _placeholder; + + private bool _placeholderWanted; + private readonly TextBlock _target; + private readonly StackPanel _chips; + private readonly TextBlock _count; + private readonly Border _preview; + private readonly TextBlock _previewHeading; + private readonly TextBlock _previewText; + private readonly Border _footer; + private readonly WrapPanel _keys; + private readonly Border _rule; + + // The command field above the text (ComposerModel.Verb): a one-line box, its label, and the text's label. + private readonly TextBox _command; + private readonly Border _commandWell; + private readonly TextBlock _commandLabel; + private readonly TextBlock _textLabel; + private readonly Control _commandRow; + + // The docked panel's own parts: the editor's framed well, and the footer's summary, Preview and Send. + private readonly Border? _well; + private readonly FittedText _summary = new() { VerticalAlignment = VerticalAlignment.Center }; + private readonly Border _previewButton; + private readonly Border _sendButton; + private ComposerSession? _session; + private ComposerModel? _model; + private long _applied = -1; + private bool _applying; + private bool? _armed; + private PanelInk _ink = new("#5fafff", "#ff5f5f", "#3a3a40"); + private Color _plane = Colors.Black; + private Color _text = Colors.White; + + /// The cells: where the keyboard goes back to, and the face the editor's monospace text uses. + /// F1 rather than the docked panel. + public ComposerView(TerminalView view, bool fullScreen) + { + _view = view; + _fullScreen = fullScreen; + ClipToBounds = true; + Focusable = false; + + _editor = new ComposerEditor + { + Background = Brushes.Transparent, + Padding = fullScreen ? new Thickness(NativeTokens.Space24, NativeTokens.Space14) : new Thickness(NativeTokens.Space10, NativeTokens.Space8), + }; + AutomationProperties.SetName(_editor, fullScreen ? "Compose a post" : "Docked composer"); + _editor.TextChanged += (_, _) => OnTextChanged(); + _editor.AddHandler(KeyDownEvent, OnEditorKeyDown, RoutingStrategies.Tunnel); + _editor.AddHandler(PointerPressedEvent, (_, _) => AskToArm(), RoutingStrategies.Tunnel, handledEventsToo: true); + _editor.GotFocus += (_, _) => AskToArm(); + _editor.ContextRequested += OnContextRequested; + _command = new TextBox + { + AcceptsReturn = false, + AcceptsTab = false, + Background = Brushes.Transparent, + BorderThickness = default, + Padding = new Thickness(NativeTokens.Space10, NativeTokens.Space6), + MinHeight = 0, + VerticalContentAlignment = VerticalAlignment.Center, + PlaceholderText = ComposerModel.CommandPlaceholder, + MaxLength = 200, + }; + AutomationProperties.SetName(_command, "Command"); + ToolTip.SetTip(_command, "The command the text is sent with: +pose, say, +bbpost 3= (Tab moves to the text)"); + _command.TextChanged += (_, _) => OnCommandChanged(); + _command.AddHandler(KeyDownEvent, OnCommandKeyDown, RoutingStrategies.Tunnel); + _command.AddHandler(PointerPressedEvent, (_, _) => AskToArm(), RoutingStrategies.Tunnel, handledEventsToo: true); + _command.GotFocus += (_, _) => AskToArm(); + _commandWell = new Border + { + Child = _command, + CornerRadius = new CornerRadius(NativeTokens.RadiusControl), + BorderThickness = new Thickness(NativeTokens.Hairline), + ClipToBounds = true, + }; + _commandLabel = new TextBlock { Text = ComposerModel.CommandLabel, VerticalAlignment = VerticalAlignment.Center }; + _textLabel = new TextBlock { Text = ComposerModel.TextLabel }; + if (fullScreen) + { + // F1: one row under the header, the label beside the box. + var row = new DockPanel { LastChildFill = true }; + _commandLabel.Margin = new Thickness(0, 0, NativeTokens.Space10, 0); + DockPanel.SetDock(_commandLabel, Dock.Left); + row.Children.Add(_commandLabel); + row.Children.Add(_commandWell); + _commandRow = new Border { Child = row, Padding = new Thickness(NativeTokens.Space24, NativeTokens.Space8) }; + } + else + { + // Docked, as the mockup's panel: Command over its box, then Text over the editor's well. + _commandLabel.Margin = new Thickness(PanelText.Pad, NativeTokens.Space12, PanelText.Pad, 0); + _commandWell.Margin = new Thickness(PanelText.Pad, NativeTokens.Space6, PanelText.Pad, 0); + // The mockup's box: 8 px above and below the line, so it is as tall as theirs (34 px). + _command.Padding = new Thickness(NativeTokens.Space10, NativeTokens.Space8); + _commandRow = new StackPanel { Children = { _commandLabel, _commandWell } }; + _textLabel.Margin = new Thickness(PanelText.Pad, NativeTokens.Space12, PanelText.Pad, 0); + } + + _marks = new MarkLayer(_editor); + _gutter = new LineGutter(_editor) { IsVisible = false }; + + _title = new TextBlock { FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, NativeTokens.Space8, 0) }; + _target = new TextBlock { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _chips = new StackPanel { Orientation = Orientation.Horizontal, Spacing = NativeTokens.Space6, VerticalAlignment = VerticalAlignment.Center }; + _count = new TextBlock { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(NativeTokens.Space10, 0, 0, 0) }; + var headerRow = new DockPanel { LastChildFill = true }; + DockPanel.SetDock(_title, Dock.Left); + DockPanel.SetDock(_count, Dock.Right); + DockPanel.SetDock(_chips, Dock.Right); + headerRow.Children.Add(_title); + headerRow.Children.Add(_count); + headerRow.Children.Add(_chips); + headerRow.Children.Add(_target); + // The docked panel's heading is every panel's (PanelText.Heading): 16 px in, a rule under it, the pop-out button + // clear at the right. + _header = new Border { Child = headerRow, Padding = fullScreen ? new Thickness(NativeTokens.Space24, NativeTokens.Space10) : new Thickness(PanelText.Pad, 0, NativeTokens.Space32, 0) }; + if (!fullScreen) + { + // Every panel's heading height, so the composer's rule meets the one beside it (who is here, the map). + _header.Height = PanelText.PanelHeadingHeight; + headerRow.VerticalAlignment = VerticalAlignment.Center; + } + + _keys = new WrapPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + + // The docked footer: how long the post is, then Preview (F1 with its as-sent band) and Send, each the session's. + _previewButton = ActionButton("Preview", "The post as it will be sent, in F1", () => _session?.TogglePreview()); + _previewButton.Name = "Preview"; + _sendButton = ActionButton("Send Ctrl+S", "Send the post (Ctrl+S)", () => _session?.Send(_editor.Text ?? string.Empty)); + _sendButton.Name = "Send"; + var dockedFooter = new DockPanel { LastChildFill = true }; + DockPanel.SetDock(_sendButton, Dock.Right); + DockPanel.SetDock(_previewButton, Dock.Right); + _previewButton.Margin = new Thickness(NativeTokens.Space8, 0, NativeTokens.Space8, 0); + dockedFooter.Children.Add(_sendButton); + dockedFooter.Children.Add(_previewButton); + dockedFooter.Children.Add(_summary); + _footer = new Border + { + Child = fullScreen ? _keys : dockedFooter, + Padding = fullScreen ? new Thickness(NativeTokens.Space24, NativeTokens.Space8) : new Thickness(PanelText.Pad, NativeTokens.Space10, PanelText.Pad, NativeTokens.Space14), + }; + + // F1's as-sent preview (⌥P): the one command ⌃S sends, in the editor's face, under the editor. + _previewHeading = new TextBlock { Text = "AS SENT", FontWeight = FontWeight.Bold, Margin = new Thickness(0, 0, 0, NativeTokens.Space4) }; + _previewText = new TextBlock { TextWrapping = TextWrapping.Wrap, MaxLines = 4, TextTrimming = TextTrimming.CharacterEllipsis }; + _preview = new Border + { + Child = new StackPanel { Children = { _previewHeading, _previewText } }, + Padding = new Thickness(NativeTokens.Space24, NativeTokens.Space8), + IsVisible = false, + }; + AutomationProperties.SetName(_preview, "As sent"); + + // The armed rule down the docked editor's left edge: the cell composer's ▌ marker, in the accent. + _rule = new Border { Width = NativeTokens.MarkWidth, HorizontalAlignment = HorizontalAlignment.Left }; + + _placeholder = new TextBlock + { + Text = ComposerModel.ClickToWrite, + FontFamily = NativeTokens.UiFont, + IsHitTestVisible = false, + IsVisible = false, + Margin = _editor.Padding + new Thickness(NativeTokens.Hairline), + }; + + var text = new Panel(); + text.Children.Add(_editor); + text.Children.Add(_placeholder); + text.Children.Add(_marks); + text.Children.Add(_rule); + + // A script's lines are numbered in a gutter beside the editor, the cells' line numbers; the error line is marked there. + var body = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; + Control page = text; + if (!fullScreen) + { + // Docked, the editor is a framed well inside the panel, as the mockup's Text field is. + _well = new Border + { + Child = text, + Margin = new Thickness(PanelText.Pad, NativeTokens.Space6, PanelText.Pad, 0), + CornerRadius = new CornerRadius(NativeTokens.RadiusControl), + BorderThickness = new Thickness(NativeTokens.Hairline), + ClipToBounds = true, + }; + page = _well; + } + + Grid.SetColumn(_gutter, 0); + Grid.SetColumn(page, 1); + body.Children.Add(_gutter); + body.Children.Add(page); + + var grid = new Grid { RowDefinitions = new RowDefinitions("Auto,Auto,Auto,*,Auto,Auto") }; + Grid.SetRow(_header, 0); + Grid.SetRow(_commandRow, 1); + Grid.SetRow(_textLabel, 2); + Grid.SetRow(body, 3); + Grid.SetRow(_preview, 4); + Grid.SetRow(_footer, 5); + _textLabel.IsVisible = !fullScreen; + grid.Children.Add(_header); + grid.Children.Add(_commandRow); + grid.Children.Add(_textLabel); + grid.Children.Add(body); + grid.Children.Add(_preview); + grid.Children.Add(_footer); + Child = grid; + ApplyFont(); + } + + /// + /// Whether this editor writes in the UI's proportional face rather than the cells' monospace one: its character's + /// choice, kept in the configuration (CharacterDefinition.ComposerProportional) and chosen from the editor's + /// right-click menu. Until the session says (and with no session) it is the last face chosen in this window, so a + /// choice shows at once rather than when the client has stored it. + /// + internal bool Proportional => _faceChosen ?? _session?.Proportional ?? false; + + /// Raised when a face is chosen, so every composer on screen re-reads its own. + internal static event Action? FaceChanged; + + // The face just chosen here, until the client has it; cleared when any face changes. + private bool? _faceChosen; + + /// The editor. Internal for tests. + internal ComposerEditor Editor => _editor; + + /// What it shows now. Internal for tests. + internal ComposerModel? Model => _model; + + /// The session it hands gestures to. Internal for tests. + internal ComposerSession? Session => _session; + + /// The header's words, joined, for a test. + internal string HeaderText => string.Join(" ", new[] { _title.Text, _target.Text, _count.Text } + .Concat(_chips.Children.OfType().Select(b => (b.Child as TextBlock)?.Text)).Where(t => !string.IsNullOrEmpty(t))); + + /// The key row's words, joined, for a test. + internal string FooterText => !_fullScreen + ? string.Join(" · ", new[] { _summary.Text, Words(_previewButton.Child!), Words(_sendButton.Child!) }.Where(t => !string.IsNullOrEmpty(t))) + : string.Join(" · ", _keys.Children.OfType() + .Select(p => string.Join(" ", p.Children.OfType().Select(Words).Where(t => t.Length > 0)))); + + /// The menu the editor last opened. Internal for tests. + internal ContextMenu? OpenedMenu { get; private set; } + + private static string Words(Control control) => control switch + { + TextBlock t => t.Text ?? string.Empty, + Border { Child: TextBlock t } => t.Text ?? string.Empty, + _ => string.Empty, + }; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + FaceChanged += ApplyFont; + if (_model is { Armed: true }) + { + Dispatcher.UIThread.Post(() => _editor.Focus(), DispatcherPriority.Input); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + FaceChanged -= ApplyFont; + base.OnDetachedFromVisualTree(e); + } + + /// + /// Shows , handing gestures to , in the pane's colours: the accent + /// and warning of , on , in . + /// + public void Show(ComposerModel model, ComposerSession session, PanelInk ink, Color plane, Color text) + { + if (!ReferenceEquals(session, _session)) + { + _session = session; + _applied = -1; + _armed = null; + + // Another character's composer, or the same one opened again: its face is the session's. + _faceChosen = null; + ApplyFont(); + } + + var coloursChanged = ink != _ink || plane != _plane || text != _text; + _ink = ink; + _plane = plane; + _text = text; + var previous = _model; + _model = model; + + if (model.Revision != _applied) + { + _applied = model.Revision; + if (!string.Equals(ComposeMessage.CleanCommand(_command.Text), model.Verb, StringComparison.Ordinal)) + { + _applying = true; + try + { + _command.Text = model.Verb; + _command.CaretIndex = model.Verb.Length; + } + finally + { + _applying = false; + } + } + + if (!string.Equals(_editor.Text ?? string.Empty, model.Body, StringComparison.Ordinal)) + { + _applying = true; + try + { + _editor.Text = model.Body; + _editor.CaretIndex = model.Edit is { Line: > 0 } at ? LineStart(model.Body, at.Line) : model.Body.Length; + _editor.ClearSelection(); + } + finally + { + _applying = false; + } + } + } + + if (previous is null || !previous.Equals(model)) + { + _editor.Marks = session.Marks(_editor.Text ?? string.Empty); + } + + // A package's script is only read: the editor shows it and keeps every edit out. + _editor.IsReadOnly = model.Edit?.ReadOnly == true; + + // A script: its lines numbered, and the line the last load error named marked in the gutter and behind the text, + // from the model's one field the cell editor marks too. + _gutter.IsVisible = model.Edit is { Script: true }; + _editor.ErrorLine = model.Edit is { Script: true, ErrorLine: > 0 } failed ? failed.ErrorLine : 0; + + if (coloursChanged || previous is null) + { + Paint(); + } + + if (previous is null || !SameChrome(previous, model) || coloursChanged) + { + Label(model); + } + + _rule.IsVisible = !_fullScreen && model.Armed; + + if (_armed != model.Armed) + { + var was = _armed; + _armed = model.Armed; + if (model.Armed) + { + if (VisualRoot is not null) + { + Dispatcher.UIThread.Post(() => _editor.Focus(), DispatcherPriority.Input); + } + } + else if (was == true && (_editor.IsKeyboardFocusWithin || _command.IsKeyboardFocusWithin)) + { + _view.Focus(); + } + } + } + + /// The gutter of line numbers, shown for a script. Internal for tests. + internal LineGutter Gutter => _gutter; + + /// The command field. Internal for tests. + internal TextBox CommandBox => _command; + + /// Whether the command field is shown (a post) and what it holds, for a test. + internal string? CommandText => _commandRow.IsVisible ? _command.Text ?? string.Empty : null; + + /// The header's length cell as drawn. Internal for tests. + internal string CountText => _count.Text ?? string.Empty; + + /// The as-sent preview's command, or null while it is hidden. Internal for tests. + internal string? PreviewText => _preview.IsVisible ? _previewText.Text : null; + + private static bool SameChrome(ComposerModel a, ComposerModel b) => + a.Preview == b.Preview && a.Target == b.Target && a.Escaping == b.Escaping && a.Edit == b.Edit && a.Dictionary == b.Dictionary + && a.CanSend == b.CanSend && a.Armed == b.Armed && a.Keys.SequenceEqual(b.Keys) && a.Verb == b.Verb; + + // ── drawing ────────────────────────────────────────────────────────────────────────────── + + private Color Accent => Parse(_ink.Bar, Color.FromRgb(0x5f, 0xaf, 0xff)); + + private Color Warn => Parse(_ink.Low, Color.FromRgb(0xff, 0x6b, 0x6b)); + + private Color Dim => Mix(_text, _plane, 0.42); + + /// The bands' plane: the pane's, one step toward the text, so the editor reads as the page between them. + private Color Band => Mix(_plane, _text, _fullScreen ? 0.06 : 0.045); + + /// The docked editor's well: a step deeper than the panel on a dark plane, lighter on a light one. + private Color Well => Luma(_plane) < 0.5 ? Mix(_plane, Colors.Black, 0.25) : Mix(_plane, Colors.White, 0.7); + + private static double Luma(Color c) => ((0.2126 * c.R) + (0.7152 * c.G) + (0.0722 * c.B)) / 255; + + private void Paint() + { + Background = new SolidColorBrush(_plane); + _header.Background = new SolidColorBrush(_fullScreen ? Band : _plane); + _footer.Background = new SolidColorBrush(_fullScreen ? Band : _plane); + _header.BorderBrush = new SolidColorBrush(_fullScreen ? Mix(_plane, _text, 0.12) : PanelText.Rule(_text, _plane)); + _header.BorderThickness = new Thickness(0, 0, 0, NativeTokens.Hairline); + _footer.BorderBrush = _header.BorderBrush; + _footer.BorderThickness = _fullScreen ? new Thickness(0, NativeTokens.Hairline, 0, 0) : default; + var tokens = NativeTokens.For(null, Luma(_plane) < 0.5); + tokens.ApplyFocusRing(this); + if (_well is not null) + { + // The mockup's Text field: the well's ground, ringed in the focus edge, as the field the post is written in. + _well.Background = new SolidColorBrush(Well); + _well.BorderBrush = new SolidColorBrush(tokens.Focus); + } + + // The Command field: the same ground, ringed in the quieter edge. + _commandWell.Background = new SolidColorBrush(_fullScreen ? Mix(_plane, _text, 0.05) : Well); + _commandWell.BorderBrush = new SolidColorBrush(tokens.Edge); + _command.Foreground = new SolidColorBrush(_text); + _command.CaretBrush = new SolidColorBrush(Accent); + _command.SelectionBrush = new SolidColorBrush(Color.FromArgb(0x70, Accent.R, Accent.G, Accent.B)); + _command.SelectionForegroundBrush = new SolidColorBrush(_text); + _commandLabel.Foreground = new SolidColorBrush(Dim); + _textLabel.Foreground = new SolidColorBrush(Dim); + + _summary.Foreground = new SolidColorBrush(Dim); + PaintButton(_previewButton, accent: false); + PaintButton(_sendButton, accent: true); + _rule.Background = new SolidColorBrush(Accent); + _editor.Foreground = new SolidColorBrush(_text); + _editor.CaretBrush = new SolidColorBrush(Accent); + _editor.SelectionBrush = new SolidColorBrush(Color.FromArgb(0x70, Accent.R, Accent.G, Accent.B)); + _editor.SelectionForegroundBrush = new SolidColorBrush(_text); + _editor.MarkColour = Warn; + _editor.ErrorColour = Warn; + _gutter.Ink = Dim; + _gutter.Warn = Warn; + _gutter.Background = new SolidColorBrush(Band); + _gutter.InvalidateVisual(); + _title.Foreground = new SolidColorBrush(_text); + _target.Foreground = new SolidColorBrush(Dim); + _count.Foreground = new SolidColorBrush(Dim); + _preview.Background = new SolidColorBrush(Band); + _preview.BorderBrush = _header.BorderBrush; + _preview.BorderThickness = new Thickness(0, NativeTokens.Hairline, 0, 0); + _previewHeading.Foreground = new SolidColorBrush(Accent); + _previewText.Foreground = new SolidColorBrush(_text); + _marks.InvalidateVisual(); + } + + private void ApplyFont() + { + var cell = _view.CellExtent.Height > 0 ? _view.CellExtent.Height : 17; + var ui = Math.Clamp(cell * 0.72, 11, 15); + _title.FontSize = _fullScreen ? ui : PanelText.Size; + _target.FontSize = _fullScreen ? ui : PanelText.Size; + _count.FontSize = ui - 1; + _summary.FontSize = PanelText.Size - 1; + _commandLabel.FontSize = _fullScreen ? ui - 1 : PanelText.Size - 1; + _textLabel.FontSize = PanelText.Size - 1; + // Game text in the cells' own face and size (the mockups' 13 px JetBrains Mono beside the output's), not a share + // of the row: a row carries the line height's leading, which made the text a size larger than the output's. + var text = _view.CellTextSize > 0 ? _view.CellTextSize : cell * 0.8; + _command.FontSize = Math.Clamp(text, 11, 18); + _command.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName); + foreach (var button in new[] { _previewButton, _sendButton }) + { + if (button.Child is TextBlock words) + { + words.FontSize = PanelText.Size - 1; + } + } + _previewHeading.FontSize = ui - 1.5; + _previewText.FontSize = Math.Clamp(cell * 0.72, 11, 16); + _previewText.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName); + _editor.FontSize = Proportional ? Math.Clamp(text * 1.08, 12, 19) : Math.Clamp(text, 11, 18); + + // Rows as far apart as the output's (the mockups' 20 px line), so a post reads at the pitch it will be read at. + _editor.LineHeight = Proportional ? double.NaN : cell; + _editor.FontFamily = Proportional + ? NativeTokens.UiFont + : NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName); + if (_model is { } model) + { + Label(model); + } + } + + /// Where line (from 1) of starts; the end when it has fewer. + internal static int LineStart(string text, int line) + { + var at = 0; + for (var i = 1; i < line; i++) + { + var next = text.IndexOf('\n', at); + if (next < 0) + { + return text.Length; + } + + at = next + 1; + } + + return at; + } + + /// The header and the key row, from the model. + private void Label(ComposerModel model) + { + var ui = _title.FontSize; + if (model.Edit is { Script: true } script) + { + // A script: its name, that a save reloads the character's scripts, and what the last save did. + _title.Text = script.ReadOnly ? "Read only" : "Script"; + var whose = model.Target.Length == 0 ? "no connection" : model.Target; + _target.Text = $"{script.Name} lua · {(script.ReadOnly ? "a package's script" : $"reloads {whose}")}" + + (script.Status is { } status ? $" · {status.TrimStart('!', ' ')}" : string.Empty); + } + else if (model.Edit is { } edit) + { + _title.Text = "Edit"; + _target.Text = $"{edit.Name} {edit.Type} · from {(model.Target.Length == 0 ? "no connection" : model.Target)}"; + } + else if (!_fullScreen) + { + // The panel heading: Compose for Corvid. + var heading = SharpMUTerm.Client.Panels.PanelHeading.For(model); + _title.Text = heading.Title; + _target.Text = heading.Context ?? string.Empty; + } + else + { + _title.Text = "Compose"; + _target.Text = model.Target.Length == 0 ? "no connection" : $"to {model.Target}"; + } + + _target.Foreground = new SolidColorBrush( + model.Target.Length == 0 || model.Edit?.Status?.StartsWith('!') == true ? Warn : Dim); + + _chips.Children.Clear(); + if (model.Edit is null && !_fullScreen) + { + // The escaping, as a chip that switches it (⌥L): Literal or As typed. + var literal = model.Escaping == ComposeEscaping.Literal; + var escaping = OutlinedChip(literal ? "Literal" : "As typed", literal ? Accent : Dim); + escaping.Name = "Escaping"; + escaping.Cursor = new Cursor(StandardCursorType.Hand); + ToolTip.SetTip(escaping, literal ? "Sent as typed, % [ ] escaped (Alt+L switches)" : "Sent exactly as typed (Alt+L switches)"); + escaping.PointerPressed += (_, e) => + { + if (e.GetCurrentPoint(escaping).Properties.IsLeftButtonPressed) + { + e.Handled = true; + _session?.ToggleEscaping(); + } + }; + _chips.Children.Add(escaping); + if (model.Armed && model.Dictionary is { } named) + { + _chips.Children.Add(OutlinedChip(named, Dim)); + } + + _count.IsVisible = false; + _sendButton.Opacity = model.CanSend ? 1 : 0.55; + } + else if (model.Edit is null) + { + _chips.Children.Add(Chip(model.Escaping == ComposeEscaping.Literal ? "literal" : "as typed", + model.Escaping == ComposeEscaping.Literal ? Accent : _text, ui - 1.5)); + if (_fullScreen || model.Armed) + { + _chips.Children.Add(model.Dictionary is { } dictionary + ? Chip(dictionary, _text, ui - 1.5) + : DictionaryChip(ui - 1.5)); + } + } + + _preview.IsVisible = _fullScreen && model.Preview && model.Edit is null; + + // The command field and the Text label: a post's, and in a docked pane only while there is room for them over an + // editor worth writing in — the heading, both labels, the field, two lines of text and the footer are about 232 px. + var roomForCommand = _fullScreen || Bounds.Height is 0 or >= 232; + _commandRow.IsVisible = model.HasCommandField && roomForCommand; + _textLabel.IsVisible = !_fullScreen && model.HasCommandField && roomForCommand; + SyncLength(); + + _keys.Children.Clear(); + if (!_fullScreen) + { + // A docked composer too short for its footer keeps the editor and drops the footer: the heading, the footer + // and an editor of two lines need about 136 px, and below that the footer would squeeze the well shut. + _footer.IsVisible = Bounds.Height is 0 or >= 136; + ShowPlaceholder(!model.Armed); + return; + } + + // The idle editor's placeholder says "Click to write a post"; the row's keyless hint saying it again was the + // panel saying one thing twice. + var placeholder = !model.Armed && !_fullScreen; + var placeholderShown = placeholder && model.Body.Length == 0; + foreach (var key in model.Keys) + { + if (placeholderShown && key.Key.Length == 0 && key.Label == ComposerModel.ClickHint) + { + continue; + } + + var item = new StackPanel { Orientation = Orientation.Horizontal, Spacing = NativeTokens.Space4, Margin = new Thickness(0, NativeTokens.Hairline, NativeTokens.Space14, NativeTokens.Hairline) }; + if (key.Key.Length > 0) + { + // A key cap, the one every native list draws (⌃P, help, the modals), not a tinted word. + item.Children.Add(ModalParts.Chip(NativeKeys.Words(key.Key), NativeTokens.For(null, Luma(_plane) < 0.5))); + } + + item.Children.Add(new TextBlock + { + Text = NativeKeys.Words(key.Label), + FontSize = ui - 1, + Foreground = new SolidColorBrush(Dim), + VerticalAlignment = VerticalAlignment.Center, + }); + _keys.Children.Add(item); + } + + // A docked composer too short for its key row keeps the editor and drops the row. + _footer.IsVisible = _fullScreen || Bounds.Height is 0 or >= 64; + ShowPlaceholder(placeholder); + } + + /// The UI-face placeholder (): wanted while idle, shown while the editor is empty. + private void ShowPlaceholder(bool wanted) + { + _placeholderWanted = wanted; + _placeholder.Foreground = new SolidColorBrush(Dim); + _placeholder.FontSize = Math.Max(NativeTokens.Body, _editor.FontSize - 1); + SyncPlaceholder(); + } + + private void SyncPlaceholder() => _placeholder.IsVisible = _placeholderWanted && string.IsNullOrEmpty(_editor.Text); + + /// What the idle editor says while empty, or null. Internal for tests. + internal string? Placeholder => _placeholder.IsVisible ? _placeholder.Text : null; + + /// A header chip (escaping, dictionary): the docked heading's outline, so F1 and the panel wear one chip. + private Border Chip(string words, Color ink, double size) => OutlinedChip(words, ink); + + /// + /// The docked heading's chip, as the mockup's Literal: 11 px words in a hairline outline of the panel's rule, + /// rounded as a control, on the panel's own plane. + /// + private Border OutlinedChip(string words, Color ink) => new() + { + Background = Brushes.Transparent, + BorderBrush = new SolidColorBrush(PanelText.Rule(_text, _plane)), + BorderThickness = new Thickness(NativeTokens.Hairline), + CornerRadius = new CornerRadius(NativeTokens.RadiusControl), + Padding = new Thickness(NativeTokens.Space6, NativeTokens.Space2), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock { Text = words, FontSize = NativeTokens.Small, Foreground = new SolidColorBrush(ink) }, + }; + + /// What the header's dictionary chip says while no dictionary marks the post. + internal const string NoDictionaryChip = "no dictionary · get one"; + + /// + /// The header's chip while no dictionary marks the post: a button for the offer ⌥W opens + /// (), so the state names its own way out. Where the session cannot offer + /// one (the docked panel, a MOO edit) it is the plain dim chip it always was. Internal for tests. + /// + internal Border DictionaryChip(double size) + { + if (_session is not { CanGetDictionary: true }) + { + return Chip("no dictionary", Dim, size); + } + + var chip = Chip(NoDictionaryChip, Accent, size); + chip.Name = "GetDictionary"; + chip.Cursor = new Cursor(StandardCursorType.Hand); + ToolTip.SetTip(chip, "Get a spelling dictionary (Alt+W)"); + AutomationProperties.SetName(chip, "Get a spelling dictionary"); + chip.PointerPressed += (_, e) => + { + if (e.GetCurrentPoint(chip).Properties.IsLeftButtonPressed) + { + e.Handled = true; + _session?.GetDictionary(); + } + }; + return chip; + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + if (_model is { } model) + { + Label(model); + } + } + + // ── text ───────────────────────────────────────────────────────────────────────────────── + + /// + /// The length cell and the preview, from the editor's own text: the model catches up with typing a moment later, and + /// a count that lagged a keystroke would read as wrong. Counted by , the cells' rule. + /// + private void SyncLength() + { + if (_model is not { } model) + { + return; + } + + var text = _editor.Text ?? string.Empty; + var verb = model.HasCommandField ? _command.Text : null; + _count.Text = ComposerModel.CharactersLabel(ComposerModel.SentLength(verb, text, model.Escaping, model.Edit is not null)); + if (!_fullScreen) + { + // 2 paragraphs · 31 words, the mockup's footer, with the length as sent in its tooltip (the chars count would + // elide the words in a 380 px column); before anything is written, the keys that move in and out of it. + // In the longest wording the footer holds (FittedText): the words alone where the paragraphs do not fit. + var summary = ComposerModel.Summary(text); + _summary.Forms = summary.Length > 0 + ? ComposerModel.SummaryForms(text) + : model.Armed + ? [NativeKeys.Words("Esc command line · F1 full screen"), NativeKeys.Words("F1 full screen")] + : [NativeKeys.Words("F1 full screen")]; + ToolTip.SetTip(_summary, summary.Length > 0 ? $"{_count.Text} as sent" : null); + } + if (_preview.IsVisible) + { + var command = ComposeMessage.Build(verb, text.Replace("\r\n", "\n", StringComparison.Ordinal), model.Escaping); + _previewText.Text = command ?? "nothing yet"; + } + } + + private void OnTextChanged() + { + SyncLength(); + SyncPlaceholder(); + var text = _editor.Text ?? string.Empty; + if (_session is { } session) + { + _editor.Marks = session.Marks(text); + if (!_applying) + { + session.Edited(text); + } + } + } + + /// The command field changed: the length cell follows, and the client is told unless it wrote it. + private void OnCommandChanged() + { + SyncLength(); + if (_session is { } session && !_applying) + { + session.EditedCommand(_command.Text ?? string.Empty); + } + } + + /// + /// A key in the command field: ⏎ and ⇥ move on to the text (a command is one line), and every composer chord — ⌃S, + /// ⌥L, ⌥P, Esc, the keys a docked editor hands the client — means what it means in the text. + /// + private void OnCommandKeyDown(object? sender, KeyEventArgs e) + { + var mods = e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift); + if ((e.Key == Key.Enter && mods == KeyModifiers.None) || (e.Key == Key.Tab && mods == KeyModifiers.None)) + { + e.Handled = true; + _editor.Focus(); + return; + } + + if (e.Key == Key.W && mods == KeyModifiers.Alt) + { + // ⌥W is a word of the text; in the command field it does nothing. + e.Handled = true; + return; + } + + OnEditorKeyDown(sender, e); + } + + private void AskToArm() + { + if (!_fullScreen && _model is { Armed: false } && _session is { } session) + { + session.Arm(); + } + } + + // ── keys ───────────────────────────────────────────────────────────────────────────────── + + private void OnEditorKeyDown(object? sender, KeyEventArgs e) + { + if (_session is not { } session || _model is not { } model) + { + return; + } + + var mods = e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift); + var text = _editor.Text ?? string.Empty; + var mapped = KeyMapper.FromKeyDown(e.Key, e.KeyModifiers); + + // ⌥⏎ is the window's: it maximises here, so the client's ⌥⏎ newline is ⌃⏎ or ⇧⏎ (and ⏎ itself, in an editor). + if (mapped is { } chord && WindowHost.Maximise.Chord.Matches(chord)) + { + e.Handled = true; + _view.ToggleMaximised(); + return; + } + + switch (e.Key, mods) + { + case (Key.S, KeyModifiers.Control): + e.Handled = true; + session.Send(text); + return; + + case (Key.Tab, KeyModifiers.Shift) when model.HasCommandField && ReferenceEquals(sender, _editor): + // ⇧⇥ from the text back to the command field; ⇥ from the command field is its own handler's. + e.Handled = true; + _command.Focus(); + return; + + case (Key.Tab, KeyModifiers.None) when model.HasCommandField && ReferenceEquals(sender, _editor): + e.Handled = true; + _command.Focus(); + _command.SelectAll(); + return; + + case (Key.L, KeyModifiers.Alt): + e.Handled = true; + session.ToggleEscaping(); + return; + + case (Key.P, KeyModifiers.Alt): + e.Handled = true; + if (_fullScreen) + { + session.TogglePreview(); + } + + return; + + case (Key.W, KeyModifiers.Alt): + e.Handled = true; + if (session.CanAddWords && ComposerMarks.WordAt(text, _editor.CaretIndex) is { } word) + { + session.AddWord(word); + } + else if (session.CanGetDictionary) + { + session.GetDictionary(); + } + + return; + + case (Key.Escape, KeyModifiers.None): + e.Handled = true; + if (_fullScreen) + { + session.Close(text); + } + else + { + session.Release(); + _view.Focus(); + } + + return; + + case (Key.Enter, KeyModifiers.Control): + case (Key.Enter, KeyModifiers.Shift): + e.Handled = true; + Type("\n"); + return; + + case (Key.V, KeyModifiers.Control | KeyModifiers.Shift): + e.Handled = true; + _editor.Paste(); + return; + } + + if (_fullScreen) + { + // F1 again closes it, as the cell window's F1 does. ⌃Q goes to the client and leaves F1 open: the quit prompt + // is drawn in front of it (ModalPresenter), so Stay comes back to the post. + if (e.Key == Key.F1 && mods == KeyModifiers.None) + { + e.Handled = true; + session.Close(text); + } + else if (e.Key == Key.Q && mods == KeyModifiers.Control && mapped is { } quit) + { + e.Handled = true; + _view.Driver.RaiseKey(quit); + } + + return; + } + + if (mapped is { } forward && ReachesTheClient(e.Key, mods)) + { + e.Handled = true; + _view.Driver.RaiseKey(forward); + } + } + + /// + /// Whether a docked editor hands a key on to the client: the function keys, and every Ctrl or Alt chord the editor + /// has no use for of its own — so ⌃P, ⌃O, ⌃B, ⌃↑/⌃↓, ⌃←/⌃→, ⌥J/⌥K and ⌥1–⌥9 do from here what they do from the + /// cells. Typing, the caret keys, selection, ⌃A/⌃C/⌃X/⌃V/⌃Z/⌃Y and word deletion stay the editor's. + /// + internal static bool ReachesTheClient(Key key, KeyModifiers mods) + { + if (key is >= Key.F1 and <= Key.F24) + { + return true; + } + + var ctrl = (mods & KeyModifiers.Control) != 0; + var alt = (mods & KeyModifiers.Alt) != 0; + var shift = (mods & KeyModifiers.Shift) != 0; + if (alt && !ctrl) + { + // ⌥←/→ are word movement in the editor, as on the command line. + return key is not (Key.Left or Key.Right or Key.Back or Key.Delete); + } + + if (!ctrl || alt) + { + return false; + } + + if (shift) + { + // ⌃⇧←/→ extend by a word; ⌃⇧Z is redo. + return key is not (Key.Left or Key.Right or Key.Home or Key.End or Key.Z); + } + + return key is not (Key.A or Key.C or Key.X or Key.V or Key.Z or Key.Y or Key.Home or Key.End + or Key.Back or Key.Delete or Key.Insert); + } + + /// Types text at the caret through the editor's own input path, so it is one undo step like any typing. + private void Type(string text) => _editor.RaiseEvent(new TextInputEventArgs { RoutedEvent = TextInputEvent, Text = text }); + + // ── the right-click menu ───────────────────────────────────────────────────────────────── + + private void OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + e.Handled = true; + var at = e.TryGetPosition(_editor, out var point) ? _editor.OffsetAt(point) : _editor.CaretIndex; + OpenMenu(at ?? _editor.CaretIndex); + } + + /// + /// Opens the editor's menu for the text at : the dictionary's suggestions and Add to + /// dictionary when a marked word is there, then the editor's own edits and the face. Internal for tests. + /// + internal ContextMenu OpenMenu(int offset) + { + var text = _editor.Text ?? string.Empty; + var items = new List(); + var mark = _editor.Marks.FirstOrDefault(m => offset >= m.Start && offset <= m.Start + m.Length); + if (_session is { } session && mark.Length > 0 && mark.Start + mark.Length <= text.Length) + { + var word = text.Substring(mark.Start, mark.Length); + var suggestions = session.Suggestions(word); + foreach (var suggestion in suggestions) + { + var replacement = suggestion; + items.Add(Item(replacement, () => Replace(mark, replacement), bold: true)); + } + + if (suggestions.Count == 0) + { + items.Add(new MenuItem { Header = "No suggestions", IsEnabled = false }); + } + + if (session.CanAddWords) + { + items.Add(Item($"Add “{word}” to dictionary", () => session.AddWord(word))); + } + + items.Add(new Separator()); + } + + items.Add(Item("Undo", _editor.Undo, enabled: _editor.CanUndo)); + items.Add(Item("Redo", _editor.Redo, enabled: _editor.CanRedo)); + items.Add(new Separator()); + items.Add(Item("Cut", _editor.Cut, enabled: _editor.CanCut)); + items.Add(Item("Copy", _editor.Copy, enabled: _editor.CanCopy)); + items.Add(Item("Paste", _editor.Paste, enabled: _editor.CanPaste)); + items.Add(Item("Select all", _editor.SelectAll)); + if (_model?.Edit is not { Script: true }) + { + // A script is code, and stays monospace. + items.Add(new Separator()); + items.Add(Item(Proportional ? "Monospace font" : "Proportional font", () => ChooseFace(!Proportional))); + } + + var menu = new ContextMenu { ItemsSource = items }; + menu.Closed += (_, _) => _editor.Focus(); + OpenedMenu = menu; + menu.Open(_editor); + return menu; + } + + /// The menu's face entry: the character keeps it (through the session), and every composer re-reads its own. + internal void ChooseFace(bool proportional) + { + _faceChosen = proportional; + _session?.ChooseFace(proportional); + FaceChanged?.Invoke(); + ApplyFont(); + } + + private static MenuItem Item(string header, Action act, bool enabled = true, bool bold = false) + { + var item = new MenuItem { Header = header, IsEnabled = enabled }; + if (bold) + { + item.FontWeight = FontWeight.SemiBold; + } + + item.Click += (_, _) => act(); + return item; + } + + /// Replaces a marked word with a suggestion, through the editor's input path so it can be undone. + private void Replace(SpellRange mark, string replacement) + { + _editor.SelectionStart = mark.Start; + _editor.SelectionEnd = mark.Start + mark.Length; + Type(replacement); + } + + // ── colours ────────────────────────────────────────────────────────────────────────────── + + /// A footer button drawn as a plate, as the panels' are: a click runs . + private static Border ActionButton(string words, string tip, Action click) + { + var button = new Border + { + Child = new TextBlock { Text = words, VerticalAlignment = VerticalAlignment.Center }, + Padding = new Thickness(NativeTokens.Space10, 0), + Height = NativeTokens.ControlHeightSmall, + CornerRadius = new CornerRadius(NativeTokens.RadiusControl), + BorderThickness = new Thickness(NativeTokens.Hairline), + Cursor = new Cursor(StandardCursorType.Hand), + VerticalAlignment = VerticalAlignment.Center, + }; + ToolTip.SetTip(button, tip); + AutomationProperties.SetName(button, words); + button.PointerPressed += (_, e) => + { + if (e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) + { + e.Handled = true; + click(); + } + }; + return button; + } + + private void PaintButton(Border button, bool accent) + { + button.Background = new SolidColorBrush(accent ? Accent : Mix(_plane, _text, 0.07)); + button.BorderBrush = new SolidColorBrush(accent ? Accent : Mix(_plane, _text, 0.18)); + if (button.Child is TextBlock words) + { + words.Foreground = new SolidColorBrush(accent ? (Luma(Accent) > 0.5 ? Mix(Accent, Colors.Black, 0.85) : Colors.White) : _text); + words.FontWeight = accent ? FontWeight.SemiBold : FontWeight.Normal; + } + } + + /// Presses the docked footer's Preview or Send button, as a click on it does. Internal for tests. + internal void PressFooter(string name) + { + if (name == "Send") + { + _session?.Send(_editor.Text ?? string.Empty); + } + else + { + _session?.TogglePreview(); + } + } + + /// The docked footer's summary as drawn. Internal for tests. + internal string SummaryText => _summary.Text ?? string.Empty; + + internal static Color Mix(Color a, Color b, double t) => Color.FromRgb( + (byte)Math.Round(a.R + ((b.R - a.R) * t)), + (byte)Math.Round(a.G + ((b.G - a.G) * t)), + (byte)Math.Round(a.B + ((b.B - a.B) * t))); + + private static Color Parse(string hex, Color fallback) => Color.TryParse(hex, out var colour) ? colour : fallback; + + /// + /// The wavy lines under the misspelt words, drawn over the editor: the editor's own template paints its background + /// and text after anything the editor draws itself. It takes no input. + /// + internal sealed class MarkLayer : Control + { + private readonly ComposerEditor _editor; + + public MarkLayer(ComposerEditor editor) + { + _editor = editor; + IsHitTestVisible = false; + Focusable = false; + ClipToBounds = true; + editor.MarksMoved += InvalidateVisual; + } + + public override void Render(DrawingContext context) => _editor.DrawMarks(context, this); + } + + /// + /// A script's line numbers, beside the editor and level with each source line's first row (read off the editor's own + /// layout, so a wrapped line is numbered once and scrolling moves the numbers): the window's form of the cell editor's + /// numbered gutter. The line names is drawn in the warning ink with a bar at the + /// edge. It takes no input. + /// + internal sealed class LineGutter : Control + { + private readonly ComposerEditor _editor; + + public LineGutter(ComposerEditor editor) + { + _editor = editor; + IsHitTestVisible = false; + Focusable = false; + ClipToBounds = true; + // Repainted whenever the text moves (the editor's layout, a scroll); re-measured only when the width it needs + // changed — a digit more, another face or size — because the editor's own layout pass raises MarksMoved, and a + // gutter that re-measured on every one would never let layout settle. + editor.MarksMoved += () => + { + if (_measured != (Digits, _editor.FontSize, _editor.FontFamily)) + { + InvalidateMeasure(); + } + + InvalidateVisual(); + }; + } + + private (int Digits, double Size, FontFamily Face) _measured; + + /// The numbers' ink. + public Color Ink { get; set; } = Colors.Gray; + + /// The error line's ink. + public Color Warn { get; set; } = Colors.IndianRed; + + /// The gutter's plane. + public IBrush? Background { get; set; } + + /// The numbers drawn now, by line, for a test: the lines in view with the error line's flagged. + public IReadOnlyList<(int Line, bool Error)> Numbers => + [.. _editor.LineSpans().Select(s => (s.Line, s.Line == _editor.ErrorLine))]; + + private int Digits => Math.Max(2, (_editor.Text ?? string.Empty).Count(c => c == '\n') + 1).ToString(System.Globalization.CultureInfo.InvariantCulture).Length; + + protected override Size MeasureOverride(Size availableSize) + { + _measured = (Digits, _editor.FontSize, _editor.FontFamily); + var sample = Text(new string('8', Digits), Ink, FontWeight.Normal); + return new Size(Math.Ceiling(sample.Width) + 22, 0); + } + + public override void Render(DrawingContext context) + { + if (Background is { } plane) + { + context.FillRectangle(plane, new Rect(Bounds.Size)); + } + + foreach (var (line, top, bottom) in _editor.LineSpans()) + { + if (_editor.TranslatePoint(new Point(0, top), this) is not { } at) + { + continue; + } + + var error = line == _editor.ErrorLine; + var number = Text(line.ToString(System.Globalization.CultureInfo.InvariantCulture), error ? Warn : Ink, error ? FontWeight.Bold : FontWeight.Normal); + context.DrawText(number, new Point(Bounds.Width - 10 - number.Width, at.Y)); + if (error) + { + context.FillRectangle(new SolidColorBrush(Warn), new Rect(Bounds.Width - 4, at.Y, 3, bottom - top)); + } + } + } + + private FormattedText Text(string text, Color ink, FontWeight weight) => + new(text, System.Globalization.CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + new Typeface(_editor.FontFamily, FontStyle.Normal, weight), _editor.FontSize, new SolidColorBrush(ink)); + } +} diff --git a/src/SharpMUTerm.Avalonia/DesktopAlert.cs b/src/SharpMUTerm.Avalonia/DesktopAlert.cs new file mode 100644 index 00000000..c6e0a384 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/DesktopAlert.cs @@ -0,0 +1,441 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// Which desktop the window is on, as far as an alert cares. +internal enum DesktopPlatform +{ + Windows, + MacOS, + Linux, +} + +/// A fixed program and its arguments, each passed as one argv entry, never a shell string. +internal sealed record AlertLaunch(string Program, IReadOnlyList Arguments); + +/// +/// Everything an alert does to the machine, behind one seam so the decisions in are tested +/// with a fake ( is the real one). +/// +internal interface IDesktopNative +{ + /// Starts and does not wait for it. + void Launch(AlertLaunch launch); + + /// Flashes the taskbar button of until it is brought forward (Windows). + void Flash(IntPtr hwnd); + + /// Sets or clears the X11 urgency hint of the window ; false when it could not. + bool SetUrgency(IntPtr xid, bool urgent); + + /// + /// Whether the installer registered (Windows), which is what lets a toast be + /// shown under it. A portable copy has none and gets the flash alone. + /// + bool ToastRegistered { get; } +} + +/// +/// What the desktop does with an alert, per platform. Every way out of the process is a fixed program with the text as +/// separate arguments, and every failure is a quiet no-op: an alert is a courtesy, never an error. +/// +/// Windows: FlashWindowEx until the window is brought forward, and a toast through +/// ToastNotificationManager under the AppUserModelID the installer registered (). The +/// toast is shown by Windows PowerShell 5.1, which can reach WinRT from a script; the text travels base64-encoded inside +/// -EncodedCommand, so nothing a server sends can become script. +/// macOS: /usr/bin/osascript with a fixed display notification script and the title and body +/// as argv. macOS files it under Script Editor and asks the player once whether to allow it. +/// Linux: notify-send, and on X11 the window's urgency hint (WM_HINTS UrgencyHint and +/// _NET_WM_STATE_DEMANDS_ATTENTION, ), cleared when the window is activated. Avalonia +/// has no Wayland back end, so under Wayland the window is an XWayland client and the hint goes through XWayland. +/// +/// +internal sealed class DesktopAlert(DesktopPlatform platform, IDesktopNative native) +{ + /// How much of a line goes into a notification; a toast or a bubble shows two or three lines at most. + internal const int MaxBody = 400; + + private IntPtr _urgentWindow; + + /// The alert for the machine this runs on. + public static DesktopAlert ForThisMachine() => new( + OperatingSystem.IsWindows() ? DesktopPlatform.Windows + : OperatingSystem.IsMacOS() ? DesktopPlatform.MacOS + : DesktopPlatform.Linux, + new DesktopNative()); + + /// + /// Shows for the window behind , whose kind is + /// (Avalonia's HWND, XID or NSWindow), both null where there is none. + /// + public void Show(AttentionRequest request, IntPtr? handle, string? descriptor) + { + try + { + if (platform == DesktopPlatform.Windows && IsWindow(handle, descriptor, "HWND") is { } hwnd) + { + native.Flash(hwnd); + } + + if (platform == DesktopPlatform.Linux && IsWindow(handle, descriptor, "XID") is { } xid && native.SetUrgency(xid, true)) + { + _urgentWindow = xid; + } + + foreach (var launch in Launches(platform, request, platform == DesktopPlatform.Windows && native.ToastRegistered)) + { + native.Launch(launch); + } + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException + or DllNotFoundException or EntryPointNotFoundException) + { + // No notifier on this machine: the title bar's count still says it. + } + } + + /// The window was brought forward: takes back an urgency hint this set. The flash stops by itself. + public void Clear() + { + if (_urgentWindow == IntPtr.Zero) + { + return; + } + + var xid = _urgentWindow; + _urgentWindow = IntPtr.Zero; + try + { + native.SetUrgency(xid, false); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + // Nothing was set. + } + } + + /// Whether an urgency hint this set is still up (tests). + internal bool Urgent => _urgentWindow != IntPtr.Zero; + + /// The programs an alert starts on . + internal static IReadOnlyList Launches(DesktopPlatform platform, AttentionRequest request, bool toastRegistered) + { + var title = Bound(request.Title, 120); + var body = Bound(request.Body, MaxBody); + return platform switch + { + DesktopPlatform.Windows => toastRegistered ? [WindowsToast.Launch(WindowsAppId.Value, title, body)] : [], + DesktopPlatform.MacOS => + [ + new AlertLaunch("/usr/bin/osascript", + ["-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run", + title, body]), + ], + _ => [new AlertLaunch("notify-send", ["--app-name=SharpMUTerm", "--", title, body])], + }; + } + + private static IntPtr? IsWindow(IntPtr? handle, string? descriptor, string kind) => + handle is { } value && value != IntPtr.Zero && string.Equals(descriptor, kind, StringComparison.Ordinal) ? value : null; + + /// Control characters out (a notification is one or two lines), and at most characters. + internal static string Bound(string text, int max) + { + var clean = new StringBuilder(Math.Min(text.Length, max)); + foreach (var c in text) + { + if (clean.Length >= max) + { + break; + } + + clean.Append(char.IsControl(c) ? ' ' : c); + } + + if (clean.Length > 0 && char.IsHighSurrogate(clean[^1])) + { + clean.Length--; + } + + return clean.ToString(); + } +} + +/// +/// The AppUserModelID the window runs under on Windows. The process takes it at start +/// (SetCurrentProcessExplicitAppUserModelID), the installer's Start-menu shortcut carries it, and the installer +/// registers it under Software\Classes\AppUserModelId with a display name and an icon, which is what Windows needs +/// before a toast is shown under it. The program never registers it itself (the telnet-link rule). +/// +internal static class WindowsAppId +{ + /// The id. The installer (packaging/windows/sharpmuterm.iss) spells it too. + public const string Value = "SharpMUTerm.Window"; + + /// Takes for this process, so the taskbar groups the window with its shortcut. Windows only. + public static void Adopt() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + _ = SetCurrentProcessExplicitAppUserModelID(Value); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + // Older than Windows 7: nothing to group by. + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SetCurrentProcessExplicitAppUserModelID(string appId); +} + +/// +/// A Windows toast through WinRT's ToastNotificationManager, shown by Windows PowerShell 5.1 because .NET on the +/// plain net10.0 target reaches no WinRT itself (that needs a -windows TFM, which the Linux build and the +/// shared assemblies cannot take). The script is fixed; the title, body and id are base64 inside it, decoded by the +/// script and added as text nodes, so neither PowerShell nor the toast's XML ever parses server text. +/// +internal static class WindowsToast +{ + /// The powershell.exe launch that shows one toast. + public static AlertLaunch Launch(string appId, string title, string body) => + new("powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", + "-EncodedCommand", Convert.ToBase64String(Encoding.Unicode.GetBytes(Script(appId, title, body)))]); + + /// The script encodes. + internal static string Script(string appId, string title, string body) => + string.Join('\n', + "$ErrorActionPreference = 'Stop'", + "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]", + "[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]", + "function D($s) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) }", + "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)", + "$n = $x.GetElementsByTagName('text')", + $"[void]$n.Item(0).AppendChild($x.CreateTextNode((D '{Base64(title)}')))", + $"[void]$n.Item(1).AppendChild($x.CreateTextNode((D '{Base64(body)}')))", + $"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier((D '{Base64(appId)}')).Show([Windows.UI.Notifications.ToastNotification]::new($x))"); + + private static string Base64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)); +} + +/// The real . +internal sealed class DesktopNative : IDesktopNative +{ + private bool? _toastRegistered; + + public bool ToastRegistered => _toastRegistered ??= ReadToastRegistered(); + + public void Launch(AlertLaunch launch) + { + var start = new ProcessStartInfo(launch.Program) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in launch.Arguments) + { + start.ArgumentList.Add(argument); + } + + using var process = Process.Start(start); + } + + public void Flash(IntPtr hwnd) + { + if (OperatingSystem.IsWindows()) + { + WindowsFlash.Flash(hwnd); + } + } + + public bool SetUrgency(IntPtr xid, bool urgent) => + OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() ? X11Urgency.Set(xid, urgent) : false; + + private static bool ReadToastRegistered() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + const string key = @"Software\Classes\AppUserModelId\" + WindowsAppId.Value; + using var user = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(key); + using var machine = user is null ? Microsoft.Win32.Registry.LocalMachine.OpenSubKey(key) : null; + return user is not null || machine is not null; + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + return false; + } + } +} + +/// FlashWindowEx. +internal static class WindowsFlash +{ + public static void Flash(IntPtr hwnd) + { + var info = new FlashInfo + { + Size = (uint)Marshal.SizeOf(), + Window = hwnd, + Flags = FlashAll | FlashUntilForeground, + Count = 0, + Timeout = 0, + }; + FlashWindowEx(ref info); + } + + private const uint FlashAll = 3; + private const uint FlashUntilForeground = 12; + + [StructLayout(LayoutKind.Sequential)] + private struct FlashInfo + { + public uint Size; + public IntPtr Window; + public uint Flags; + public uint Count; + public uint Timeout; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlashWindowEx(ref FlashInfo info); +} + +/// +/// The X11 urgency hint, set the two ways window managers read it: the ICCCM WM_HINTS UrgencyHint flag (which +/// the window manager may clear on focus) and the EWMH _NET_WM_STATE_DEMANDS_ATTENTION state, asked of the root +/// window with a client message as EWMH requires. Each call opens its own display connection (window ids are +/// server-wide, so Avalonia's own connection is not needed) and closes it after a flush. 64-bit only, which is every +/// platform the window ships for; anywhere else it says it did nothing. +/// +internal static class X11Urgency +{ + private const long UrgencyHint = 1L << 8; + private const long SubstructureNotifyMask = 1L << 19; + private const long SubstructureRedirectMask = 1L << 20; + private const int ClientMessage = 33; + private const long StateRemove = 0; + private const long StateAdd = 1; + private const long SourceApplication = 1; + + public static bool Set(IntPtr window, bool urgent) + { + if (!Environment.Is64BitProcess || window == IntPtr.Zero) + { + return false; + } + + var display = XOpenDisplay(IntPtr.Zero); + if (display == IntPtr.Zero) + { + return false; + } + + try + { + var hints = XGetWMHints(display, window); + var owned = hints == IntPtr.Zero; + if (owned) + { + hints = XAllocWMHints(); + } + + if (hints != IntPtr.Zero) + { + var flags = Marshal.ReadInt64(hints); + Marshal.WriteInt64(hints, urgent ? flags | UrgencyHint : flags & ~UrgencyHint); + XSetWMHints(display, window, hints); + XFree(hints); + } + + var message = new ClientMessageEvent + { + Type = ClientMessage, + SendEvent = 1, + Display = display, + Window = window, + MessageType = XInternAtom(display, "_NET_WM_STATE", false), + Format = 32, + Data0 = (IntPtr)(urgent ? StateAdd : StateRemove), + Data1 = XInternAtom(display, "_NET_WM_STATE_DEMANDS_ATTENTION", false), + Data2 = IntPtr.Zero, + Data3 = (IntPtr)SourceApplication, + }; + XSendEvent(display, XDefaultRootWindow(display), false, + (IntPtr)(SubstructureNotifyMask | SubstructureRedirectMask), ref message); + XFlush(display); + return true; + } + finally + { + XCloseDisplay(display); + } + } + + /// XClientMessageEvent, padded to the size of the XEvent union (24 longs) that XSendEvent reads. + [StructLayout(LayoutKind.Explicit, Size = 192)] + private struct ClientMessageEvent + { + [FieldOffset(0)] public int Type; + [FieldOffset(8)] public IntPtr Serial; + [FieldOffset(16)] public int SendEvent; + [FieldOffset(24)] public IntPtr Display; + [FieldOffset(32)] public IntPtr Window; + [FieldOffset(40)] public IntPtr MessageType; + [FieldOffset(48)] public int Format; + [FieldOffset(56)] public IntPtr Data0; + [FieldOffset(64)] public IntPtr Data1; + [FieldOffset(72)] public IntPtr Data2; + [FieldOffset(80)] public IntPtr Data3; + [FieldOffset(88)] public IntPtr Data4; + } + + private const string LibX11 = "libX11.so.6"; + + [DllImport(LibX11)] + private static extern IntPtr XOpenDisplay(IntPtr name); + + [DllImport(LibX11)] + private static extern int XCloseDisplay(IntPtr display); + + [DllImport(LibX11)] + private static extern IntPtr XDefaultRootWindow(IntPtr display); + + [DllImport(LibX11, CharSet = CharSet.Ansi, BestFitMapping = false)] + private static extern IntPtr XInternAtom(IntPtr display, string name, [MarshalAs(UnmanagedType.Bool)] bool onlyIfExists); + + [DllImport(LibX11)] + private static extern IntPtr XGetWMHints(IntPtr display, IntPtr window); + + [DllImport(LibX11)] + private static extern IntPtr XAllocWMHints(); + + [DllImport(LibX11)] + private static extern int XSetWMHints(IntPtr display, IntPtr window, IntPtr hints); + + [DllImport(LibX11)] + private static extern int XFree(IntPtr data); + + [DllImport(LibX11)] + private static extern int XSendEvent(IntPtr display, IntPtr window, [MarshalAs(UnmanagedType.Bool)] bool propagate, IntPtr mask, ref ClientMessageEvent message); + + [DllImport(LibX11)] + private static extern int XFlush(IntPtr display); +} diff --git a/src/SharpMUTerm.Avalonia/DesktopLayer.TabDrag.cs b/src/SharpMUTerm.Avalonia/DesktopLayer.TabDrag.cs new file mode 100644 index 00000000..cf56e891 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/DesktopLayer.TabDrag.cs @@ -0,0 +1,161 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using SharpMUTerm.Client.Chrome; + +namespace SharpMUTerm.Avalonia; + +/// +/// Dragging a tab off a native strip. The strip starts it () and the layer takes +/// the pointer, because the client tears the strips down to show its drop preview and a pointer held by a strip would be +/// lost with it. Every move is handed to the client as a desktop cell, plus the slot among the strip's tabs while the +/// pointer is over its own strip (the tabs as laid out when the drag began); the client resolves the drop, shows the +/// cells' preview and publishes back what the drop would do (), which +/// and the strip draw. The release asks the client to drop; a right press or the pointer lost +/// abandons it. The window decides nothing. +/// +internal sealed partial class DesktopLayer +{ + private readonly TabDragView _dragView = new(); + private TabDragGesture? _gesture; + + /// The dragged tab's hint. Internal for tests. + internal TabDragView DragView => _dragView; + + /// Whether a native tab is being dragged. Internal for tests. + internal bool TabDragging => _gesture is not null; + + private NativeTabStrip NewStrip() + { + var strip = new NativeTabStrip(); + strip.DragStarted += (s, tab, e) => + { + if (StartTabDrag(s, tab, e.GetPosition(this))) + { + e.Pointer.Capture(this); + } + }; + return strip; + } + + /// Starts dragging off with the pointer at (layer pixels). Internal for tests. + internal bool StartTabDrag(NativeTabStrip strip, TabChipModel tab, Point at) + { + if (_gesture is not null || strip.Overlay is not { Drag: { } send } overlay) + { + return false; + } + + var row = new Rect(GetLeft(strip), GetTop(strip), strip.Width, strip.Height); + _gesture = new TabDragGesture(overlay.Model.PaneId, tab.WindowId, tab.Name, strip.TabAreas, row, send) { Pointer = at }; + Step(TabDragPhase.Begin, at); + return true; + } + + /// Moves the drag to (layer pixels). Internal for tests. + internal void MoveTabDrag(Point at) + { + if (_gesture is not null) + { + Step(TabDragPhase.Move, at); + } + } + + /// Ends the drag at : a drop, or abandoned. Internal for tests. + internal void EndTabDrag(Point at, bool drop) + { + if (_gesture is null) + { + return; + } + + Step(drop ? TabDragPhase.Drop : TabDragPhase.Cancel, at); + _gesture = null; + SyncTabDrag(_view.CellExtent); + } + + private void Step(TabDragPhase phase, Point at) + { + var g = _gesture!; + g.Pointer = at; + var origin = CellOrigin; + var cell = _view.CellExtent; + var x = cell.Width > 0 ? (int)Math.Floor((at.X - origin.X) / cell.Width) : 0; + var y = cell.Height > 0 ? (int)Math.Floor((at.Y - origin.Y) / cell.Height) : 0; + int? slot = g.Row.Contains(at) ? NativeTabStrip.Slot(g.Areas, g.WindowId, at.X - g.Row.X) : null; + g.Send(new TabDragStep(phase, g.PaneId, g.WindowId, x, y, slot)); + SyncTabDrag(cell); + } + + /// The hint over the cells: what the client last said the drop would do, and the tab under the pointer. + private void SyncTabDrag(Size cell) + { + SetLeft(_dragView, 0); + SetTop(_dragView, 0); + _dragView.Width = Bounds.Width; + _dragView.Height = Bounds.Height; + var tokens = _paneTokens?.Tokens ?? NativeTokens.Dark; + if (_gesture is not { } g) + { + _dragView.Show(null, tokens); + return; + } + + var drag = _pointer.PaneChrome?.Drag is { } said && said.WindowId == g.WindowId ? said : null; + _dragView.Show((drag, CellOrigin, cell, g.Name, g.Pointer), tokens); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_gesture is not null) + { + MoveTabDrag(e.GetPosition(this)); + e.Handled = true; + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (_gesture is not null) + { + // A second button while dragging is the conventional "never mind", as in the cells. + EndTabDrag(e.GetPosition(this), drop: false); + e.Pointer.Capture(null); + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_gesture is not null) + { + EndTabDrag(e.GetPosition(this), drop: e.InitialPressMouseButton == MouseButton.Left); + e.Pointer.Capture(null); + e.Handled = true; + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + if (_gesture is { } g) + { + EndTabDrag(g.Pointer, drop: false); + } + } + + /// A drag in flight: the tab, the strip it left as laid out then, and where the pointer is. + private sealed record TabDragGesture( + string PaneId, + string WindowId, + string Name, + IReadOnlyList<(string WindowId, Rect Area)> Areas, + Rect Row, + Action Send) + { + public Point Pointer { get; set; } + } +} diff --git a/src/SharpMUTerm.Avalonia/DesktopLayer.cs b/src/SharpMUTerm.Avalonia/DesktopLayer.cs new file mode 100644 index 00000000..8a020043 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/DesktopLayer.cs @@ -0,0 +1,926 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Core.Layouts; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Tui; +using SkiaSharp; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's native views, in a layer over the whole window: the header band and the connections sidebar beside +/// the (when the window draws them, ), the header buttons, and +/// native panels drawn over the panes the client says hold them. The header and the sidebar have no cells under +/// them — the client leaves no row or column for them, and this layer moves the view out of their way +/// () — while a panel sits over its pane's cells, which are painted underneath exactly as in +/// a terminal, so nothing a panel does takes a cell, moves a pane or changes what any world is told. +/// +/// Input. The layer itself has no background and so is never hit: a pointer anywhere it draws nothing goes +/// to the view, and on to the client, as before. A header button is the window's alone — its press, release, +/// movement and wheel stop at it and never reach the client, which is what lets it sit over cells the client +/// would otherwise read as a press on the header. The vitals gauges are a picture of their pane and take no input: +/// a press, a drag, a right-click or the wheel over them reaches the client as a press on the pane's cells, so +/// focus, the pane's menu and selection behave as they do in a terminal. The map, the who-here list and the quick +/// bar are used with the pointer, so they take it (: zoom, pan, click to walk; +/// : click a name; : click a binding) and hand only a right +/// click on to the client. None of them takes keyboard focus. +/// +/// +internal sealed partial class DesktopLayer : Canvas +{ + /// How many header cells a button covers. The header always ends in three blank cells. + internal const int ButtonCells = 3; + + private readonly TerminalView _view; + private readonly WindowPointer _pointer; + private readonly HostChrome _chrome; + private readonly Dictionary _buttons = new(StringComparer.Ordinal); + private readonly Dictionary _gauges = new(StringComparer.Ordinal); + private readonly Dictionary _maps = new(StringComparer.Ordinal); + private readonly Dictionary _popOuts = new(StringComparer.Ordinal); + private readonly NativeHeader _header; + private readonly NativeSidebar _sidebar; + private readonly NativeFooter _footer; + private readonly Dictionary _whoHere = new(StringComparer.Ordinal); + private readonly Dictionary _quickBars = new(StringComparer.Ordinal); + private readonly Dictionary _collapsedBars = new(StringComparer.Ordinal); + private readonly Dictionary _composers = new(StringComparer.Ordinal); + private readonly NoticeToasts _toasts; + private readonly Dictionary _strips = new(StringComparer.Ordinal); + private readonly Dictionary _bands = new(StringComparer.Ordinal); + private readonly SeamRules _seams = new(); + private (SharpMUTerm.Core.Theming.Theme Theme, NativeTokens Tokens)? _paneTokens; + private int _noticeAboveRow; + + /// The cells. + /// + /// Which chrome the window draws: the same value the client was told (), so the part + /// the client leaves out of the cells is the part this layer draws, and nothing is drawn twice or not at all. + /// + public DesktopLayer(TerminalView view, HostChrome chrome) + { + _view = view; + _chrome = chrome; + _pointer = view.Driver.Pointer; + _view.CellsHoldHeader = !chrome.Header; + _view.DrawsInputField = chrome.Input; + ClipToBounds = true; + _pointer.ActionsChanged += () => Dispatcher.UIThread.Post(Sync); + _pointer.PanelsChanged += () => Dispatcher.UIThread.Post(Sync); + _pointer.ChromeChanged += () => Dispatcher.UIThread.Post(Sync); + _pointer.PaneChromeChanged += () => Dispatcher.UIThread.Post(Sync); + + // The chrome first, so every panel and button is drawn over it. + _header = new NativeHeader(view) { IsVisible = false }; + _sidebar = new NativeSidebar(view) { IsVisible = false }; + _footer = new NativeFooter(view) { IsVisible = false }; + Children.Add(_header); + Children.Add(_sidebar); + Children.Add(_footer); + + // The runs of the native views' rules into the divider cells beside them: over the cells, under nothing it touches. + Children.Add(_seams); + _pointer.StatusChanged += () => Dispatcher.UIThread.Post(Sync); + + // A dragged tab's hint over the panels; the toasts still over it. + Children.Add(_dragView); + + // The toasts last, over everything: a notice is the one thing the window says over a panel. + _toasts = new NoticeToasts(); + Children.Add(_toasts); + _pointer.NoticeSaid += notice => Dispatcher.UIThread.Post(() => + { + _noticeAboveRow = notice.AboveRow; + Sync(); + _toasts.Show(notice); + }); + _view.Repainted += Sync; + SyncInsets(collapsed: false); + Sync(); + } + + /// Which chrome this layer draws. Internal for tests. + internal HostChrome Chrome => _chrome; + + /// + /// Where the cells start in this layer: past the native header and sidebar. Every view placed over cells is placed + /// from here, so a panel sits on its pane however wide the sidebar is. + /// + internal Point CellOrigin => new(_view.Margin.Left, _view.Margin.Top); + + /// + /// Moves the cells out of the way of the native header and sidebar: the view's margin is their size, so the view — + /// and the grid it hands the driver, and so every pane the client lays out — is what is left of the window. + /// + private void SyncInsets(bool collapsed) + { + var insets = NativeLayout.Insets(_chrome, collapsed); + if (_view.Margin != insets) + { + _view.Margin = insets; + } + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + Sync(); + } + + /// The header button for an action, or null. Internal for tests. + internal HeaderButton? Button(string actionId) => _buttons.GetValueOrDefault(actionId); + + /// The native vitals gauges by window id. Internal for tests. + internal IReadOnlyDictionary Gauges => _gauges; + + /// The native maps by window id. Internal for tests. + internal IReadOnlyDictionary Maps => _maps; + + /// The pop-out buttons, by the panel window each pops out. Internal for tests. + internal IReadOnlyDictionary PopOuts => _popOuts; + + /// The native header. Internal for tests. + internal NativeHeader Header => _header; + + /// The native sidebar. Internal for tests. + internal NativeSidebar Sidebar => _sidebar; + + /// The native status footer. Internal for tests. + internal NativeFooter Footer => _footer; + + /// The native who-here lists by window id. Internal for tests. + internal IReadOnlyDictionary WhoHere => _whoHere; + + /// The native quick bars by window id. Internal for tests. + internal IReadOnlyDictionary QuickBars => _quickBars; + + /// The collapsed panels' bars, by window id. Internal for tests. + internal IReadOnlyDictionary CollapsedBars => _collapsedBars; + + /// The native docked composers by window id. Internal for tests. + internal IReadOnlyDictionary Composers => _composers; + + /// + /// Gives the keyboard to the docked composer the client says is armed, or else to the cells: where it goes back to + /// when ⌃P or F1 closes. + /// + internal void FocusArmedComposer() + { + if (_composers.Values.FirstOrDefault(c => c.Model is { Armed: true }) is { } armed) + { + armed.Editor.Focus(); + return; + } + + _view.Focus(); + } + + /// The last header menu opened. Internal for tests. + internal ContextMenu? OpenedMenu { get; private set; } + + /// Puts every native view where the client's last word and the last frame say it goes. + internal void Sync() + { + var frame = _view.Driver.Frame; + var cell = _view.CellExtent; + SyncChrome(); + SyncButtons(frame, cell); + SyncPanels(frame, cell); + SyncPaneChrome(cell); + SyncTabDrag(cell); + SyncSeams(cell); + SyncToasts(frame, cell); + } + + /// The native views' rules carried on to the pane dividers' hairlines beside them. Internal for tests. + internal SeamRules Seams => _seams; + + /// + /// Runs each full-width rule a native view over a pane draws () on through the half divider + /// cell between the view's edge and the divider's hairline (), so a tab + /// strip's foot, a panel's heading and a strip's seams meet the hairline rather than stopping half a cell short. + /// + private void SyncSeams(Size cell) + { + SetLeft(_seams, 0); + SetTop(_seams, 0); + _seams.Width = Math.Max(0, Bounds.Width); + _seams.Height = Math.Max(0, Bounds.Height); + var origin = CellOrigin; + var hairlines = _view.VerticalHairlines().Select(h => h.Translate(new Vector(origin.X, origin.Y))).ToList(); + var views = _strips.Values.Cast().Concat(_bands.Values).Concat(_gauges.Values).Concat(_maps.Values) + .Concat(_whoHere.Values).Concat(_quickBars.Values).Concat(_collapsedBars.Values).Concat(_composers.Values) + .Where(v => v.IsVisible && v.Width > 0 && v.Height > 0 && v is IEdgeRules) + .Select(v => (new Rect(GetLeft(v), GetTop(v), v.Width, v.Height), ((IEdgeRules)v).EdgeRules(new Size(v.Width, v.Height)))); + _seams.Show(SeamRules.Between(views, hairlines, cell.Width)); + } + + /// The native tab strips by pane id. Internal for tests. + internal IReadOnlyDictionary Strips => _strips; + + /// The native scrolled-back bands by window id. Internal for tests. + internal IReadOnlyDictionary Bands => _bands; + + /// + /// Each pane's tab strip over the row the client left blank for it, and each scrolled-back band over its row: placed + /// from the cells, so a strip is exactly its pane's width and one row deep, and never over another cell. + /// + private void SyncPaneChrome(Size cell) + { + var said = _pointer.PaneChrome; + var strips = said?.Strips ?? []; + var bands = said?.Bands ?? []; + NativeTokens tokens = NativeTokens.Dark; + if (said is not null) + { + if (_paneTokens is not { } cached || !ReferenceEquals(cached.Theme, said.Theme)) + { + _paneTokens = (said.Theme, NativeTokens.For(said.Theme)); + } + + tokens = _paneTokens.Value.Tokens; + _view.DividerRule = new SKColor(tokens.Rule.R, tokens.Rule.G, tokens.Rule.B); + } + + var origin = CellOrigin; + Sync(_strips, strips.Select(s => (s.Model.PaneId, s.Row)), NewStrip, origin, cell); + foreach (var strip in strips) + { + _strips[strip.Model.PaneId].Show(strip, tokens, said!.Interactive, said.Drag); + } + + Sync(_bands, bands.Select(b => (b.Model.WindowId, b.Row)), () => new ScrollBandView(), origin, cell); + foreach (var band in bands) + { + _bands[band.Model.WindowId].Show(band, tokens, said!.Interactive); + } + } + + private void Sync(Dictionary controls, IEnumerable<(string Key, PaneRect Row)> wanted, Func create, Point origin, Size cell) + where T : Control + { + var rows = wanted.ToList(); + foreach (var stale in controls.Keys.Where(k => rows.All(r => r.Key != k)).ToList()) + { + Children.Remove(controls[stale]); + controls.Remove(stale); + } + + foreach (var (key, row) in rows) + { + if (!controls.TryGetValue(key, out var control)) + { + control = create(); + controls[key] = control; + Children.Insert(0, control); + } + + SetLeft(control, origin.X + (row.X * cell.Width)); + SetTop(control, origin.Y + (row.Y * cell.Height)); + control.Width = (row.Width * cell.Width) + RightOfGrid(row.X, row.Width, _view.Driver.Frame.Width, cell); + control.Height = row.Height * cell.Height; + } + } + + /// + /// The view's pixels right of the grid's last column (less than a cell; the cells' last run is carried over them), for + /// a native view whose cells end at that column: it runs on to the view's edge with them, or a tab strip's rule and a + /// panel's plane stop short of the window's edge by a strip that shows at any size not a whole number of cells. + /// + private double RightOfGrid(int x, int width, int columns, Size cell) => + columns > 0 && width > 0 && x + width >= columns + ? Math.Clamp(_view.Bounds.Width - (columns * cell.Width), 0, cell.Width) + : 0; + + /// The toasts over the cell area, their stack sitting on the row the client said the pane area ends at. + private void SyncToasts(ScreenFrame frame, Size cell) + { + var origin = CellOrigin; + SetLeft(_toasts, origin.X); + SetTop(_toasts, origin.Y); + _toasts.Width = Math.Max(0, frame.Width * cell.Width); + _toasts.Height = Math.Max(0, frame.Height * cell.Height); + var above = _noticeAboveRow > 0 && _noticeAboveRow < frame.Height ? _noticeAboveRow : Math.Max(0, frame.Height - 2); + _toasts.Bottom = above * cell.Height; + + // The native maps' legends, in the toasts' coordinates: a toast is stacked clear of one rather than over it. + var avoid = new List(); + foreach (var map in _maps.Values) + { + if (map.LegendRect(new Size(map.Width, map.Height)) is { } legend) + { + avoid.Add(legend.Translate(new Vector(GetLeft(map) - origin.X, GetTop(map) - origin.Y))); + } + } + + // The native quick bars and docked composers too: a quick bar's keys and the post being typed are what a toast must + // never sit on, so a toast is lifted above them as it is above a legend. + foreach (var view in _quickBars.Values.Cast().Concat(_composers.Values)) + { + if (view.IsVisible && view.Width > 0 && view.Height > 0) + { + avoid.Add(new Rect(GetLeft(view) - origin.X, GetTop(view) - origin.Y, view.Width, view.Height)); + } + } + + _toasts.Avoid = avoid; + + // Hidden, not stacked under it, while something modal is in front: a settings window is a window of its own and a + // toast under it showed as a clipped fragment past its edge, and ⌃P and the modals have a scrim the toast would sit + // on. The status row still says the notice, and the toast keeps its clock, so it is gone when its time is. + _toasts.IsVisible = !(ToastsHidden?.Invoke() ?? false) && _pointer.Chrome is not { Interactive: false }; + } + + /// Whether something modal the window draws is in front, which hides the toasts. Set by the main window. + internal Func? ToastsHidden { get; set; } + + /// The notice toasts. Internal for tests. + internal NoticeToasts Toasts => _toasts; + + /// + /// The native header across the top and the sidebar down the left, when the window draws them — always, a modal in + /// front included: the client draws its modals in the cells, which no longer reach under either, so the chrome + /// stays where a desktop app's stays and only goes inert (). ⌃B armed is said + /// by the header itself, and a collapsed rail is the sidebar's slim strip. + /// + private void SyncChrome() + { + var chrome = _pointer.Chrome; + SyncInsets(chrome?.Sidebar.Collapsed ?? false); + var width = Bounds.Width; + var height = Bounds.Height; + var top = _chrome.Header ? NativeLayout.HeaderHeight : 0; + var bottom = _chrome.Status ? NativeLayout.StatusHeight : 0; + + // The header buttons' slots, plus the resize band, stay clear for the buttons drawn above the header. + _header.ReserveRight = _chrome.Header + ? (_pointer.Actions.Count * (NativeLayout.ButtonSize + NativeLayout.ButtonGap)) + WindowChrome.EdgeBand + 4 + : 0; + + // Before the client has said anything the bands are drawn empty, on the view's own colours, so a window never + // opens with a hole where its header will be. + var ink = chrome is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(chrome.Palette.Ink); + Place(_header, _chrome.Header, new Rect(0, 0, width, NativeLayout.HeaderHeight), chrome, + chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.HeaderPlane), ink); + Place(_sidebar, _chrome.Rail, new Rect(0, top, _view.Margin.Left, Math.Max(0, height - top - bottom)), chrome, + chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.RailPlane), ink); + + // The footer across the whole width under the cells and the sidebar, and the field's strip: the header's plane, as + // the mockups' footer and command-line strip are their header's. + if (chrome is not null) + { + var plane = NativeChromeView.Parse(chrome.Palette.HeaderPlane); + _view.FooterPlane = new SKColor(plane.R, plane.G, plane.B); + _footer.Plane = plane; + } + + if (_chrome.Status && width > 0 && height > bottom) + { + SetLeft(_footer, 0); + SetTop(_footer, height - bottom); + _footer.Width = width; + _footer.Height = bottom; + _footer.Show(_pointer.Status); + _footer.IsVisible = true; + } + else + { + _footer.IsVisible = false; + } + } + + private static void Place(NativeChromeView view, bool drawn, Rect area, ChromeOverlay? chrome, Color plane, Color ink) + { + if (!drawn || area.Width <= 0 || area.Height <= 0) + { + view.IsVisible = false; + return; + } + + SetLeft(view, area.X); + SetTop(view, area.Y); + view.Width = area.Width; + view.Height = area.Height; + view.Show(chrome, plane, ink); + view.IsVisible = true; + } + + private void SyncButtons(ScreenFrame frame, Size cell) + { + var actions = _pointer.Actions; + foreach (var stale in _buttons.Keys.Where(id => actions.All(a => a.Id != id)).ToList()) + { + Children.Remove(_buttons[stale]); + _buttons.Remove(stale); + } + + var origin = CellOrigin; + for (var i = 0; i < actions.Count; i++) + { + var action = actions[i]; + if (!_buttons.TryGetValue(action.Id, out var button)) + { + button = new HeaderButton(action.Id); + button.Click += () => OpenMenu(action, button); + AutomationProperties.SetName(button, action.Label); + ToolTip.SetTip(button, NativeKeys.Words(action.Hint is { } hint ? $"{action.Label} ({hint})" : action.Label)); + _buttons[action.Id] = button; + Children.Add(button); + } + + if (_chrome.Header) + { + // In the window's own header: right to left from its end, clear of the resize band, on its plane. + var side = NativeLayout.ButtonSize; + button.Width = side; + button.Height = side; + SetLeft(button, Bounds.Width - WindowChrome.EdgeBand - 4 - ((i + 1) * (side + NativeLayout.ButtonGap)) + NativeLayout.ButtonGap); + SetTop(button, Math.Round((NativeLayout.HeaderHeight - side) / 2)); + var said = _pointer.Chrome; + button.Ink = said is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(said.Palette.Ink); + button.Plane = said is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(said.Palette.HeaderPlane); + button.IsVisible = Bounds.Width > 0; + continue; + } + + // Right to left from the header's end, one slot of blank cells each. Shown only while every cell of + // its slot is blank, so a button never covers a word of the header. + var first = frame.Width - (ButtonCells * (i + 1)); + var blank = first >= 0 && frame.Height > 0 + && Enumerable.Range(first, ButtonCells).All(x => WindowChrome.IsMoveHandle(frame, new System.Drawing.Point(x, 0))); + button.IsVisible = blank; + if (!blank) + { + continue; + } + + // Clear of the window's resize band at the right edge, which a press there is for, and a little over + // half a cell clear of whatever the header says before its blank cells, so it never touches a word. + var gridRight = (first + ButtonCells) * cell.Width; + var right = (Bounds.Width > 0 ? Math.Min(gridRight, Bounds.Width) : gridRight) - WindowChrome.EdgeBand - 1; + var size = Math.Max(8, Math.Min(cell.Height + 2, right - ((first + 0.6) * cell.Width))); + button.Width = size; + button.Height = Math.Max(4, cell.Height - 2); + SetLeft(button, origin.X + right - size); + SetTop(button, origin.Y + 1); + var behind = _view.Renderer.Background(frame[frame.Width - 1, 0]); + button.Ink = Colour(_view.Renderer.DefaultForeground); + button.Plane = Colour(behind); + } + } + + private void SyncPanels(ScreenFrame frame, Size cell) + { + var text = Colour(_view.Renderer.DefaultForeground); + Place( + _gauges, + _pointer.Panels.Where(p => p.PanelId == PanelIds.Vitals && p.Vitals is { IsEmpty: false }), + () => new VitalsGauges(), + frame, + cell, + (gauges, panel, plane) => + { + gauges.Seams = (panel.RuleAbove, panel.RuleBelow); + gauges.Show(panel.Vitals!, panel.Ink, plane, text, cell.Height); + }); + Place( + _maps, + _pointer.Panels.Where(p => p.PanelId == PanelIds.Map && p.Map is not null), + () => new MapCanvas(_view), + frame, + cell, + (map, panel, plane) => map.Show(panel.Map!, panel.Ink, plane, text, panel.WalkTo, panel.SelectRoom, panel.EditMap, panel.LookMap, MapLean(plane))); + Place( + _whoHere, + _pointer.Panels.Where(p => p.PanelId == PanelIds.WhoHere && p.WhoHere is not null), + () => new WhoHereList(_view), + frame, + cell, + (list, panel, plane) => list.Show(panel.WhoHere!, panel.Ink, plane, text, cell.Height, panel.Pick)); + Place( + _quickBars, + _pointer.Panels.Where(p => p.PanelId == PanelIds.QuickBar && p.QuickBar is not null), + () => new QuickBarButtons(_view), + frame, + cell, + (bar, panel, plane) => + { + bar.Seams = (panel.RuleAbove, panel.RuleBelow); + bar.Show(panel.QuickBar!, panel.Ink, plane, text, cell.Height, panel.Pick, panel.Dispatch); + }); + + // A collapsed panel's bar, at the foot of its column (HostChrome.Strips). + Place( + _collapsedBars, + _pointer.Panels.Where(p => p.Collapsed is not null), + () => new CollapsedBarView(_view), + frame, + cell, + (bar, panel, plane) => bar.Show(panel.Collapsed!, panel.Ink, plane, text, panel.Expand)); + + // The docked composer: a native editor over its pane, the cell editor still drawn (and still the draft) under it. + Place( + _composers, + _pointer.Panels.Where(p => p.PanelId == PanelIds.Composer && p.Composer is not null && p.ComposerSession is not null), + () => new ComposerView(_view, fullScreen: false), + frame, + cell, + (composer, panel, plane) => composer.Show(panel.Composer!, panel.ComposerSession!, panel.Ink, plane, text)); + SyncPopOuts(frame, cell); + + // A pane a native view covers draws no overlay scrollbar of its own: its cells are only the view's backing (the + // docked composer's cell editor scrolls to its caret), and on a short pane — the default window at 150% — the + // bar showed over the divider row beside the view. + _view.CoveredWindows = _gauges.Keys.Concat(_maps.Keys).Concat(_whoHere.Keys).Concat(_quickBars.Keys) + .Concat(_collapsedBars.Keys).Concat(_composers.Keys).ToHashSet(StringComparer.Ordinal); + } + + /// + /// The colour a map's shades lean toward (): the focus edge of the tokens for the theme the + /// client last said, else of the built-in variant the pane's plane belongs to. + /// + private Color MapLean(Color plane) + { + if (_pointer.PaneChrome is { } said) + { + if (_paneTokens is not { } cached || !ReferenceEquals(cached.Theme, said.Theme)) + { + _paneTokens = (said.Theme, NativeTokens.For(said.Theme)); + } + + return _paneTokens.Value.Tokens.Focus; + } + + return NativeTokens.LeanFor(plane); + } + + /// Whether a panel is a one-row strip that keeps a corner for its pop-out button rather than sitting under it. + internal static bool ReservesPopOut(PanelOverlay panel) => panel.PopOut is not null && panel.Rect.Height == 1; + + /// The pixels a one-row strip leaves at its right end for the button. + private static double PopOutReserve(Size cell) => PopOutSize(cell) + 6; + + private static double PopOutSize(Size cell) => Math.Clamp(cell.Height - 2, 12, 18); + + /// Where the pointer last was over the window, in this layer's coordinates; null once it left. + private Point? _pointerAt; + + /// + /// Shows each pop-out button while the pointer is over its panel and hides it otherwise (D3: an always-on button lay + /// over the panel's own text). A one-row strip's reserved corner stays, as the pane's plane, with the icon on hover. + /// + private void SyncPopOutHover() + { + foreach (var button in _popOuts.Values) + { + var over = _pointerAt is { } at && button.PanelBounds.Contains(at); + button.Hovering = over; + button.IsVisible = over || button.Reserved; + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (TopLevel.GetTopLevel(this) is { } top) + { + top.AddHandler(PointerMovedEvent, OnWindowPointerMoved, global::Avalonia.Interactivity.RoutingStrategies.Tunnel, handledEventsToo: true); + top.PointerExited += OnWindowPointerExited; + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (TopLevel.GetTopLevel(this) is { } top) + { + top.RemoveHandler(PointerMovedEvent, OnWindowPointerMoved); + top.PointerExited -= OnWindowPointerExited; + } + + base.OnDetachedFromVisualTree(e); + } + + private void OnWindowPointerMoved(object? sender, PointerEventArgs e) + { + _pointerAt = e.GetPosition(this); + SyncPopOutHover(); + } + + private void OnWindowPointerExited(object? sender, PointerEventArgs e) + { + _pointerAt = null; + SyncPopOutHover(); + } + + /// Moves the pointer for a test, as the window's own pointer movement would. + internal void SimulatePointer(Point? at) + { + _pointerAt = at; + SyncPopOutHover(); + } + + /// + /// A pop-out button in the top right corner of every docked panel the client says can float, above the panel's + /// own view and below the toasts, shown while the pointer is over the panel. A panel that cannot float (or a host + /// that cannot) publishes no callback, and gets no button. + /// + private void SyncPopOuts(ScreenFrame frame, Size cell) + { + var wanted = _pointer.Panels.Where(p => p.PopOut is not null && p.Rect.Width > 0 && p.Rect.Height > 0).ToList(); + foreach (var stale in _popOuts.Keys.Where(id => wanted.All(p => p.WindowId != id)).ToList()) + { + Children.Remove(_popOuts[stale]); + _popOuts.Remove(stale); + } + + var origin = CellOrigin; + foreach (var panel in wanted) + { + if (!_popOuts.TryGetValue(panel.WindowId, out var button)) + { + button = new PopOutButton(); + _popOuts[panel.WindowId] = button; + Children.Insert(Math.Max(0, Children.IndexOf(_toasts)), button); + } + + var size = PopOutSize(cell); + var rect = panel.Rect; + var right = origin.X + ((rect.X + rect.Width) * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell); + button.PanelBounds = new Rect(origin.X + (rect.X * cell.Width), origin.Y + (rect.Y * cell.Height), rect.Width * cell.Width, rect.Height * cell.Height); + button.Reserved = ReservesPopOut(panel); + if (button.Reserved) + { + // The strip's reserved end: the whole row's height, on the pane's own plane. + var reserve = PopOutReserve(cell); + button.Width = reserve; + button.Height = rect.Height * cell.Height; + SetLeft(button, right - reserve); + SetTop(button, origin.Y + (rect.Y * cell.Height)); + var inside = rect.X >= 0 && rect.Y >= 0 && rect.X < frame.Width && rect.Y < frame.Height; + button.Plane = Colour(inside ? _view.Renderer.Background(frame[rect.X + rect.Width - 1, rect.Y]) : _view.Renderer.DefaultBackground); + } + else + { + button.Width = size; + button.Height = size; + SetLeft(button, right - size - 3); + SetTop(button, origin.Y + (rect.Y * cell.Height) + 2); + button.Plane = null; + } + + button.Show(panel, Colour(_view.Renderer.DefaultForeground)); + } + + SyncPopOutHover(); + } + + /// + /// One native control per panel window, over that pane's output rectangle; a control whose panel left the + /// screen goes with it. + /// + private void Place( + Dictionary controls, + IEnumerable panels, + Func create, + ScreenFrame frame, + Size cell, + Action show) + where T : Control + { + var wanted = panels.ToList(); + foreach (var stale in controls.Keys.Where(id => wanted.All(p => p.WindowId != id)).ToList()) + { + Children.Remove(controls[stale]); + controls.Remove(stale); + } + + foreach (var panel in wanted) + { + if (!controls.TryGetValue(panel.WindowId, out var control)) + { + control = create(); + controls[panel.WindowId] = control; + + // Under the header buttons, so a button is never covered by a panel. + Children.Insert(0, control); + } + + var rect = panel.Rect; + var origin = CellOrigin; + SetLeft(control, origin.X + (rect.X * cell.Width)); + SetTop(control, origin.Y + (rect.Y * cell.Height)); + // A one-row strip that can pop out leaves its right end to the button (ReservesPopOut), so the button never + // lies over its text (`88 / 100`, `F4 edits keys`); the button paints the pane's plane over the cells there. + control.Width = Math.Max(0, (rect.Width * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell) + - (ReservesPopOut(panel) ? PopOutReserve(cell) : 0)); + control.Height = (rect.Height * cell.Height) + BelowGrid(frame, rect, cell); + + // The pane's own plane, read off the frame, so the native view sits on whatever the pane wears — its + // character's tint, the focus lift — without the client having to say it twice. + var inside = rect.X >= 0 && rect.Y >= 0 && rect.X < frame.Width && rect.Y < frame.Height; + var plane = inside ? _view.Renderer.Background(frame[rect.X, rect.Y]) : _view.Renderer.DefaultBackground; + show(control, panel, Colour(plane)); + } + } + + /// + /// The pixels under the grid a panel also covers: where the window draws the command line's field in the output's + /// column, the grid is shorter than the view and the columns beside the field + /// are filled with their last row's plane down to the footer. A panel whose pane ends on the grid's last row in such a + /// column (a collapsed panel's bar, the map) runs down over that filler, and over the view's leftover pixels under it + /// (less than a row), so it meets the footer as the mockups draw it. + /// + private double BelowGrid(ScreenFrame frame, SharpMUTerm.Core.Workspaces.PaneRect rect, Size cell) => + _chrome.Input && frame.Band is { } band && rect.Y + rect.Height >= frame.Height && rect.Width > 0 + && !band.Spans(rect.X, frame.Width) && !band.Spans(rect.X + rect.Width - 1, frame.Width) + ? NativeLayout.FieldReserve + Math.Clamp(_view.Bounds.Height - ((frame.Height * cell.Height) + NativeLayout.FieldReserve), 0, cell.Height) + : 0; + + /// The client builds the menu on its thread; it is opened here, under the button. + private void OpenMenu(HeaderAction action, HeaderButton button) => + action.Menu(model => Dispatcher.UIThread.Post(() => + { + if (model.Entries.Count == 0) + { + return; + } + + var menu = NativeMenu.Build(model, action.Dispatch, PlacementMode.BottomEdgeAlignedRight, button); + menu.Closed += (_, _) => _view.Focus(); + OpenedMenu = menu; + menu.Open(button); + })); + + internal static Color Colour(SKColor colour) => Color.FromRgb(colour.Red, colour.Green, colour.Blue); +} + +/// +/// A small icon-only button drawn by the window over blank header cells. Drawn rather than templated, so it looks +/// the same under any Avalonia theme and its hover plate is the header's own colour lifted, not a theme's grey. +/// It never takes keyboard focus: typing stays with the client. +/// +internal sealed class HeaderButton : Control +{ + private bool _pressed; + + public HeaderButton(string actionId) + { + ActionId = actionId; + Focusable = false; + Cursor = new Cursor(StandardCursorType.Hand); + } + + public string ActionId { get; } + + public Color Ink { get; set; } = Colors.White; + + public Color Plane { get; set; } = Colors.Black; + + /// Raised on a left click. + public event Action? Click; + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + InvalidateVisual(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + InvalidateVisual(); + } + + // Every pointer event over the button is the window's: handled here, so nothing routes on to the client. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + _pressed = e.GetCurrentPoint(this).Properties.IsLeftButtonPressed; + e.Handled = true; + InvalidateVisual(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + e.Handled = true; + var click = _pressed && e.InitialPressMouseButton == MouseButton.Left && new Rect(Bounds.Size).Contains(e.GetPosition(this)); + _pressed = false; + InvalidateVisual(); + if (click) + { + Click?.Invoke(); + } + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + e.Handled = true; + } + + /// Clicks the button as a left press and release would. Internal for tests. + internal void PerformClick() => Click?.Invoke(); + + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + + // Bare at rest, as the mockups' icon button: a plate only under the pointer and while pressed. The plate is drawn + // at rest too, fully transparent, because what a control draws is what the pointer hits: with nothing drawn a + // press would fall through to the client's cells. + var plate = (byte)(_pressed ? 0x55 : IsPointerOver ? 0x38 : 0); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(plate, Ink.R, Ink.G, Ink.B)), null, bounds, NativeTokens.RadiusControl, NativeTokens.RadiusControl); + + // The layouts glyph: a frame split into a wide pane and a column of two, the shape of a layout; the header's + // quiet ink at rest (the mockups' #9aa4b8), the full ink under the pointer. + var pen = new Pen(new SolidColorBrush(Color.FromArgb(IsPointerOver || _pressed ? (byte)0xE0 : (byte)0xA8, Ink.R, Ink.G, Ink.B)), 1.25); + var w = Math.Min(bounds.Width - 6, 14); + var h = Math.Min(bounds.Height - 6, 11); + var x = Math.Round((bounds.Width - w) / 2) + 0.5; + var y = Math.Round((bounds.Height - h) / 2) + 0.5; + var icon = new Rect(x, y, w, h); + context.DrawRectangle(null, pen, icon, 1.5, 1.5); + var split = Math.Round(x + (w * 0.58)) + 0.5; + context.DrawLine(pen, new Point(split, y), new Point(split, y + h)); + var middle = Math.Round(y + (h / 2)) + 0.5; + context.DrawLine(pen, new Point(split, middle), new Point(x + w, middle)); + } +} + +/// +/// How big the window's own chrome is, in logical pixels: the header band across the top and the sidebar down the +/// left, each the size of a desktop app's rather than a cell's. The client is told only that it draws neither +/// (); the sizes are the window's, and the cell grid is the rest of the window. +/// +internal static class NativeLayout +{ + /// + /// The header band's height: the mockup's 44, a multiple of 4 like every chrome size here, so the cells below it + /// start on a whole device pixel at 125% and 150% (' spacing scale). + /// + public const double HeaderHeight = 44; + + /// + /// The sidebar's width, open: with the mockups' 252 px (a 232 px column and its 10 px + /// padding either side), so the panes start where the mockups' do, one rule further on. + /// + public const double SidebarWidth = 244; + + /// The sidebar's width folded to its strip (⌃B b, or its own fold button). + public const double SidebarSlim = 48; + + /// + /// The sidebar's breathing room before the panes: its own plane carried a few pixels on, so a pane's first column + /// of text does not sit hard against the sidebar's edge. Part of the sidebar, never of a pane, so it costs no cell. + /// + public const double SidebarGutter = NativeTokens.Space8; + + /// The status footer's height (). + public const double StatusHeight = 28; + + /// The footer's text inset from the window's edges. + public const double FooterPadX = NativeTokens.PanelInset; + + /// The command line's strip: the padding above the field and below it (). + public const double FieldOuter = NativeTokens.Space10; + + /// The field's own padding above its first row and below its last: the mockup's 9 px and its border. + public const double FieldInner = NativeTokens.Space10; + + /// How far the field's edge sits outside the client's padding columns, horizontally. + public const double FieldInsetX = NativeTokens.Space12; + + /// + /// The field's edge from the grid's left and right edges: the mockup's 20 px, as far as the client's padding columns + /// leave room for the field's own inner padding ( at least) before the prompt. + /// + public const double FieldSide = NativeTokens.Space20; + + /// The pixels the strip adds around its rows: its padding and the field's, above and below. + public const double FieldStrip = 2 * (FieldOuter + FieldInner); + + /// How much lower the bars' rows are drawn than their cells: the strip's padding and the field's. + public const double FieldTop = FieldOuter + FieldInner; + + /// The vertical pixels the field adds around the rows it frames, which the grid gives up. + public const double FieldReserve = FieldStrip; + + /// A header button's side. + public const double ButtonSize = NativeTokens.ControlHeightSmall; + + /// The gap between two header buttons. + public const double ButtonGap = NativeTokens.Space8; + + /// The cells' margin in the window: the space the native header and sidebar take. + public static Thickness Insets(HostChrome chrome, bool collapsed) => new( + chrome.Rail ? (collapsed ? SidebarSlim : SidebarWidth) + SidebarGutter : 0, + chrome.Header ? HeaderHeight : 0, + 0, + chrome.Status ? StatusHeight : 0); +} diff --git a/src/SharpMUTerm.Avalonia/DictionaryOfferView.cs b/src/SharpMUTerm.Avalonia/DictionaryOfferView.cs new file mode 100644 index 00000000..13bc5c40 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/DictionaryOfferView.cs @@ -0,0 +1,126 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using SharpMUTerm.Client.Modals; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's offer of a spelling dictionary: the question, what will be fetched from where and saved where (the +/// cells' consent sentences, ), and Download / Not now. Download ends in the +/// client's InstallDictionary, the method the cells' ⏎ ends in; nothing is fetched before it. +/// +internal sealed class DictionaryOfferView : DockPanel, IModalView +{ + private readonly DictionaryOfferSession _session; + private readonly Action _done; + private readonly Button _download; + private readonly Button _later; + + public DictionaryOfferView(DictionaryOfferSession session, NativeTokens colours, Action done) + { + _session = session; + _done = done; + var model = session.Model; + + var heading = ModalParts.Heading(model.Question, colours); + heading.Margin = new Thickness(NativeTokens.Space24, NativeTokens.Space16, NativeTokens.Space24, NativeTokens.Space8); + SetDock(heading, Dock.Top); + Children.Add(heading); + + var facts = new StackPanel { Margin = new Thickness(NativeTokens.Space24, NativeTokens.Space2, NativeTokens.Space24, NativeTokens.Space4), Spacing = NativeTokens.Space6 }; + foreach (var line in model.Lines) + { + facts.Children.Add(new TextBlock + { + Text = NativeKeys.Words(line), + Foreground = colours.Brush(line.Length == 0 ? colours.Dim : colours.Text), + TextWrapping = TextWrapping.Wrap, + FontSize = NativeTokens.Body, + }); + } + + SetDock(facts, Dock.Top); + Children.Add(facts); + + var footer = ModalParts.Footer(DictionaryOfferModel.Keys, colours); + SetDock(footer, Dock.Bottom); + + _download = ModalParts.Button("Download", "Enter", colours, Accept, accent: true); + _download.IsEnabled = model.Folder is not null && !model.Installed; + _later = ModalParts.Button("Not now", "Esc", colours, Decline); + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = NativeTokens.Space10, + Margin = new Thickness(NativeTokens.Space24, NativeTokens.Space14, NativeTokens.Space24, 0), + }; + buttons.Children.Add(_download); + buttons.Children.Add(_later); + SetDock(buttons, Dock.Bottom); + Children.Add(footer); + Children.Add(buttons); + Children.Add(new Panel()); + + AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + } + + public string Title => "Spelling dictionary"; + + public double PreferredWidth => 560; + + public bool Centred => true; + + /// The two buttons, for a test. + public Button DownloadButton => _download; + + public Button LaterButton => _later; + + public void FocusFirst() => (_download.IsEnabled ? _download : _later).Focus(); + + public void Detach() + { + } + + /// Download: the session ends and the client fetches and installs. + public void Accept() + { + _session.Accept(); + _done(); + } + + /// Not now: nothing is fetched. + public void Decline() + { + _session.Dismiss(); + _done(); + } + + private void OnKey(object? sender, KeyEventArgs e) + { + if (ModalParts.FocusedButtonTakes(e)) + { + return; + } + + if (e.KeyModifiers != KeyModifiers.None) + { + return; + } + + if (e.Key == Key.Escape) + { + e.Handled = true; + Decline(); + } + else if (e.Key == Key.Enter && e.Source is not Button) + { + e.Handled = true; + Accept(); + } + } +} diff --git a/src/SharpMUTerm.Avalonia/FittedText.cs b/src/SharpMUTerm.Avalonia/FittedText.cs new file mode 100644 index 00000000..868d9a04 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/FittedText.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SharpMUTerm.Avalonia; + +/// +/// A line of text with shorter forms, drawn in the longest that fits the width it is given — the client's own words +/// are never cut to an ellipsis when a shorter wording says the same (the cells' KeyLadder rule). The last form +/// is still trimmed if even it does not fit. The window's text is laid out in logical pixels, so a column that fits at +/// one display scale fits at all of them; what moves the width is the window's size, which at 125% and 150% on a +/// 1080p screen is smaller than the 1440 px the mockups were drawn at. +/// +internal sealed class FittedText : TextBlock +{ + private IReadOnlyList _forms = []; + + public FittedText() + { + TextTrimming = TextTrimming.CharacterEllipsis; + } + + /// The wordings, longest first; setting them shows the first until the next measure picks one. + public IReadOnlyList Forms + { + get => _forms; + set + { + _forms = value; + Text = value.Count > 0 ? value[0] : string.Empty; + InvalidateMeasure(); + } + } + + /// The form logical pixels hold: the first that fits, else the last. + internal string Pick(double width) + { + if (_forms.Count == 0) + { + return string.Empty; + } + + var typeface = new Typeface(FontFamily, FontStyle, FontWeight); + foreach (var form in _forms) + { + var measured = new FormattedText(form, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, FontSize, null); + if (measured.WidthIncludingTrailingWhitespace <= width + 0.01) + { + return form; + } + } + + return _forms[^1]; + } + + protected override Size MeasureOverride(Size availableSize) + { + if (_forms.Count > 1 && !double.IsInfinity(availableSize.Width)) + { + var room = availableSize.Width - Padding.Left - Padding.Right; + var pick = Pick(room); + if (!string.Equals(pick, Text, StringComparison.Ordinal)) + { + Text = pick; + } + } + + return base.MeasureOverride(availableSize); + } +} diff --git a/src/SharpMUTerm.Avalonia/FloatingPanels.cs b/src/SharpMUTerm.Avalonia/FloatingPanels.cs new file mode 100644 index 00000000..ad5145c0 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/FloatingPanels.cs @@ -0,0 +1,612 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Client.Panels; +using SharpMUTerm.Core.Layouts; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window host's : one per panel the client says is +/// popped out, owned by the main window, opened where it was left (clamped to the screens there are now) or beside the +/// main window, and closed when the client docks it. The client calls in on its own thread; everything here is posted +/// to the window's. +/// +internal sealed class FloatingPanelPresenter : IFloatingPanels +{ + private readonly Dictionary _windows = new(StringComparer.Ordinal); + private IReadOnlyList _panels = Array.Empty(); + private Window? _owner; + private TerminalView? _view; + private bool _attached; + + /// What the client last said is floating; any thread. + public IReadOnlyList Panels => Volatile.Read(ref _panels); + + /// The open floating windows by panel window id. Window's thread; internal for tests. + internal IReadOnlyDictionary Windows => _windows; + + /// + /// The screens' working areas in screen pixels, which a remembered place is clamped to. The owner's screens by + /// default; a test hands in its own. + /// + internal Func>? Screens { get; set; } + + /// Raised on the client's thread when changes. + public event Action? Changed; + + /// Where the windows belong and whose cells their keys go to. On the window's thread. + public void Attach(Window? owner, TerminalView view) + { + _owner = owner; + _view = view; + _attached = true; + Sync(); + } + + /// The main window is going: every floating window closes with it and docks nothing. + public void Detach() + { + _attached = false; + foreach (var window in _windows.Values.ToList()) + { + window.CloseForClient(); + } + + _windows.Clear(); + } + + public void Show(IReadOnlyList panels) + { + Volatile.Write(ref _panels, panels); + Changed?.Invoke(); + if (_attached) + { + Dispatcher.UIThread.Post(Sync); + } + } + + /// Opens, updates and closes windows to match what the client last said. On the window's thread. + internal void Sync() + { + if (!_attached || _view is null) + { + return; + } + + var panels = Panels; + foreach (var gone in _windows.Keys.Where(id => panels.All(p => p.Model.WindowId != id)).ToList()) + { + _windows[gone].CloseForClient(); + _windows.Remove(gone); + } + + foreach (var panel in panels) + { + if (_windows.TryGetValue(panel.Model.WindowId, out var open)) + { + open.Update(panel, _view.CellExtent.Height); + if (panel.Model.Bounds.IsPlaced && !open.IsAt(panel.Model.Bounds)) + { + open.Place(Clamp(panel.Model.Bounds), Scaling()); + } + + continue; + } + + var window = new FloatingPanelWindow(panel, _view); + window.Update(panel, _view.CellExtent.Height); + var bounds = panel.Model.Bounds.IsPlaced ? panel.Model.Bounds : BesideOwner(panel.Model.PanelId); + window.Place(Clamp(bounds), Scaling()); + window.Closed += (_, _) => + { + if (_windows.TryGetValue(panel.Model.WindowId, out var known) && ReferenceEquals(known, window)) + { + _windows.Remove(panel.Model.WindowId); + } + }; + _windows[panel.Model.WindowId] = window; + + // Opened without taking the keyboard: the player was typing on the command line, and still is. + if (_owner is { IsVisible: true } owner) + { + window.Show(owner); + } + else + { + window.Show(); + } + + window.ReportPlace(); + } + } + + private double Scaling() => _owner?.DesktopScaling is > 0 and var scale ? scale : 1; + + /// Clamped to the screens a person can see now (a monitor unplugged since is not one of them). + private FloatingBounds Clamp(FloatingBounds bounds) => bounds.ClampTo(Screens?.Invoke() ?? OwnerScreens()); + + private IReadOnlyList OwnerScreens() => + _owner?.Screens.All.Select(s => new FloatingBounds(s.WorkingArea.X, s.WorkingArea.Y, s.WorkingArea.Width, s.WorkingArea.Height)).ToList() + ?? (IReadOnlyList)Array.Empty(); + + /// A first place for a panel: just right of the main window, top-aligned, sized for what it shows. + private FloatingBounds BesideOwner(string panelId) + { + var scale = Scaling(); + var (width, height) = panelId switch + { + PanelIds.Map => (460.0, 460.0), + PanelIds.Vitals => (300.0, 250.0), + PanelIds.WhoHere => (280.0, 380.0), + PanelIds.QuickBar => (560.0, 76.0), + _ => (360.0, 300.0), + }; + + var x = 80.0; + var y = 80.0; + if (_owner is { } owner) + { + // Right of the main window; left of it when the right has no room on the screen and the left has, else + // right anyway and the clamp brings it back over the main window's edge. + var offset = _windows.Count * 28; + var left = owner.Position.X; + var right = owner.Position.X + (owner.Bounds.Width * scale); + x = right + 8 + offset; + y = owner.Position.Y + offset; + var screens = Screens?.Invoke() ?? OwnerScreens(); + var screen = screens.FirstOrDefault(s => left >= s.X && left < s.X + s.Width) ?? screens.FirstOrDefault(); + if (screen is not null && x + (width * scale) > screen.X + screen.Width && left - 8 - (width * scale) - offset >= screen.X) + { + x = left - 8 - (width * scale) - offset; + } + } + + return new FloatingBounds(x, y, width * scale, height * scale); + } +} + +/// +/// A panel popped out into a window of its own: the panel's own native view (the map, the vitals gauges, who is here, +/// the quick bar), drawn from the same model and with the same actions as when it is docked, under a slim bar saying +/// whose it is with a way back. It never takes the keyboard on opening, and a key pressed while it is in front goes to +/// the command line, so the player keeps typing. Closing it docks the panel back. +/// +internal sealed class FloatingPanelWindow : Window +{ + private const double BarHeight = 26; + + private readonly TerminalView _view; + private readonly Control _content; + private readonly TextBlock _title; + private readonly FrameButton _dock; + private readonly Border _bar; + private readonly DispatcherTimer _report; + private bool _closingForClient; + private bool _placing; + + public FloatingPanelWindow(FloatingPanel panel, TerminalView view) + { + _view = view; + Panel = panel; + NativeTokens.ApplyTextRendering(this); + ShowActivated = false; + ShowInTaskbar = false; + CanResize = true; + MinWidth = 160; + MinHeight = 56; + WindowStartupLocation = WindowStartupLocation.Manual; + + _content = panel.Model.PanelId switch + { + PanelIds.Map => new MapCanvas(view) { RightClick = ShowOwnMenu }, + PanelIds.Vitals => new VitalsGauges(), + PanelIds.WhoHere => new WhoHereList(view) { RightClick = ShowOwnMenu }, + PanelIds.QuickBar => new QuickBarButtons(view) { RightClick = ShowOwnMenu }, + _ => new Border(), + }; + _content.Focusable = false; + + _title = new TextBlock { VerticalAlignment = VerticalAlignment.Center, FontSize = NativeTokens.Body, Margin = new Thickness(NativeTokens.Space10, 0, 0, 0) }; + _dock = new FrameButton("Dock") { Width = 52, Height = 20, Margin = new Thickness(0, 0, NativeTokens.Space6, 0), VerticalAlignment = VerticalAlignment.Center }; + _dock.Click += () => Panel.Dock(); + AutomationProperties.SetName(_dock, "Dock back into the layout"); + ToolTip.SetTip(_dock, "Dock back into its place in the layout"); + + var bar = new DockPanel { Height = BarHeight, LastChildFill = true }; + DockPanel.SetDock(_dock, Dock.Right); + bar.Children.Add(_dock); + bar.Children.Add(_title); + _bar = new Border { Child = bar }; + + var root = new DockPanel { LastChildFill = true }; + DockPanel.SetDock(_bar, Dock.Top); + root.Children.Add(_bar); + root.Children.Add(_content); + Content = root; + + _report = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _report.Tick += (_, _) => + { + _report.Stop(); + ReportPlace(); + }; + PositionChanged += (_, _) => Moved(); + Resized += (_, _) => Moved(); + Closing += (_, _) => + { + // The player closed it: the panel goes back to its place, never away. + if (!_closingForClient) + { + Panel.Dock(); + } + }; + + // A right click anywhere the panel's own view does not answer (the vitals gauges take no input) is the window's menu. + PointerPressed += (_, e) => + { + if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed) + { + e.Handled = true; + ShowOwnMenu(e.GetPosition(this)); + } + }; + } + + /// What it is showing. + public FloatingPanel Panel { get; private set; } + + /// The panel's own native view inside it. Internal for tests. + internal Control PanelView => _content; + + /// The Dock button. Internal for tests. + internal FrameButton DockButton => _dock; + + /// The last menu the window opened. Internal for tests. + internal ContextMenu? OpenedMenu { get; private set; } + + /// Shows : its title, the client's colours, and the panel's model and actions. + public void Update(FloatingPanel panel, double cellHeight) + { + Panel = panel; + Title = panel.Model.WindowTitle; + var plane = Parse(panel.Plane, Colors.Black); + var text = Parse(panel.Text, Colors.White); + Background = new SolidColorBrush(plane); + _bar.Background = new SolidColorBrush(Shade(plane, text, 0.08)); + _title.Foreground = new SolidColorBrush(Color.FromArgb(0xD0, text.R, text.G, text.B)); + _title.Text = panel.Model.WindowTitle; + _dock.Ink = text; + _dock.Plane = plane; + + var content = panel.Content; + switch (_content) + { + case MapCanvas map when content.Map is { } model: + map.Show(model, content.Ink, plane, text, content.WalkTo, content.SelectRoom, content.EditMap, content.LookMap, NativeTokens.LeanFor(plane)); + break; + case VitalsGauges gauges when content.Vitals is { } vitals: + gauges.Show(vitals, content.Ink, plane, text, cellHeight); + break; + case WhoHereList list when content.WhoHere is { } who: + list.Show(who, content.Ink, plane, text, cellHeight, content.Pick); + break; + case QuickBarButtons bar when content.QuickBar is { } quick: + bar.Show(quick, content.Ink, plane, text, cellHeight, content.Pick, content.Dispatch); + break; + } + } + + /// Puts the window at (screen pixels), without reporting it back as a move. + public void Place(FloatingBounds bounds, double scaling) + { + _placing = true; + try + { + Position = new PixelPoint((int)Math.Round(bounds.X), (int)Math.Round(bounds.Y)); + Width = Math.Max(MinWidth, bounds.Width / scaling); + Height = Math.Max(MinHeight, bounds.Height / scaling); + } + finally + { + _placing = false; + } + } + + /// Where the window is now, in screen pixels. + public FloatingBounds CurrentBounds() + { + var scaling = DesktopScaling > 0 ? DesktopScaling : 1; + var size = ClientSize.Width > 0 ? ClientSize : new Size(Width, Height); + return new FloatingBounds(Position.X, Position.Y, Math.Round(size.Width * scaling), Math.Round(size.Height * scaling)); + } + + /// Whether the window is already at , give or take a pixel. + public bool IsAt(FloatingBounds bounds) + { + var now = CurrentBounds(); + return Math.Abs(now.X - bounds.X) <= 1 && Math.Abs(now.Y - bounds.Y) <= 1 + && Math.Abs(now.Width - bounds.Width) <= 2 && Math.Abs(now.Height - bounds.Height) <= 2; + } + + /// Tells the client where the window is, so the workspace and a saved layout remember it. + public void ReportPlace() => Panel.Moved(CurrentBounds()); + + /// Closed because the client docked the panel (or the main window went): docks nothing. + public void CloseForClient() + { + _closingForClient = true; + _report.Stop(); + Close(); + } + + private void Moved() + { + if (_placing || _closingForClient) + { + return; + } + + _report.Stop(); + _report.Start(); + } + + // Every key goes to the command line, the window's own chords included, as if the cells had it. + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + _view.HandleKeyDown(e); + } + } + + protected override void OnTextInput(TextInputEventArgs e) + { + base.OnTextInput(e); + if (!e.Handled) + { + _view.HandleTextInput(e); + } + } + + /// The window's own menu: the way back, since there is no pane under it for the client's. + private void ShowOwnMenu(Point at) + { + var items = new List + { + new($"Dock {Panel.Model.Title} back", null, () => Panel.Dock()), + }; + var menu = NativeMenu.Build(items, PlacementMode.Pointer, this); + OpenedMenu = menu; + menu.Open(this); + } + + private static Color Parse(string hex, Color fallback) => Color.TryParse(hex, out var colour) ? colour : fallback; + + /// moved a little toward : the bar a step off the panel. + private static Color Shade(Color plane, Color ink, double by) => Color.FromRgb( + (byte)(plane.R + ((ink.R - plane.R) * by)), + (byte)(plane.G + ((ink.G - plane.G) * by)), + (byte)(plane.B + ((ink.B - plane.B) * by))); + + /// A small drawn text button, in the window's colours rather than a theme's; never takes the keyboard. + internal sealed class FrameButton : Control + { + private readonly string _label; + private bool _pressed; + + public FrameButton(string label) + { + _label = label; + Focusable = false; + Cursor = new Cursor(StandardCursorType.Hand); + } + + public Color Ink { get; set; } = Colors.White; + + public Color Plane { get; set; } = Colors.Black; + + /// Raised on a left click. + public event Action? Click; + + /// Clicks it, as a left press and release would. Internal for tests. + internal void PerformClick() => Click?.Invoke(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + InvalidateVisual(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + InvalidateVisual(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + _pressed = e.GetCurrentPoint(this).Properties.IsLeftButtonPressed; + e.Handled = true; + InvalidateVisual(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + e.Handled = true; + var click = _pressed && e.InitialPressMouseButton == MouseButton.Left && new Rect(Bounds.Size).Contains(e.GetPosition(this)); + _pressed = false; + InvalidateVisual(); + if (click) + { + Click?.Invoke(); + } + } + + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + var plate = (byte)(_pressed ? 0x55 : IsPointerOver ? 0x38 : 0x1c); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(plate, Ink.R, Ink.G, Ink.B)), null, bounds, 4, 4); + var text = new FormattedText( + _label, + System.Globalization.CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + Typeface.Default, + 11.5, + new SolidColorBrush(Color.FromArgb(0xE8, Ink.R, Ink.G, Ink.B))); + context.DrawText(text, new Point(Math.Round((bounds.Width - text.Width) / 2), Math.Round((bounds.Height - text.Height) / 2))); + } + } +} + +/// +/// The small button over a docked native panel's top-right corner that pops it out into a window of its own — only +/// where the window can float that panel (the client hands a callback). Drawn, never focused; its presses are the +/// window's and never reach the cells under it. +/// +internal sealed class PopOutButton : Control +{ + private bool _pressed; + + public PopOutButton() + { + Focusable = false; + Cursor = new Cursor(StandardCursorType.Hand); + AutomationProperties.SetName(this, "Pop out into its own window"); + ToolTip.SetTip(this, "Pop out into its own window"); + } + + /// The panel it pops out, as the client last published it. + public PanelOverlay? Panel { get; private set; } + + /// The panel's rectangle in the layer, which the pointer must be over for the button to show. + internal Rect PanelBounds { get; set; } + + /// Whether the button holds a one-row strip's reserved end, which stays (as ) when the icon goes. + internal bool Reserved { get; set; } + + /// The pane's plane, painted under a reserved button so the cells under the strip's end do not show; else null. + internal Color? Plane { get; set; } + + /// Whether the pointer is over the panel, which is when the icon is drawn. + internal bool Hovering + { + get => _hovering; + set + { + if (_hovering != value) + { + _hovering = value; + InvalidateVisual(); + } + } + } + + private bool _hovering; + + public Color Ink { get; set; } = Colors.White; + + /// Shows the button for , in . + public void Show(PanelOverlay panel, Color ink) + { + Panel = panel; + if (Ink != ink) + { + Ink = ink; + InvalidateVisual(); + } + } + + /// Clicks it, as a left press and release would. Internal for tests. + internal void PerformClick() => Panel?.PopOut?.Invoke(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + InvalidateVisual(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + InvalidateVisual(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + _pressed = e.GetCurrentPoint(this).Properties.IsLeftButtonPressed; + e.Handled = true; + InvalidateVisual(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + e.Handled = true; + var click = _pressed && e.InitialPressMouseButton == MouseButton.Left && new Rect(Bounds.Size).Contains(e.GetPosition(this)); + _pressed = false; + InvalidateVisual(); + if (click) + { + PerformClick(); + } + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + e.Handled = true; + } + + public override void Render(DrawingContext context) + { + var whole = new Rect(Bounds.Size); + if (Plane is { } under) + { + context.FillRectangle(new SolidColorBrush(under), whole); + } + + if (!Hovering && !IsPointerOver && !_pressed) + { + return; + } + + // A reserved strip end draws the icon as a square at its right, vertically centred. + var square = Math.Min(whole.Width, Math.Min(whole.Height, 18)); + var bounds = Reserved + ? new Rect(whole.Width - square - 3, Math.Round((whole.Height - square) / 2), square, square) + : whole; + using var _ = context.PushTransform(Matrix.CreateTranslation(bounds.X, bounds.Y)); + bounds = new Rect(bounds.Size); + var plate = (byte)(_pressed ? 0x60 : IsPointerOver ? 0x40 : 0x18); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(plate, Ink.R, Ink.G, Ink.B)), null, bounds, 3, 3); + + // A frame with an arrow leaving its corner: out into a window of its own. + var alpha = (byte)(IsPointerOver ? 0xF0 : 0xA8); + var pen = new Pen(new SolidColorBrush(Color.FromArgb(alpha, Ink.R, Ink.G, Ink.B)), 1.2); + var side = Math.Min(bounds.Width, bounds.Height); + var inset = Math.Round(side * 0.24) + 0.5; + var box = new Rect(inset, inset + 1, side - (2 * inset) - 1, side - (2 * inset) - 1); + var gap = box.Width * 0.45; + context.DrawLine(pen, new Point(box.Right - gap, box.Top), new Point(box.Left, box.Top)); + context.DrawLine(pen, new Point(box.Left, box.Top), new Point(box.Left, box.Bottom)); + context.DrawLine(pen, new Point(box.Left, box.Bottom), new Point(box.Right, box.Bottom)); + context.DrawLine(pen, new Point(box.Right, box.Bottom), new Point(box.Right, box.Top + gap)); + var tip = new Point(box.Right + 1, box.Top - 1); + context.DrawLine(pen, new Point(box.Left + (box.Width * 0.45), box.Bottom - (box.Height * 0.45)), tip); + context.DrawLine(pen, tip, new Point(tip.X - (box.Width * 0.38), tip.Y)); + context.DrawLine(pen, tip, new Point(tip.X, tip.Y + (box.Height * 0.38))); + } +} diff --git a/src/SharpMUTerm.Avalonia/Fonts/Inter-Bold.ttf b/src/SharpMUTerm.Avalonia/Fonts/Inter-Bold.ttf new file mode 100644 index 00000000..9fb9b751 Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/Inter-Bold.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/Inter-Medium.ttf b/src/SharpMUTerm.Avalonia/Fonts/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/Inter-Medium.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/Inter-OFL.txt b/src/SharpMUTerm.Avalonia/Fonts/Inter-OFL.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/src/SharpMUTerm.Avalonia/Fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/SharpMUTerm.Avalonia/Fonts/Inter-Regular.ttf b/src/SharpMUTerm.Avalonia/Fonts/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/Inter-Regular.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/Inter-SemiBold.ttf b/src/SharpMUTerm.Avalonia/Fonts/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/Inter-SemiBold.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Bold.ttf b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 00000000..8c93043d Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Bold.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-BoldItalic.ttf b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-BoldItalic.ttf new file mode 100644 index 00000000..1ddf216d Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-BoldItalic.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Italic.ttf b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 00000000..ccc9d6a5 Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Italic.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-OFL.txt b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Regular.ttf b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..dff66cc5 Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/JetBrainsMono-Regular.ttf differ diff --git a/src/SharpMUTerm.Avalonia/Fonts/README.md b/src/SharpMUTerm.Avalonia/Fonts/README.md new file mode 100644 index 00000000..63c34b8b --- /dev/null +++ b/src/SharpMUTerm.Avalonia/Fonts/README.md @@ -0,0 +1,24 @@ +# Bundled fonts + +The window draws its own cells and its own native views, so it carries the faces it is designed in: Inter for every +native view, JetBrains Mono for the cells, and the Nerd Font icons the client's chrome uses. Before these were bundled, +a machine with no Nerd Font installed drew every icon as a box, and the native views took whatever sans-serif the +platform offered. + +| File | Size | Source | Licence | +|---|---|---|---| +| `Inter-{Regular,Medium,SemiBold,Bold}.ttf` | 1.67 MB | [Inter 4.1](https://github.com/rsms/inter/releases/tag/v4.1), `extras/ttf` (static weights) | SIL OFL 1.1 (`Inter-OFL.txt`) | +| `JetBrainsMono-*.ttf` | 1.11 MB | [JetBrains Mono 2.304](https://github.com/JetBrains/JetBrainsMono/releases/tag/v2.304) | SIL OFL 1.1 (`JetBrainsMono-OFL.txt`) | +| `SymbolsNerdFontMono-Regular.ttf` | 2.51 MB | [Nerd Fonts 3.4.0, Symbols Only](https://github.com/ryanoasis/nerd-fonts/releases/tag/v3.4.0) | MIT (`SymbolsNerdFont-LICENSE.txt`); the glyphs keep their upstream licences, listed at [nerd-fonts/license-audit](https://github.com/ryanoasis/nerd-fonts/blob/master/license-audit.md) | + +Inter's four static weights are smaller than the `Avalonia.Fonts.Inter` package (six weights, about 1.9 MB) and are +pinned here by file; the variable font is larger still. + +**The bundle is the default on every platform.** `BundledFonts` registers every face with Avalonia's font manager +under `fonts:SharpMUTerm`, makes Inter the default family, and maps the bare names `Inter` and `JetBrains Mono` onto +the bundle, so a view naming either gets the bundled file whatever is installed. `NativeTokens.UiFont` and +`NativeTokens.MonoFont` name the bundle first and the platform's faces after it, which are used only for a glyph the +bundle lacks (emoji, CJK): no emoji or CJK face is bundled. + +The cells use the bundled JetBrains Mono unless `--font` names an installed family. The bundled symbols are tried for +an icon the text face lacks, then an installed Nerd Font, then the platform's fallback. diff --git a/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFont-LICENSE.txt b/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFont-LICENSE.txt new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFont-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFontMono-Regular.ttf b/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFontMono-Regular.ttf new file mode 100644 index 00000000..d898eea9 Binary files /dev/null and b/src/SharpMUTerm.Avalonia/Fonts/SymbolsNerdFontMono-Regular.ttf differ diff --git a/src/SharpMUTerm.Avalonia/HelpView.cs b/src/SharpMUTerm.Avalonia/HelpView.cs new file mode 100644 index 00000000..d4058396 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/HelpView.cs @@ -0,0 +1,396 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Controls.Documents; +using SharpMUTerm.Client.Help; +using SharpMUTerm.Client.Modals; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's help: a search box, the rows of under their headings, and a how-to page in place +/// of the list once one is chosen. The rows are for the typed query, the function the cells' +/// help lists, so both hosts list the same rows. ⏎ on a slash command places it on the command line and sends nothing; ⏎ +/// on a key runs the command it names; ⏎ on a how-to opens its page. Every word is the window's (). +/// +internal sealed class HelpView : DockPanel, IModalView +{ + private readonly HelpSession _session; + private readonly NativeTokens _colours; + private readonly Action _done; + private readonly TextBox _box; + private readonly TextBlock _count; + private readonly TextBlock _footer; + private readonly Border _frame; + private readonly StackPanel _list; + private readonly ScrollViewer _page; + private readonly StackPanel _pageText; + private readonly List _rows = []; + private IReadOnlyList _view = []; + private int _selected; + private string _query; + + public HelpView(HelpSession session, NativeTokens colours, Action done) + { + _session = session; + _colours = colours; + _done = done; + + _box = ModalParts.SearchBox("Search help: keys, slash commands, how-tos…", "Search help", colours); + SetDock(_box, Dock.Top); + Children.Add(_box); + + _count = ModalParts.Dim(string.Empty, colours); + _count.HorizontalAlignment = HorizontalAlignment.Right; + SetDock(_count, Dock.Top); + Children.Add(_count); + + _footer = ModalParts.Footer(string.Empty, colours); + _footer.Margin = default; // flush under the list rule, as the palette band is + SetDock(_footer, Dock.Bottom); + Children.Add(_footer); + + var panel = new Panel(); + var (frame, list, _) = ModalParts.List(colours); + _frame = frame; + _list = list; + frame.MinHeight = 160; + panel.Children.Add(frame); + + _pageText = new StackPanel { Margin = new Thickness(NativeTokens.Space24, NativeTokens.Space4, NativeTokens.Space24, NativeTokens.Space12), Spacing = NativeTokens.Space8 }; + _page = new ScrollViewer { Content = _pageText, IsVisible = false, MinHeight = 160 }; + panel.Children.Add(_page); + Children.Add(panel); + + _box.Text = session.InitialQuery; + _box.CaretIndex = session.InitialQuery.Length; + _query = session.InitialQuery; + _box.TextChanged += (_, _) => + { + // Avalonia raises this after the fact, the initial text included; only a query that changed redraws. + var query = _box.Text ?? string.Empty; + if (query == _query) + { + return; + } + + _query = query; + _selected = 0; + ShowList(); + }; + AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + + if (session.Model.Topic(session.InitialTopic) is { } topic) + { + ShowPage(topic); + } + else + { + ShowList(); + } + } + + public string Title => "Help"; + + public double PreferredWidth => 760; + + public bool Centred => false; + + /// The rows listed now, for a test. + public IReadOnlyList Rows => _view; + + /// The row ⏎ acts on. + public int Selected => _selected; + + /// The page showing, or null for the list. + public string? Page { get; private set; } + + /// The search box, for a test. + public TextBox Box => _box; + + public void FocusFirst() => _box.Focus(); + + public void Detach() + { + } + + /// ⏎ on listed row : a how-to opens its page, anything else ends help and acts. + public void Choose(int row) + { + if (row < 0 || row >= _view.Count) + { + return; + } + + var entry = _view[row]; + if (entry.Kind == HelpKind.Topic) + { + if (_session.Model.Topic(entry.Target) is { } topic) + { + ShowPage(topic); + } + + return; + } + + var index = IndexOf(entry); + if (index >= 0 && _session.Choose(index)) + { + _done(); + } + } + + /// Esc: a page goes back to the list; from the list help closes. + public void Back() + { + if (Page is not null) + { + ShowList(); + _box.Focus(); + return; + } + + _session.Dismiss(); + _done(); + } + + private int IndexOf(HelpEntry entry) + { + var entries = _session.Model.Entries; + for (var i = 0; i < entries.Count; i++) + { + if (ReferenceEquals(entries[i], entry)) + { + return i; + } + } + + return -1; + } + + /// The face a help page's table rows (the map's keys) are set in, so their columns line up. + private static FontFamily TableFace { get; } = NativeTokens.MonoFont; + + private void ShowPage(HelpTopic topic) + { + Page = topic.Id; + _frame.IsVisible = false; + _page.IsVisible = true; + _box.IsVisible = false; + _count.Text = string.Empty; + _pageText.Children.Clear(); + _pageText.Children.Add(new TextBlock + { + Text = NativeKeys.Words(topic.Title), + FontSize = NativeTokens.Heading, + FontWeight = FontWeight.SemiBold, + Foreground = _colours.Brush(_colours.Text), + TextWrapping = TextWrapping.Wrap, + }); + _pageText.Children.Add(new TextBlock + { + Text = NativeKeys.Words(topic.Summary), + FontSize = NativeTokens.Body, + Foreground = _colours.Brush(_colours.Dim), + TextWrapping = TextWrapping.Wrap, + }); + // The cells' blocks (HelpPage): a heading over each paragraph, keys as key caps, the map's table rows in the + // cell face. No blank paragraphs: the stack's spacing and a heading's top margin are the gaps, so the page has no + // tall empty bands. + HelpBlock? previous = null; + foreach (var block in HelpPage.Blocks(topic)) + { + if (block.Heading is { } heading) + { + _pageText.Children.Add(new TextBlock + { + Text = NativeKeys.Words(heading), + FontSize = NativeTokens.Title, + FontWeight = FontWeight.SemiBold, + Foreground = _colours.Brush(_colours.Header), + Margin = new Thickness(0, previous is null ? NativeTokens.Space4 : NativeTokens.Space8, 0, -NativeTokens.Space4), + }); + } + + var text = new TextBlock + { + FontSize = NativeTokens.Body + 1, + Foreground = _colours.Brush(_colours.Text), + TextWrapping = block.Preformatted ? TextWrapping.NoWrap : TextWrapping.Wrap, + LineHeight = (NativeTokens.Body + 1) * 1.45, + Margin = block.Preformatted && previous is { Preformatted: true } ? new Thickness(NativeTokens.Space12, -NativeTokens.Space6, 0, 0) + : block.Preformatted ? new Thickness(NativeTokens.Space12, 0, 0, 0) : default, + }; + if (block.Preformatted) + { + text.FontFamily = TableFace; + } + + foreach (var span in block.Spans) + { + text.Inlines!.Add(span.IsKey + ? new Run(" " + NativeKeys.Words(span.Text) + " ") + { + Background = _colours.Brush(_colours.KeyFill), + Foreground = _colours.Brush(_colours.KeyInk), + FontWeight = FontWeight.SemiBold, + FontSize = NativeTokens.Body, + } + : new Run(NativeKeys.Words(span.Text))); + } + + _pageText.Children.Add(text); + previous = block; + } + + var back = ModalParts.Button("Back to help", "Esc", _colours, Back); + back.HorizontalAlignment = HorizontalAlignment.Left; + back.Margin = new Thickness(0, NativeTokens.Space10, 0, 0); + _pageText.Children.Add(back); + _page.Offset = default; + _footer.Text = "Up and Down scroll · Esc back to the list"; + } + + private void ShowList() + { + Page = null; + _frame.IsVisible = true; + _page.IsVisible = false; + _box.IsVisible = true; + var model = _session.Model; + _view = model.View(_box.Text ?? string.Empty); + _count.Text = _view.Count == model.Entries.Count ? $"{_view.Count} entries" : $"{_view.Count} of {model.Entries.Count}"; + _footer.Text = "Up and Down pick · Enter opens, places or runs it · Esc closes"; + _list.Children.Clear(); + _rows.Clear(); + _selected = _view.Count == 0 ? 0 : Math.Clamp(_selected, 0, _view.Count - 1); + if (_view.Count == 0) + { + _list.Children.Add(new TextBlock + { + Text = NativeKeys.Words(HelpModel.NoMatches), + Foreground = _colours.Brush(_colours.Dim), + Margin = new Thickness(NativeTokens.Space12, NativeTokens.Space10), + TextWrapping = TextWrapping.Wrap, + }); + return; + } + + string? section = null; + for (var i = 0; i < _view.Count; i++) + { + var entry = _view[i]; + if (entry.Section != section) + { + _list.Children.Add(ModalParts.GroupHeader(entry.Section, _colours, section is null)); + section = entry.Section; + } + + var index = i; + var line = new DockPanel(); + if (entry.Key.Length > 0) + { + var chip = ModalParts.Key(entry.Key, _colours); + chip.Margin = new Thickness(NativeTokens.Space10, 0, 0, 0); + SetDock(chip, Dock.Right); + line.Children.Add(chip); + } + + var title = new TextBlock + { + Text = NativeKeys.Words(entry.Title), + Foreground = _colours.Brush(_colours.Text), + FontSize = NativeTokens.Title, + Width = 240, + Margin = new Thickness(0, 0, NativeTokens.Space14, 0), + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + }; + SetDock(title, Dock.Left); + line.Children.Add(title); + line.Children.Add(new TextBlock + { + Text = NativeKeys.Words(entry.Detail), + Foreground = _colours.Brush(_colours.Dim), + FontSize = NativeTokens.Body, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + }); + var row = ModalParts.Row(line, NativeKeys.Words(entry.Title), () => Select(index), () => Choose(index)); + _rows.Add(row); + _list.Children.Add(row); + } + + Highlight(); + } + + private void Select(int index) + { + if (index != _selected) + { + _selected = index; + Highlight(); + } + } + + private void Highlight() + { + for (var i = 0; i < _rows.Count; i++) + { + ModalParts.Mark(_rows[i], i == _selected, _colours); + } + + if (_selected < _rows.Count) + { + _rows[_selected].BringIntoView(); + } + } + + private void OnKey(object? sender, KeyEventArgs e) + { + if (ModalParts.FocusedButtonTakes(e)) + { + return; + } + + if (e.KeyModifiers != KeyModifiers.None) + { + return; + } + + switch (e.Key) + { + case Key.Escape: + e.Handled = true; + Back(); + return; + case Key.Up when Page is null: + e.Handled = true; + Select(ModalParts.Step(_selected, -1, _view.Count)); + return; + case Key.Down when Page is null: + e.Handled = true; + Select(ModalParts.Step(_selected, 1, _view.Count)); + return; + case Key.Up: + e.Handled = true; + _page.LineUp(); + return; + case Key.Down: + e.Handled = true; + _page.LineDown(); + return; + case Key.Enter when Page is null: + e.Handled = true; + Choose(_selected); + return; + case Key.Back when Page is not null: + e.Handled = true; + Back(); + return; + } + } +} diff --git a/src/SharpMUTerm.Avalonia/HistoryView.cs b/src/SharpMUTerm.Avalonia/HistoryView.cs new file mode 100644 index 00000000..370af062 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/HistoryView.cs @@ -0,0 +1,204 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using SharpMUTerm.Client.Modals; +using SharpMUTerm.Core.Input; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's ⌃R: whose list it is, a filter box, and the command line's history newest first with the match +/// marked. The list is the client's snapshot () filtered by the same function the cells use +/// (), so a query lists the same rows; ⏎ places the entry on the command line and +/// sends nothing. The keys that are not typing are read by the cells' rules (). +/// +internal sealed class HistoryView : DockPanel, IModalView +{ + private readonly HistorySession _session; + private readonly NativeTokens _colours; + private readonly Action _done; + private readonly TextBox _box; + private readonly TextBlock _count; + private readonly StackPanel _list; + private readonly List _rows = []; + private IReadOnlyList _matches = []; + private int _selected; + + public HistoryView(HistorySession session, NativeTokens colours, Action done) + { + _session = session; + _colours = colours; + _done = done; + var model = session.Model; + + _box = ModalParts.SearchBox("Filter the history…", "Filter the history", colours); + SetDock(_box, Dock.Top); + Children.Add(_box); + + var header = new DockPanel { Margin = new Thickness(NativeTokens.Space20, 0, NativeTokens.Space20, NativeTokens.Space6) }; + var whose = new TextBlock { Text = model.BarLabel, FontSize = NativeTokens.Body, Foreground = colours.Brush(colours.Dim) }; + _count = new TextBlock { FontSize = NativeTokens.Body, Foreground = colours.Brush(colours.Dim), HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Right }; + SetDock(whose, Dock.Left); + header.Children.Add(whose); + header.Children.Add(_count); + SetDock(header, Dock.Top); + Children.Add(header); + + var footer = ModalParts.Footer("↑↓ pick · Enter places it on the command line · Esc cancel", colours); + footer.Margin = default; // flush under the list rule, as the palette band is + SetDock(footer, Dock.Bottom); + Children.Add(footer); + + var (frame, list, _) = ModalParts.List(colours); + _list = list; + frame.MinHeight = 120; + Children.Add(frame); + + _box.Text = session.InitialQuery; + _box.CaretIndex = session.InitialQuery.Length; + _box.TextChanged += (_, _) => + { + _selected = 0; + Draw(); + }; + AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + Draw(); + } + + public string Title => "Command history"; + + public double PreferredWidth => 680; + + public bool Centred => false; + + /// The rows listed now, for a test. + public IReadOnlyList Matches => _matches; + + /// The row ⏎ places. + public int Selected => _selected; + + /// The filter box, for a test. + public TextBox Box => _box; + + public void FocusFirst() => _box.Focus(); + + public void Detach() + { + } + + /// ⏎ on listed row . + public void Insert(int row) + { + if (row >= 0 && row < _matches.Count) + { + _session.Insert(_matches[row].Index); + _done(); + } + } + + private void Draw() + { + var model = _session.Model; + _matches = model.View(_box.Text ?? string.Empty); + _count.Text = model.CountText(_matches.Count); + _list.Children.Clear(); + _rows.Clear(); + _selected = _matches.Count == 0 ? 0 : Math.Clamp(_selected, 0, _matches.Count - 1); + if (_matches.Count == 0) + { + _list.Children.Add(new TextBlock + { + Text = model.Entries.Count == 0 ? HistoryModel.NothingEntered : HistoryModel.NoMatches, + Foreground = _colours.Brush(_colours.Dim), + Margin = new Thickness(NativeTokens.Space12, NativeTokens.Space10), + TextWrapping = TextWrapping.Wrap, + }); + return; + } + + var mono = NativeTokens.MonoFont; + for (var i = 0; i < _matches.Count; i++) + { + var match = _matches[i]; + var index = i; + var text = new TextBlock + { + Foreground = _colours.Brush(_colours.Text), + FontFamily = mono, + FontSize = NativeTokens.Title, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + text.Inlines = ModalParts.Marked(match.Text, match.MatchStart, match.MatchLength, _colours); + var row = ModalParts.Row(text, match.Text, () => Select(index), () => Insert(index)); + _rows.Add(row); + _list.Children.Add(row); + } + + Highlight(); + } + + private void Select(int index) + { + if (index != _selected) + { + _selected = index; + Highlight(); + } + } + + private void Highlight() + { + for (var i = 0; i < _rows.Count; i++) + { + ModalParts.Mark(_rows[i], i == _selected, _colours); + } + + if (_selected < _rows.Count) + { + _rows[_selected].BringIntoView(); + } + } + + private void OnKey(object? sender, KeyEventArgs e) + { + if (ModalParts.FocusedButtonTakes(e)) + { + return; + } + + if (ModalParts.Console(e) is not { } key) + { + return; + } + + var bare = key.Modifiers == 0; + if (bare && key.Key is not (ConsoleKey.UpArrow or ConsoleKey.DownArrow or ConsoleKey.Enter or ConsoleKey.Escape)) + { + return; + } + + var decision = HistorySearchPrompt.Interpret(key, _box.Text ?? string.Empty, _selected, _matches.Count); + if (!bare && decision.Action == HistoryAction.None) + { + return; + } + + e.Handled = true; + switch (decision.Action) + { + case HistoryAction.Insert: + Insert(_selected); + break; + case HistoryAction.Cancel: + _session.Dismiss(); + _done(); + break; + case HistoryAction.Redraw: + Select(decision.Selected); + break; + } + } +} diff --git a/src/SharpMUTerm.Avalonia/ImportReviewView.cs b/src/SharpMUTerm.Avalonia/ImportReviewView.cs new file mode 100644 index 00000000..011b2ad2 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/ImportReviewView.cs @@ -0,0 +1,242 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Client.Modals; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's review of an import from another client: what can act as the player, what will be added, what was left +/// out and why (, the model the cells draw), a check box for the passwords the file held, +/// and Import / Cancel. Import ends in the client's AcceptImport, the method the cells' Enter ends in; the check +/// box ends in ToggleImportPasswords, and the client republishes. Nothing is written before Import. +/// +internal sealed class ImportReviewView : DockPanel, IModalView +{ + private readonly ImportReviewSession _session; + private readonly NativeTokens _colours; + private readonly Action _done; + private readonly StackPanel _body = new() { Spacing = NativeTokens.Space4, Margin = new Thickness(NativeTokens.Space20, NativeTokens.Space2, NativeTokens.Space20, NativeTokens.Space4) }; + private readonly Button _import; + private readonly Button _cancel; + private readonly CheckBox _passwords; + private ImportReviewModel _model; + + public ImportReviewView(ImportReviewSession session, NativeTokens colours, Action done) + { + _session = session; + _colours = colours; + _done = done; + _model = session.Model; + + var heading = ModalParts.Heading(_model.Title, colours); + SetDock(heading, Dock.Top); + Children.Add(heading); + var intro = ModalParts.Dim(_model.Intro, colours); + SetDock(intro, Dock.Top); + Children.Add(intro); + + var footer = ModalParts.Footer(_model.Empty ? "Esc close" : _model.Keys, colours); + SetDock(footer, Dock.Bottom); + + _import = ModalParts.Button("Import", "Enter", colours, Accept, accent: true); + _import.IsEnabled = !_model.Empty; + _cancel = ModalParts.Button("Cancel", "Esc", colours, Decline); + _passwords = new CheckBox + { + Content = "Store the passwords", + IsVisible = _model.Passwords > 0, + IsChecked = _model.StorePasswords, + Foreground = colours.Brush(colours.Text), + FontSize = NativeTokens.Body, + VerticalAlignment = VerticalAlignment.Center, + Focusable = true, + }; + _passwords.IsCheckedChanged += (_, _) => + { + if ((_passwords.IsChecked == true) != _model.StorePasswords) + { + _session.TogglePasswords(); + } + }; + + var buttons = new DockPanel { Margin = new Thickness(NativeTokens.Space20, NativeTokens.Space12, NativeTokens.Space20, 0), LastChildFill = false }; + DockPanel.SetDock(_passwords, Dock.Left); + buttons.Children.Add(_passwords); + var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = NativeTokens.Space10 }; + right.Children.Add(_import); + right.Children.Add(_cancel); + DockPanel.SetDock(right, Dock.Right); + buttons.Children.Add(right); + SetDock(buttons, Dock.Bottom); + Children.Add(footer); + Children.Add(buttons); + + Children.Add(new ScrollViewer + { + Content = _body, + MaxHeight = 460, + HorizontalScrollBarVisibility = global::Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + }); + + Draw(); + _session.Changed += OnChanged; + AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + } + + public string Title => _model.Title; + + public double PreferredWidth => 680; + + public bool Centred => true; + + /// The buttons and the check box, for a test. + public Button ImportButton => _import; + + public Button CancelButton => _cancel; + + public CheckBox PasswordsBox => _passwords; + + /// The text the body shows, for a test. + public IReadOnlyList BodyText => _body.Children.OfType() + .Select(t => t.Inlines is { Count: > 0 } inlines + ? string.Concat(inlines.OfType().Select(r => r.Text)) + : t.Text ?? string.Empty) + .ToArray(); + + public void FocusFirst() => (_import.IsEnabled ? _import : _cancel).Focus(); + + public void Detach() => _session.Changed -= OnChanged; + + /// Import: the session ends and the client writes the import. + public void Accept() + { + if (_model.Empty) + { + return; + } + + _session.Accept(); + _done(); + } + + /// Cancel: nothing is written. + public void Decline() + { + _session.Dismiss(); + _done(); + } + + private void OnChanged(ImportReviewModel model) => Dispatcher.UIThread.Post(() => + { + _model = model; + _passwords.IsChecked = model.StorePasswords; + Draw(); + }); + + private void Draw() + { + _body.Children.Clear(); + if (_model.Empty) + { + _body.Children.Add(Line("There is nothing in it this client can import.", _colours.Dim)); + } + else + { + _body.Children.Add(ModalParts.GroupHeader(ImportReviewModel.ActsHeading, _colours, first: true)); + if (_model.ActsAsYou.Count == 0) + { + _body.Children.Add(Line("Nothing: no rule in it sends a command.", _colours.Dim)); + } + + foreach (var act in _model.ActsAsYou) + { + _body.Children.Add(Line("• " + act, _colours.Text)); + } + + _body.Children.Add(ModalParts.GroupHeader(ImportReviewModel.AddsHeading, _colours, first: false)); + foreach (var row in _model.Adds) + { + _body.Children.Add(Row(row, _colours.Text)); + } + } + + if (_model.LeftOutCount > 0) + { + _body.Children.Add(ModalParts.GroupHeader($"{ImportReviewModel.LeftOutHeading} ({_model.LeftOutCount})", _colours, first: false)); + foreach (var row in _model.LeftOut) + { + _body.Children.Add(Row(row, _colours.Dim)); + } + + if (_model.MoreLeftOut is { } more) + { + _body.Children.Add(Line(more, _colours.Dim)); + } + } + + if (_model.PasswordLine is { } passwords) + { + var line = Line(passwords, _model.StorePasswords ? _colours.Text : _colours.Warn); + line.Margin = new Thickness(NativeTokens.Space12, NativeTokens.Space12, NativeTokens.Space12, 0); + _body.Children.Add(line); + } + } + + private TextBlock Line(string text, Color colour) => new() + { + Text = NativeKeys.Words(text), + Foreground = _colours.Brush(colour), + FontSize = NativeTokens.Body, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(NativeTokens.Space12, NativeTokens.Hairline, NativeTokens.Space12, NativeTokens.Hairline), + }; + + private TextBlock Row(ImportReviewRow row, Color detail) + { + var block = new TextBlock + { + FontSize = NativeTokens.Body, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(NativeTokens.Space12, NativeTokens.Space2, NativeTokens.Space12, NativeTokens.Space2), + }; + block.Inlines ??= new global::Avalonia.Controls.Documents.InlineCollection(); + block.Inlines.Add(new global::Avalonia.Controls.Documents.Run(row.Kind + " ") { Foreground = _colours.Brush(_colours.Accent) }); + block.Inlines.Add(new global::Avalonia.Controls.Documents.Run(row.Name) { Foreground = _colours.Brush(_colours.Text), FontWeight = FontWeight.SemiBold }); + block.Inlines.Add(new global::Avalonia.Controls.Documents.Run(" " + NativeKeys.Words(row.Detail)) { Foreground = _colours.Brush(detail) }); + return block; + } + + private void OnKey(object? sender, KeyEventArgs e) + { + if (ModalParts.FocusedButtonTakes(e)) + { + return; + } + + if (e.KeyModifiers != KeyModifiers.None) + { + return; + } + + if (e.Key == Key.Escape) + { + e.Handled = true; + Decline(); + } + else if (e.Key == Key.Enter && e.Source is not Button and not CheckBox) + { + e.Handled = true; + Accept(); + } + else if (e.Key == Key.P && _model.Passwords > 0) + { + e.Handled = true; + _session.TogglePasswords(); + } + } +} diff --git a/src/SharpMUTerm.Avalonia/KeyMapper.cs b/src/SharpMUTerm.Avalonia/KeyMapper.cs index 05af5605..8889267b 100644 --- a/src/SharpMUTerm.Avalonia/KeyMapper.cs +++ b/src/SharpMUTerm.Avalonia/KeyMapper.cs @@ -50,6 +50,11 @@ internal static class KeyMapper case Key.Insert: return Make('\x1b', ConsoleKey.Insert); case Key.Delete: return Make('\x1b', ConsoleKey.Delete); case >= Key.F1 and <= Key.F24: return Make('\0', ConsoleKey.F1 + (key - Key.F1)); + + // The numpad as itself, so a Num7 binding fires (HostKeys.DeliversNumpad). It carries its digit, so an + // unbound numpad key still types one; the TextInput that follows is swallowed (TerminalView). + case >= Key.NumPad0 and <= Key.NumPad9: + return Make((char)('0' + (key - Key.NumPad0)), ConsoleKey.NumPad0 + (key - Key.NumPad0)); } // Ctrl together with Alt is AltGr on a European layout: the character it types is TextInput's. diff --git a/src/SharpMUTerm.Avalonia/LayoutsView.cs b/src/SharpMUTerm.Avalonia/LayoutsView.cs new file mode 100644 index 00000000..571b2107 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/LayoutsView.cs @@ -0,0 +1,416 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using SharpMUTerm.Client.Modals; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's ⌥M: the character's layouts, BUILT IN then YOURS, with what each is in use for, and a row of buttons +/// for what can be done to the one pointed at. Every button and key is a the client runs +/// through the method the cell list's key runs, and what it said comes back as the list's status. The keys are the +/// cell list's: ⏎ apply, S save changes, N save as new, W use for the world, C use for this character, R rename and D +/// delete (a layout of the player's own only), ↑↓ move, Esc closes. +/// +internal sealed class LayoutsView : DockPanel, IModalView +{ + private readonly LayoutsSession _session; + private readonly NativeTokens _colours; + private readonly Action _done; + private readonly StackPanel _list; + private readonly TextBlock _status; + private readonly WrapPanel _buttons; + private readonly TextBox _rename; + private readonly List _rows = []; + private readonly List _rowIndex = []; + private LayoutsModel _model; + private int _selected; + private bool _renaming; + private bool _confirming; + + public LayoutsView(LayoutsSession session, NativeTokens colours, Action done) + { + _session = session; + _colours = colours; + _done = done; + _model = session.Model; + _selected = _model.Selected; + global::Avalonia.Automation.AutomationProperties.SetName(this, "Layouts"); + + var heading = ModalParts.Heading("Layouts", colours); + SetDock(heading, Dock.Top); + Children.Add(heading); + var whose = ModalParts.Dim($"How {_model.Subject}'s panes are arranged. Applying one never closes a window.", colours); + SetDock(whose, Dock.Top); + Children.Add(whose); + + _rename = new TextBox + { + Margin = new Thickness(NativeTokens.Space14, NativeTokens.Space4, NativeTokens.Space14, NativeTokens.Space4), + Padding = new Thickness(NativeTokens.Space10, NativeTokens.Space6), + CornerRadius = new CornerRadius(NativeTokens.RadiusM), + IsVisible = false, + PlaceholderText = "New name", + }; + global::Avalonia.Automation.AutomationProperties.SetName(_rename, "New name"); + + var footer = ModalParts.Footer("↑↓ pick · Esc close", colours); + SetDock(footer, Dock.Bottom); + Children.Add(footer); + + _buttons = new WrapPanel { Margin = new Thickness(NativeTokens.Space14, NativeTokens.Space8, NativeTokens.Space14, 0), HorizontalAlignment = HorizontalAlignment.Right }; + SetDock(_buttons, Dock.Bottom); + Children.Add(_buttons); + + _status = ModalParts.Dim(string.Empty, colours, new Thickness(NativeTokens.Space20, NativeTokens.Space8, NativeTokens.Space20, 0)); + SetDock(_status, Dock.Bottom); + Children.Add(_status); + + SetDock(_rename, Dock.Bottom); + Children.Add(_rename); + + var (frame, list, _) = ModalParts.List(colours); + _list = list; + Children.Add(frame); + + AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + _session.Changed += OnChanged; + Draw(); + } + + public string Title => "Layouts"; + + public double PreferredWidth => 640; + + public bool Centred => false; + + /// The model drawn now, for a test. + public LayoutsModel Model => _model; + + /// The row pointed at. + public int Selected => _selected; + + /// The buttons offered now, by label, for a test. + public IReadOnlyList