diff --git a/SPEC.md b/SPEC.md index c24db1b..3c2582f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -854,6 +854,176 @@ was the one property of a preset you could not set. boundary changes the stored order without changing what the menu shows. +### Phase 5a - Terminal parity ✅ done +Everything a mainstream terminal has that qtxterm did not. Grouped here +because the individual features are small; what was not small was discovering +that three of the shortcuts already documented had never worked. + +- [x] **Find in the scrollback** (`Ctrl+Shift+F`), on the vendored + `addon-search`. The bar lives *in the page*, not in a Qt widget above + the view: a Qt bar would take rows off the grid and reflow the shell's + output every time you searched. + - Two failures that unit tests passed straight through. xterm 5.5 gates + `registerMarker`/`registerDecoration` behind `allowProposedApi`, so the + addon threw *after* finding its matches, while selecting one - which + surfaced as "No results" on a query with three. And the two overview + ruler colours read as optional but are passed straight to + `registerDecoration`, where an undefined colour throws. + - The match highlight is a **translucent** tint, and that is not a + cosmetic choice. xterm draws search decorations *over* the glyphs, so an + opaque highlight erases the text it is pointing at. Reusing the theme's + `selectionBackground` looked safe - it is the one background a theme + guarantees its foreground on - and turned every match into a solid white + block on VS Code Dark High Contrast, whose selection colour is white. + Now yellow at 25%, the current match at 50% with a foreground-coloured + border. Caught by looking at a screenshot, not by a test. +- [x] **Clickable URLs**, on the vendored `addon-web-links`. Ctrl+click + (Cmd on macOS), following VS Code, Windows Terminal and iTerm2: a bare + click already places the cursor and starts a selection. + - Opens in the **system** browser, not a qtxterm browser tab, even though + the app has them. That is where the user's extensions, blocklists and + sessions are, and the URL is untrusted output. + - The scheme is checked again in Python. The addon's regex matches only + http/https, but it is not a security boundary - the text it ran against + came out of the terminal, which over SSH means it came from the remote + host - and `QDesktopServices.openUrl` will launch a registered handler + for any scheme. Tested against file, ms-msdt (Follina), javascript and + vbscript URLs. +- [x] **Copy/paste, font zoom and tab-by-number** shortcuts, plus + **close-on-exit** and a **background image**. + +#### Focus after a split was landing on the tab bar - fixed +Reported as "pane navigation moves between tabs". It was not the navigation: +after `split_active()` (and after `addTab`) Qt left keyboard focus on the +**QTabBar**, which handles Left/Right by switching tabs. So a freshly split +pane could not be typed into until it was clicked, and arrows moved tabs. + +- [x] `PaneWidget.focus_pane()`, called after a split, a new tab, and a pane + close. For a terminal it takes two steps: `setFocus()` on the view + moves Qt's focus off the tab bar, and `term.focus()` moves it again + *inside* the page to the hidden textarea xterm reads from. Without the + second the pane looks focused and types nowhere. A pane split a moment + ago has no page yet, so the request is remembered and replayed from + `_on_script_loaded`. +- [x] Alt+Arrow pane navigation ranks candidates by how much their edges + **overlap**, not by distance between centres. Measured: with a tall + pane left of two stacked right, the two candidates' centres sat 138 and + 139 pixels off axis - a one-pixel coin flip that would flip again if + the splitter moved. + +#### Punctuation shortcuts fail in two opposite ways - fixed +The `Alt+Shift` splits were documented from Phase 4i and could never have +fired from a keyboard. Both classes are now covered by registering every +spelling. + +| Chord | Qt matches | Keyboard sends | +|---|---|---| +| Alt+Shift+equals | `Key_Equal` | `Key_Plus`, because Shift+equals is plus | +| Alt+Shift+minus | `Key_Minus` | `Key_Underscore` | +| Ctrl+Shift+underscore | `Key_Underscore` | `Key_Minus` - with Ctrl held the character is a control code, so Qt cannot derive the shifted character from the layout and reports the base key | + +Worth knowing for the next such bug: **`QTest.keyClick` cannot catch this.** +It hands Qt an event you constructed, so it only ever confirms that a binding +matches the event you invented. An earlier "all six chords work" measurement +was made that way and was worthless. `SendInput` is the honest test, and it +is unavailable in some environments - it returned 0 here even with the window +confirmed foreground. + +#### Shortcuts are per platform, not translated - `shortcuts.py` +Qt maps `Ctrl` to Command on macOS, `Meta` to Control and `Alt` to Option. +That does half the job and ruins the other half, so the table spells out both +sides: + +- On Windows and Linux the shell owns Ctrl+letter, so the app takes + `Ctrl+Shift`. On macOS the shell uses Control and Command is free, so the + binding is a plain Cmd+letter - translating `Ctrl+Shift+T` would give + Cmd+Shift+T, a different gesture entirely. +- `Ctrl+Tab` on macOS becomes Cmd+Tab, the OS application switcher, so + next-tab is spelled `Meta+Tab` there. +- `Ctrl+C` on macOS is Cmd+C and is genuinely copy; the interrupt is + `Meta+C` and is deliberately left unbound. A test asserts it stays that + way. +- `shortcuts.conflicts()` exists because a collision is invisible: two + QShortcuts sharing a sequence makes Qt fire **neither**. A test calls it on + both platforms rather than trusting the table to have been read carefully. + It caught the split-down chord being a tempting second spelling for + zoom-out. + +#### Close-on-exit is three settings, not a checkbox +A shell you exited on purpose should take its pane with it; a shell that +*died* has usually printed why, and closing the pane throws that away exactly +when you needed to read it. Default is the middle option, matching Windows +Terminal's closeOnExit. Closing is deferred a turn of the event loop - +deleting the widget that owns the object currently emitting is how you get a +crash rather than a closed pane - and the pane is re-checked at that point, +since the tab can be gone by then. + +#### Background image +The theme colour becomes a **veil** over the image rather than the ground: +the image is the bottom layer, with a flat wash of the theme background over +it at (100 minus strength). Default strength 30, because a photograph at full +strength behind text is unreadable and the first thing someone tries should +still work as a terminal. `allowTransparency` is set at construction, and +xterm's own background goes transparent only when an image is set, so the +no-image case renders exactly as before. + +#### Shortcuts are rebindable, and only the differences are stored +Added because no default table can be right everywhere, and the reason is not +taste. A tiling window manager that owns Alt+Arrow, a desktop that has claimed +a chord, a shell binding somebody depends on - none of these are visible from +inside qtxterm, and all of them take the key before the app sees it. Rebinding +makes the defaults a starting point rather than a verdict. + +The alternative considered first was adding a second default chord per action +as a fallback, which is what prompted the question this settles: **is binding +several chords to one action good practice?** The distinction that matters: + +- A **compatibility hedge** is two spellings of one physical gesture where + only one can ever fire - Alt+Shift+= and Alt+Shift++ are the same keypress. + Free: no keyspace consumed, nothing shadowed, nothing extra to learn. +- A **true alias** is two different gestures for one action - Ctrl+= and + Ctrl++, or Ctrl+Shift+C and Ctrl+Insert. Each costs a chord permanently, and + in a terminal the keyspace is scarce because the shell owns most of it. + +Hedge freely; alias only against evidence. Measured at the time: 26 actions +resolved to 44 sequences on Windows/Linux against 29 on macOS, and the +tab-number slots alone (Alt+N *and* Ctrl+Alt+N) accounted for roughly half the +aliasing. Adding Ctrl+Shift+Arrow as a speculative Linux fallback was rejected +on those grounds - it would have been a "just in case" alias, and rebinding is +the honest answer to environmental capture. + +Three decisions in the implementation: + +- **Only overrides are written.** Saving the resolved table would freeze + today's defaults into every config file, so a later version that improved a + binding or added an action would never reach anyone who had opened the + editor once. An untouched action follows the defaults forever. +- **A conflict is refused, not accepted.** Two QShortcuts sharing a sequence + makes Qt fire *neither* - it reports the ambiguity and gives up - so a + last-one-wins policy would silently disable both actions. The store rejects + the save and names the action already holding the chord. +- **Shortcuts are rebuilt, not patched.** Working out which QShortcuts a + changed table implies is more code than making them all again, and the set + is small. The previous set is disposed first: a left-behind QShortcut keeps + firing, and once its replacement exists the two are ambiguous - which is the + failure above, self-inflicted. + +`shortcuts.py` stays a pure table with no Qt storage in it; `keybindings.py` +layers overrides on top, and `terminal_tabs` resolves through the store when +it has one and straight from the table when it does not, which is what tests +and embedders get. + +An empty binding list is a real setting, distinct from resetting: an action +nobody wants a key for is a legitimate thing to ask for, and the difference is +"no shortcut" versus "the default shortcut". + +#### Documentation is tested, not proofread +`tests/test_usage_docs.py` asserts that every shortcut the app binds appears +in USAGE.md, and that the guide survives `QTextBrowser.setMarkdown` - whose +escaping rules are not GitHub's, so the file being correct is no evidence the +dialog is. It was written after the guide was found advertising chords that +could not fire, and after a pipe character silently ate a table cell. + ## Open Questions / Deferred ### Start-up latency - measured, not yet decided @@ -953,36 +1123,64 @@ with no stop is exactly today's behaviour. No visible "advanced" tier - one job type with optional fields, because two tiers is two things to learn and a migration when a simple job later needs a window. -### Ctrl-C does not reach child processes on Windows - bug, not yet fixed -Found while designing the above, and **independent of cron**: pressing Ctrl-C -in any qtxterm terminal does not stop a running command on Windows. - -`pywinpty`'s `sendintr()` is literally `write("")`, which is what typing -Ctrl-C already does. Measured, spawning `ping -t` and counting replies: +### Ctrl-C on Windows - the reported bug was a measurement artifact +Re-measured during Phase 5a, and the conclusion is the opposite of what this +document said before: **Ctrl-C works.** Pressing it in a cmd or PowerShell tab +stops a running child, in a normally launched qtxterm. -| Backend | child started | after `` | after closing | -|---|---|---|---| -| **ConPTY** (what we use) | running | **still running** | killed | -| WinPTY (legacy) | running | killed | killed | +What made it look broken is worth recording, because it is an easy trap and it +wasted a long investigation. -At an idle prompt the shell echoes `^C`, which makes it look like it worked; -a running child never sees it. Closing the terminal kills the process under -both backends, so that is the only stop that currently works. +**Windows makes the "ignore Ctrl+C" state inheritable.** A process whose +parent called `SetConsoleCtrlHandler(NULL, TRUE)` inherits the ignore, and so +does everything *it* spawns. Automation runners, CI agents and some launchers +set it as a matter of course. When qtxterm is started from such a process the +flag travels down the whole chain - qtxterm, its ConPTY, the shell in the tab, +and the shell's children - so nothing in any terminal responds to Ctrl+C. The +same build launched normally is fine. -Three ways out: +Measured, one script, two trials, the only difference being the flag: -1. `GenerateConsoleCtrlEvent` - attach to the child console, raise - CTRL_C_EVENT, detach. The correct mechanism, and it fixes interactive - Ctrl-C too. Our own process must ignore the event first or it takes the - app down with it. -2. Switch to the WinPTY backend - works immediately, but it is the - deprecated emulation layer with an extra agent process and worse - fidelity. Bad trade for the terminal's quality. -3. Leave it, and stop things by closing the tab. - -Preferred: 1. Linux is unaffected - a real SIGINT to the foreground process -group is straightforward there, and untested only because the bug is -Windows-specific. +| Condition | Child after Ctrl-C | +|---|---| +| as launched by an automation shell | still running | +| after `SetConsoleCtrlHandler(NULL, FALSE)` | **stopped** | + +Two earlier findings were real observations with the wrong explanation +attached, and both dissolve once the flag is understood: + +- **Git Bash appeared immune.** It is: MSYS2 implements POSIX signals in its + own runtime and raises SIGINT for the foreground process group without ever + consulting the Windows console control path, so an inherited console flag + cannot affect it. That is why it kept working while cmd and PowerShell did + not - not evidence of a ConPTY defect. +- **`AttachConsole` + `GenerateConsoleCtrlEvent` reported success and killed + nothing.** Expected, once the target is ignoring the event. + +The ConPTY-versus-WinPTY table previously recorded here should be treated as +unverified: it may well have been taken under the same inherited flag, and it +attributes to ConPTY a behaviour that reproduces just as readily without it. + +Genuinely useful things learned along the way, kept because they are true +regardless: + +- `AttachConsole` **resets** the Ctrl+C ignore flag, so + `SetConsoleCtrlHandler(NULL, TRUE)` must be called *after* attaching, not + before. Getting this backwards takes the calling process down with the + event. Doing console surgery in a throwaway helper process avoids the + question entirely. +- The standard handles are stale after `AttachConsole`; `CONIN$` has to be + reopened with `CreateFile` before `GetConsoleMode` will work. +- The pseudoconsole's input mode already has `ENABLE_PROCESSED_INPUT` + (measured 0x01f7), so the "processed input is off" theory is dead in any + case. + +**Testing lesson, which is the durable part.** Every measurement above was +taken from a harness that silently changed the thing being measured. A test +environment that disables Ctrl+C cannot be used to test Ctrl+C, and nothing in +the output said so - the app simply looked broken. Where a behaviour depends +on process or console state inherited from the launcher, the only trustworthy +check is the application started the way a user starts it. ### Stable `Preset.id` - proposed, low priority, not implemented Give every preset an `id: str` (uuid4 hex), assigned in `__post_init__` when diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index ddca2aa..54a934b 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -30,6 +30,8 @@ "assets/xterm/xterm.js", "assets/xterm/xterm.css", "assets/xterm/addon-fit.js", + "assets/xterm/addon-search.js", + "assets/xterm/addon-web-links.js", "assets/USAGE.md", "assets/logo.ico", ] diff --git a/src/qtxterm/appearance.py b/src/qtxterm/appearance.py index c3085f1..4d11026 100644 --- a/src/qtxterm/appearance.py +++ b/src/qtxterm/appearance.py @@ -10,9 +10,16 @@ _FONT_FAMILY_KEY = "appearance/fontFamily" _FONT_SIZE_KEY = "appearance/fontSize" _SCROLLBACK_KEY = "appearance/scrollback" +_BACKGROUND_IMAGE_KEY = "appearance/backgroundImage" +_BACKGROUND_OPACITY_KEY = "appearance/backgroundOpacity" DEFAULT_FONT_FAMILY = "Consolas" DEFAULT_FONT_SIZE = 14 +# Shared by the Preferences spin box and the zoom shortcuts, so the two +# cannot drift apart - zooming past a size the dialog refuses to show would +# leave a preference the user could see but not edit back. +MIN_FONT_SIZE = 6 +MAX_FONT_SIZE = 72 # xterm.js's own default, kept as ours so the setting starts where the # terminal already was. Every line held is memory that a terminal left open @@ -24,6 +31,15 @@ MIN_SCROLLBACK = 0 MAX_SCROLLBACK = 100_000 +# How strongly a background image shows through, as a percentage. 0 hides it +# entirely (the theme's own background, i.e. no image at all) and 100 shows it +# untouched. The default is deliberately well below 100: a photograph at full +# strength behind text is unreadable, and someone trying the feature for the +# first time should see something that still works as a terminal. +MIN_BACKGROUND_OPACITY = 0 +MAX_BACKGROUND_OPACITY = 100 +DEFAULT_BACKGROUND_OPACITY = 30 + @dataclasses.dataclass class Appearance: @@ -31,6 +47,11 @@ class Appearance: font_family: str = DEFAULT_FONT_FAMILY font_size: int = DEFAULT_FONT_SIZE scrollback: int = DEFAULT_SCROLLBACK + # Absolute path to an image, or "" for none. Stored as the path the + # user picked rather than a copy, so replacing the file on disk + # changes the background without touching the setting. + background_image: str = "" + background_opacity: int = DEFAULT_BACKGROUND_OPACITY @property def theme(self) -> Theme: @@ -57,11 +78,21 @@ def _load(self) -> Appearance: theme_name = default_theme_name() font_family = self._settings.value(_FONT_FAMILY_KEY, DEFAULT_FONT_FAMILY) font_size = int(self._settings.value(_FONT_SIZE_KEY, DEFAULT_FONT_SIZE)) + font_size = max(MIN_FONT_SIZE, min(font_size, MAX_FONT_SIZE)) scrollback = int(self._settings.value(_SCROLLBACK_KEY, DEFAULT_SCROLLBACK)) + background_image = self._settings.value(_BACKGROUND_IMAGE_KEY, "") or "" + background_opacity = int( + self._settings.value(_BACKGROUND_OPACITY_KEY, DEFAULT_BACKGROUND_OPACITY) + ) return Appearance( theme_name=theme_name, font_family=font_family, font_size=font_size, + background_image=background_image, + background_opacity=max( + MIN_BACKGROUND_OPACITY, + min(background_opacity, MAX_BACKGROUND_OPACITY), + ), # Clamped on the way in: a hand-edited ini shouldn't be able to # ask xterm.js for a negative buffer. scrollback=max(MIN_SCROLLBACK, min(scrollback, MAX_SCROLLBACK)), @@ -73,4 +104,6 @@ def save(self, appearance: Appearance) -> None: self._settings.setValue(_FONT_FAMILY_KEY, appearance.font_family) self._settings.setValue(_FONT_SIZE_KEY, appearance.font_size) self._settings.setValue(_SCROLLBACK_KEY, appearance.scrollback) + self._settings.setValue(_BACKGROUND_IMAGE_KEY, appearance.background_image) + self._settings.setValue(_BACKGROUND_OPACITY_KEY, appearance.background_opacity) self.changed.emit() diff --git a/src/qtxterm/assets/USAGE.md b/src/qtxterm/assets/USAGE.md index d0c860c..20d0952 100644 --- a/src/qtxterm/assets/USAGE.md +++ b/src/qtxterm/assets/USAGE.md @@ -4,6 +4,10 @@ A tabbed terminal with one-click command buttons and reusable command presets. ## Terminals and tabs +Shortcuts below are the Windows and Linux ones. macOS uses Command in +place of Ctrl and drops the Shift - `Cmd+T`, `Cmd+W`, `Cmd+F`. The full +side-by-side list is under [Keyboard shortcuts](#keyboard-shortcuts). + | Action | How | |---|---| | New tab (default shell) | `Ctrl+Shift+T`, or the `+` button at the right of the tab bar | @@ -11,9 +15,14 @@ A tabbed terminal with one-click command buttons and reusable command presets. | New browser tab | **File → New Browser** | | Close tab | `Ctrl+Shift+W`, or the `x` on the tab | | Next / previous tab | `Ctrl+Tab` / `Ctrl+Shift+Tab` | +| Go to tab 1-8, or the last | `Alt+1` ... `Alt+9`, or `Ctrl+Alt+1` ... | | Rename a tab | Double-click the tab | -| Split the pane | `Alt+Shift+=` (right) / `Alt+Shift+-` (down), or right-click → **Pane → Split** | +| Find in the scrollback | `Ctrl+Shift+F` | +| Copy / paste | `Ctrl+Shift+C` / `Ctrl+Shift+V` | +| Bigger / smaller / default text | `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | +| Split the pane | Right-click → **Pane → Split**, or see [Split panes](#split-panes) | | Close a pane | `Alt+Shift+W`, or right-click → **Pane → Close** | +| Move the keyboard between panes | `Alt+←` `Alt+→` `Alt+↑` `Alt+↓` | | Move a pane | Right-click → **Pane → Move Left/Right** (or Up/Down) | | Pull a pane into its own tab | Right-click → **Pane → Move to New Tab** | @@ -46,9 +55,15 @@ button isn't available there - Qt only draws it alongside existing tabs. ## Split panes A tab can hold several panes side by side. Right-click a terminal and pick -**Pane → Split Right** or **Split Down**, or use `Alt+Shift+=` / -`Alt+Shift+-`. Everything that rearranges panes lives under that one **Pane** -group. +**Pane → Split Right** or **Split Down**, or use a keyboard chord - +there are two pairs and either works: + +- `Ctrl+Shift+|` splits **right** - a vertical bar for a vertical divider +- `Ctrl+Shift+_` splits **down** - an underscore for a horizontal one +- `Alt+Shift++` splits right, `Alt+Shift+-` splits down, matching Windows + Terminal + +Everything that rearranges panes lives under that one **Pane** group. Splits nest, so you can build columns of rows. Drag the divider to resize. Browser panes split too, and a split gives you **another pane of the same @@ -56,6 +71,15 @@ kind** - splitting a browser gives a browser, splitting a terminal gives a terminal. In a browser pane the shortcuts are the only route: right-clicking a web page shows Chromium's own menu, which you want for links and images. +`Alt+←` `Alt+→` `Alt+↑` `Alt+↓` move the keyboard between panes. They go by +where panes actually sit on screen, not by the order they were created, so +`Alt+→` lands on the pane genuinely to the right even in a nested split. There +is no wraparound - from the rightmost pane, `Alt+→` stays put. + +A new pane takes the keyboard as soon as it opens, whether it came from a +split or a new tab, so you can type into it straight away without clicking +first. + The pane you last clicked or typed in is the **active** one, outlined in the highlight colour whenever a tab has more than one. That outline matters: sidebar buttons, the Command menu and Selection Actions all go to the active @@ -73,8 +97,91 @@ commands cover the cases that actually come up. **Pane → Close** (`Alt+Shift+W`) closes just that terminal; closing the last pane closes the tab. `Ctrl+Shift+W` still closes the whole tab, panes and -all. `Alt+Shift` chords are used rather than `Ctrl+Shift` because shells and -full-screen apps rarely bind them. +all. `Alt+Shift+W` rather than a `Ctrl` chord, because closing a pane +sits next to the `Alt+Shift` splits above it. + +## Keyboard shortcuts + +Every shortcut, on each platform. The two columns differ more than a +find-and-replace would suggest, and the reason is worth a sentence: + +- **On Windows and Linux the shell owns `Ctrl`+letter.** `Ctrl+C` interrupts, + `Ctrl+W` deletes a word, `Ctrl+F` moves forward a character. So qtxterm's + own actions take `Ctrl+Shift`, exactly as Windows Terminal, GNOME Terminal + and VS Code's terminal do. +- **On macOS the opposite holds.** The shell uses Control and Command is + free, so the binding is plain `Cmd`+letter, like every other Mac app. In + particular `Cmd+C` is copy while Control+C still interrupts, because they + are different keys. + +| Action | Windows / Linux | macOS | +|---|---|---| +| New tab | `Ctrl+Shift+T` | `Cmd+T` | +| Close tab | `Ctrl+Shift+W` | `Cmd+W` | +| Next tab | `Ctrl+Tab` | `Ctrl+Tab` or `Cmd+Shift+]` | +| Previous tab | `Ctrl+Shift+Tab` | `Ctrl+Shift+Tab` or `Cmd+Shift+[` | +| Go to tab 1-8, or the last with 9 | `Alt+1`..`Alt+9` or `Ctrl+Alt+1`..`9` | `Cmd+1`..`Cmd+9` | + +Where two chords are listed they both work on purpose. `Alt+1` is GNOME +Terminal's and `Ctrl+Alt+1` is Windows Terminal's, and people arrive with +one or the other already in their fingers. +| Find | `Ctrl+Shift+F` | `Cmd+F` | +| Copy | `Ctrl+Shift+C` or `Ctrl+Insert` | `Cmd+C` | +| Paste | `Ctrl+Shift+V` or `Shift+Insert` | `Cmd+V` | +| Bigger text | `Ctrl+=` or `Ctrl+Shift+=` | `Cmd+=` or `Cmd+Shift+=` | +| Smaller text | `Ctrl+-` | `Cmd+-` | +| Default text size | `Ctrl+0` | `Cmd+0` | +| Split right | `Alt+Shift++` | `Cmd+D` | +| Split down | `Alt+Shift+-` | `Cmd+Shift+D` | +| Close pane | `Alt+Shift+W` | `Cmd+Shift+W` | +| Move between panes | `Alt+←` `Alt+→` `Alt+↑` `Alt+↓` | `Cmd+Opt+←` and friends | +| Follow a link | `Ctrl+click` | `Cmd+click` | + +On Windows and Linux the splits have a second pair, `Ctrl+Shift+|` for right +and `Ctrl+Shift+_` for down, which read as what they do - a vertical bar for +a vertical divider, an underscore for a horizontal one. + +Two macOS choices are worth calling out. Next tab is a physical `Ctrl+Tab` +there, not `Cmd+Tab`, which belongs to the OS application switcher. And +`Cmd+D` / `Cmd+Shift+D` for splitting come from iTerm2 rather than from the +Windows chords, since that is what Mac terminal users already have in their +fingers. + +### Changing a shortcut + +**File -> Keyboard Shortcuts...** rebinds any of them. Pick an action, press +the keys you want, and press **Add**. **Remove** drops a chord, **Reset** puts +one action back to its default, and **Reset All** puts back the lot. Actions +you have changed are shown in bold. + +An action can hold more than one chord - that is why several of them ship with +two - and it can hold none at all, if you would rather have the key back for +the shell. + +Two things the editor will not let you do, both for the same reason. A chord +already used by another action is refused, naming the action holding it; and a +chord you type replaces nothing silently. Qt fires **neither** of two shortcuts +that share a chord, so quietly accepting a duplicate would break both actions +with nothing to show for it. + +Changes apply immediately - there is no restart, and open tabs pick the new +chord up at once. + +Only the shortcuts you actually change are saved, so anything you leave alone +keeps following the defaults, including in later versions that improve them. +The file is JSON and hand-editable, beside your presets: + +- Windows - `%LOCALAPPDATA%\qtxterm\keybindings.json` +- macOS - `~/Library/Application Support/qtxterm/keybindings.json` +- Linux - `~/.config/qtxterm/keybindings.json` + +If a chord in that file cannot be read it is ignored rather than stopping the +app, and the action falls back to its default. + +This is also the answer to a shortcut that never arrives. A tiling window +manager that owns `Alt+Arrow`, or a desktop that has claimed a chord for +itself, takes the key before qtxterm ever sees it - no default table can +predict that, so rebind it to something free. ## Browser tabs @@ -122,7 +229,17 @@ item flips back to unchecked so you can bring it back. ## Copy and paste -Right-click in a terminal for **Copy** and **Paste**. +`Ctrl+Shift+C` and `Ctrl+Shift+V`, or right-click for **Copy** and **Paste**. +`Ctrl+Insert` and `Shift+Insert` work too. On macOS it is plain `Cmd+C` and +`Cmd+V`. + +`Ctrl+C` is deliberately left alone on Windows and Linux: it is the +interrupt, and a terminal that stole it to mean copy would be unable to stop +a running command. macOS has no such clash, because copy is `Cmd+C` there +while the interrupt is Control+C - two different keys. + +Copying with nothing selected does nothing at all rather than emptying the +clipboard, which matters most on macOS where the binding is a bare `Cmd+C`. - **Copy** takes the text you've selected with the mouse. It's greyed out when nothing is selected. @@ -131,6 +248,62 @@ Right-click in a terminal for **Copy** and **Paste**. clipboard text is handed to the terminal as a paste, not as typing, so shells and editors that use bracketed paste treat it correctly. +## Find in the scrollback + +`Ctrl+Shift+F` opens a find bar in the top-right corner of the active +terminal. It searches the whole scrollback, not just the lines on screen, so +it will find something that scrolled past a thousand lines ago - as far back +as your scrollback setting keeps. + +| Key | What | +|---|---| +| `Ctrl+Shift+F` | Open the find bar (again to re-focus it) | +| `Enter` | Next match | +| `Shift+Enter` | Previous match | +| `Esc` | Close, clear the highlights, and put the cursor back in the terminal | + +`Aa` makes the search case-sensitive, `.*` treats what you typed as a regular +expression. The counter reads `3 of 17`, or `No results` with the box outlined +in red. + +Every match is tinted, and the one you are on is brighter with an outline +around it. Both are drawn in the theme's own colors, so the bar and the +highlights follow whatever theme you have set - including a theme change while +the bar is open. + +Typing extends the current match rather than jumping ahead on every keystroke, +so searching for `error` doesn't walk you through three matches on the way to +finishing the word. + +`Ctrl+Shift+F` rather than `Ctrl+F` because `Ctrl+F` is forward-char in +bash and readline, and is bound to something in most full-screen apps. Find +does nothing in a browser pane - there is no scrollback to search, and it +won't quietly search a terminal you aren't looking at. + +## Clickable links + +A URL in the output is a link. Hover it and it underlines, with a tip showing +where it goes; **Ctrl+click** (Cmd+click on macOS) opens it in your normal +browser. + +Ctrl rather than a plain click, matching VS Code's terminal, Windows Terminal +and iTerm2: an ordinary click already places the cursor and starts a +selection, and terminal output is full of URLs you did not mean to visit. + +Only `http://` and `https://` are linkified, and only those are ever opened. +A bare `example.com` stays plain text. That is deliberate rather than +fussiness - what the terminal prints is not necessarily yours: over SSH it is +whatever the remote host chose to print. Restricting the schemes keeps a +printed line from launching a local handler. + +The tip shows the whole target, which is worth reading before you click: a +link can wrap across two rows or run off the edge of the terminal, so the +text under your cursor is not always the whole URL. + +Links open in your system browser rather than a qtxterm browser tab. That is +where your extensions, blocklists and logged-in sessions already live, and +it is the safer home for a URL that arrived as untrusted output. + ## Selection Actions Select text in a terminal, right-click, and pick **Selection** to run @@ -230,13 +403,34 @@ Changes save immediately, and the sidebar and both menus refresh straight away. Presets are stored as JSON, so you can hand-edit or back them up: - Windows - `%LOCALAPPDATA%\qtxterm\presets.json` -- Linux - `~/.config/qtxterm/presets.json` - macOS - `~/Library/Application Support/qtxterm/presets.json` +- Linux - `~/.config/qtxterm/presets.json` + +## Background image + +**File -> Preferences... -> Background image** puts a picture behind the +terminal. **Image strength** controls how much of it shows through. + +The theme colour is laid over the image as a veil rather than replaced by it, +so lowering the strength dims the picture toward your theme's normal +background. The default is 30%, because a photograph at full strength behind +text is unreadable - start there and raise it until it stops being +comfortable. + +**The image spans the tab, not each pane.** Split a tab three ways and you +get one continuous picture with the dividers cutting across it, rather than +the same image repeated in every pane. Each tab shows the whole image again. + +The path is stored, not a copy of the file, so replacing the image on disk +changes the background without touching the setting. Point it at a file that +no longer exists and you simply get a normal terminal rather than a broken +one. **Clear** removes it. ## Preferences -**File → Preferences...** sets the default shell, color theme, font, and -font size. +**File → Preferences...** sets the default shell, what happens when a shell +exits, the color theme, font, font size, scrollback, and the order of the +right-click menu. ### Default shell @@ -250,6 +444,25 @@ shell you pick there regardless of this setting. If the chosen shell later disappears - a WSL distro you removed - new tabs quietly fall back to the system default rather than failing to open. +### When a shell exits + +What happens to a pane once its shell finishes: + +| Setting | What it does | +|---|---| +| Close it, unless the shell failed | The default. A shell you exited on purpose takes its pane with it; one that died leaves the pane open | +| Always close it | Even when the shell failed | +| Leave it open | What qtxterm did before this setting existed | + +The default is the middle ground on purpose. Exiting a shell yourself means +you are finished with that pane, so keeping it costs a second keystroke. But +a shell that *died* has usually printed why, and closing its pane throws that +away exactly when you wanted to read it. + +Only the pane closes, not the tab around it - unless it was the last pane, in +which case the tab goes too. The window still outlives its terminals either +way. + ### Appearance | Theme | Look | @@ -266,6 +479,14 @@ leaves the native look alone. Changes apply immediately to every open tab, and are remembered for next time. +`Ctrl+=` and `Ctrl+-` resize the text without opening this dialog, and +`Ctrl+0` puts it back to the default. `Ctrl+Shift+=` zooms in too - it is +the same key with Shift held, which is how most people press "plus", and +every browser accepts both. There is only one stored size, so +zooming *is* editing the preference - which is why `Ctrl+0` returns to the +default rather than to whatever the dialog last held, since otherwise it +would have nothing to mean. + ## What else is remembered The window's size and position, and whether the Commands sidebar is showing, @@ -273,8 +494,8 @@ are restored the next time you open qtxterm - alongside the appearance settings above. They live next to your presets: - Windows - `%LOCALAPPDATA%\qtxterm\window_state.ini` -- Linux - `~/.config/qtxterm/window_state.ini` - macOS - `~/Library/Application Support/qtxterm/window_state.ini` +- Linux - `~/.config/qtxterm/window_state.ini` ## How multiline presets run diff --git a/src/qtxterm/assets/terminal.html b/src/qtxterm/assets/terminal.html index 8348505..1c936aa 100644 --- a/src/qtxterm/assets/terminal.html +++ b/src/qtxterm/assets/terminal.html @@ -18,13 +18,119 @@ resolve CSS custom properties inside ::-webkit-scrollbar-* pseudo elements, so a var() rule silently falls back and never follows the theme. */ + + /* Find bar. Lives in the page rather than in a Qt widget above the view so + that showing it doesn't resize the terminal - a Qt bar would take rows + off the grid, reflowing the shell's output every time you searched. + Overlaid, so it costs the terminal nothing. + + Colors come from the theme as custom properties set by terminal.js + (applyFindTheme). Only the four roots are injected; every shade below is + mixed off them, so a new theme needs no new variables. */ + #find-bar[hidden] { display: none; } + #find-bar { + position: fixed; + top: 0; + /* Clear of the scrollbar (8px) so the bar never sits on top of it. */ + right: 14px; + z-index: 10; + display: flex; + align-items: center; + gap: 4px; + padding: 5px 6px; + /* Lifted off the terminal ground, or the bar reads as output. */ + background: color-mix(in srgb, var(--find-fg) 10%, var(--find-bg)); + border: 1px solid color-mix(in srgb, var(--find-fg) 32%, var(--find-bg)); + border-top: none; + border-radius: 0 0 5px 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + /* The UI font, not the terminal's: this is chrome, not output. */ + font: 12px/1.4 system-ui, "Segoe UI", sans-serif; + color: var(--find-fg); + } + #find-input { + width: 180px; + padding: 3px 6px; + font: inherit; + color: var(--find-fg); + background: var(--find-bg); + border: 1px solid color-mix(in srgb, var(--find-fg) 32%, var(--find-bg)); + border-radius: 3px; + outline: none; + } + #find-input:focus { border-color: var(--find-accent); } + #find-bar.no-results #find-input { border-color: var(--find-error); } + #find-count { + min-width: 66px; + text-align: right; + opacity: 0.75; + /* So the width doesn't twitch as the index counts up. */ + font-variant-numeric: tabular-nums; + } + #find-bar button { + min-width: 22px; + height: 22px; + padding: 0 4px; + font: inherit; + line-height: 1; + color: var(--find-fg); + background: transparent; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; + } + #find-bar button:hover { + background: color-mix(in srgb, var(--find-fg) 18%, transparent); + } + #find-bar button.active { + background: color-mix(in srgb, var(--find-accent) 30%, transparent); + border-color: var(--find-accent); + } + + /* Hovering a URL shows what it points at and how to follow it. + Both halves matter: a link can be wrapped across two rows or run off the + edge of the terminal, so the text under the cursor is not necessarily + the whole target - and the modifier is not guessable. Positioned from + JS, since it follows the pointer. */ + #link-tip[hidden] { display: none; } + #link-tip { + position: fixed; + z-index: 11; + max-width: 60ch; + padding: 4px 7px; + /* Breaks anywhere, because a URL has no spaces to wrap at and would + otherwise push the tip wider than the window. */ + overflow-wrap: anywhere; + font: 12px/1.4 system-ui, "Segoe UI", sans-serif; + color: var(--find-fg); + background: color-mix(in srgb, var(--find-fg) 10%, var(--find-bg)); + border: 1px solid color-mix(in srgb, var(--find-fg) 32%, var(--find-bg)); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + pointer-events: none; + } + #link-tip .link-hint { + opacity: 0.7; + }
+ +