diff --git a/README.md b/README.md index 6cbc329..974c8ba 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Nirux is alpha software. ## Highlights - Persistent workspaces: keep each task's name and context visible, stack workspaces vertically, and archive inactive ones in a collapsible sidebar section that does not poll GitHub. Collapsed, the sidebar is a rail: the project on top (its menu switches projects), then a tile per workspace with its initials (a branch name drops its type, so `feat/crash-report` reads CR) and its state in the corner, amber only when an agent waits for you; inactive workspaces stay behind an archive tile, and "+" opens a new one. Hover a tile for what it's doing; click, drag and right-click work as on the cards. -- Horizontal columns: mix Ghostty-backed terminals, WKWebView browser columns, and Monaco editor columns in the same workspace. `Cmd+F` in a terminal searches its scrollback with Ghostty's native search, highlighting every match. +- Horizontal columns: mix Ghostty-backed terminals, WKWebView browser columns, and Monaco editor columns in the same workspace. `Cmd+F` in a terminal searches its scrollback with Ghostty's native search, highlighting every match; in a browser column, WebKit's find searches the page and counts the matches. - Agent launchers: start Claude Code or Codex from the command palette with configurable permission and sandbox presets. - Attention and Activity: per-column agent status (working / needs attention, with elapsed time) driven by real Claude Code hooks and Codex turn notifications — not output guessing (Gemini CLI and OpenCode, which have no hooks, get output-activity status) — plus a persistent sidebar feed, edge glows for off-screen attention, native macOS notifications that focus the right workspace and column on click, and a Dock badge counting waiting workspaces. - Stuck agents: a permission or question left open past a threshold (Settings, 10 minutes by default) shows `waiting 2h05m` on its card and notifies once (Telegram too, and while Nirux is in the background); a Claude turn that ended on an API error shows `API error`, with a Resume button — for transient errors only (overloaded, server error) — that types `continue` only on a click, once Claude is back at an empty prompt; a `claude` that died mid-turn gets an overlay that resumes its conversation in its permission mode. @@ -145,8 +145,8 @@ Useful shortcuts: | `Cmd+Z` / `Shift+Cmd+Z` | Undo / redo in the editor and in panel text fields (palette, rename, settings) | | `Shift+Cmd+F` | Search workspace | | `Alt+Cmd+F` | Search Everywhere: text in the scrollback of every terminal, across workspaces and projects; picking a match opens its terminal's find bar on it. A full-screen program (vim, less, Claude Code in its no-flicker mode) shows no scrollback: only its screen is searched. Then the transcripts of the Claude sessions the history knows (running or past, only what you typed and Claude answered, read in place within a time budget); picking one of their matches resumes the session, or goes to its column | -| `Cmd+F` | Find in the focused editor or terminal; a terminal's find bar closes with `Esc` | -| `Cmd+G` / `Shift+Cmd+G` | Next / previous terminal match (`Return` / `Shift+Return` in the find bar); next moves up to older output, as in Ghostty | +| `Cmd+F` | Find in the focused editor, terminal or browser page (unless the page has its own search); the find bar closes with `Esc` (on a page, from its field) | +| `Cmd+G` / `Shift+Cmd+G` | Next / previous match (`Return` / `Shift+Return` in the find bar); in a terminal, next moves up to older output, as in Ghostty | | `Cmd+S` / `Alt+Cmd+S` | Save the active editor file / save all | | `Shift+Cmd+D` | Toggle editor diff | | `Alt+Cmd+Z` | Toggle word wrap in editor | diff --git a/Sources/Nirux/Input/TerminalFindKeyRouting.swift b/Sources/Nirux/Input/TerminalFindKeyRouting.swift index eec5747..da76857 100644 --- a/Sources/Nirux/Input/TerminalFindKeyRouting.swift +++ b/Sources/Nirux/Input/TerminalFindKeyRouting.swift @@ -1,7 +1,8 @@ import AppKit -/// Where the key interceptor sends a key when a terminal's find bar is -/// involved. Terminal columns otherwise write every key to the PTY. +/// Where the key interceptor sends a key when a find bar is involved: a +/// terminal's, or a browser column's. Terminal columns otherwise write every +/// key to the PTY. enum TerminalFindKeyRouting { enum FieldRoute: Equatable { /// Plain keys (Return, Escape, arrows included) edit the field. @@ -16,7 +17,7 @@ enum TerminalFindKeyRouting { case fieldEditor } - /// A key typed while a terminal's find field has the focus. + /// A key typed while a find field has the focus. static func routeInField(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> FieldRoute { let modifiers = modifierFlags.intersection([.command, .option, .control, .shift]) guard modifiers.contains(.command) else { return .field } diff --git a/Sources/Nirux/Input/WebContentKeyRouting.swift b/Sources/Nirux/Input/WebContentKeyRouting.swift index 139932c..596887d 100644 --- a/Sources/Nirux/Input/WebContentKeyRouting.swift +++ b/Sources/Nirux/Input/WebContentKeyRouting.swift @@ -39,9 +39,10 @@ enum WebContentKeyRouting { modifiers == [.command] || modifiers == [.command, .shift] { return true } - // Cmd+F, Cmd+G, Shift+Cmd+G: Edit > Find in Terminal and Find - // Next/Previous only act on terminal columns. Monaco's find widget - // and web apps' own search keep these chords. + // Cmd+F, Cmd+G, Shift+Cmd+G: Monaco's find widget and web apps' + // own search keep these chords. A page that leaves them unhandled + // hands them back (WebKit resends the key), and Edit > Find opens + // a browser column's find bar, as in Safari. if types("f", ansiKeyCode: 0x03), modifiers == [.command] { return true } diff --git a/Sources/Nirux/Model/ColumnState.swift b/Sources/Nirux/Model/ColumnState.swift index 9b6bfd8..6c8dbbc 100644 --- a/Sources/Nirux/Model/ColumnState.swift +++ b/Sources/Nirux/Model/ColumnState.swift @@ -114,9 +114,10 @@ final class ColumnState { /// The user asked to resume the agent that died mid-turn. var onResumeExitedAgent: (() -> Void)? - /// ⌘F find bar and the search it drives, created on first use - /// (ColumnState+TerminalFind.swift). - var findBar: TerminalFindBar? + /// ⌘F find bar, created on first use, and the terminal search it + /// drives; a browser column's bar drives its `pageFind` + /// (ColumnState+Find.swift). + var findBar: FindBar? var terminalSearch: TerminalSearchSession? /// Claude session transcript this column follows, its latest usage and @@ -138,8 +139,8 @@ final class ColumnState { private static func terminalMenu() -> NSMenu { let menu = NSMenu() menu.addItem(ColumnHeaderView.columnItem( - "Find in Terminal…", mainMenuAction: #selector(NiruxApp.showTerminalFind(_:)) - ) { $0.showTerminalFind() }) + "Find in Terminal…", mainMenuAction: #selector(NiruxApp.showFind(_:)) + ) { $0.showFind() }) menu.addItem(.separator()) ColumnHeaderView.columnMenuItems().forEach(menu.addItem) return menu diff --git a/Sources/Nirux/NiruxApp+KeyHandling.swift b/Sources/Nirux/NiruxApp+KeyHandling.swift index d250880..d181698 100644 --- a/Sources/Nirux/NiruxApp+KeyHandling.swift +++ b/Sources/Nirux/NiruxApp+KeyHandling.swift @@ -39,14 +39,14 @@ extension NiruxApp { let col = workspace.columns[safe: workspace.focusedIndex] else { return event } // Don't consume modifiers for web content columns, or while a - // terminal's find field is typing. + // find field is typing. if Self.takesWebContentKeys(col) || workspace.findFieldColumn != nil { return event } return nil } } - /// A key typed while a terminal's find field has the focus edits the - /// field, never a PTY (see TerminalFindKeyRouting). + /// A key typed while a find field has the focus edits the field, never + /// a PTY or page (see TerminalFindKeyRouting). private static func routeFindFieldKey(_ event: NSEvent, in column: ColumnState) -> NSEvent? { switch TerminalFindKeyRouting.routeInField(keyCode: event.keyCode, modifierFlags: event.modifierFlags) { case .field: @@ -202,7 +202,7 @@ extension NiruxApp { let col = workspace.columns[safe: workspace.focusedIndex] else { return event } - // Keys follow a terminal's find field while it has the focus, + // Keys follow a find field while it has the focus, // even when the focused column changed without moving the // keyboard (a resize drag, an agent opening an editor column). if let findColumn = workspace.findFieldColumn { diff --git a/Sources/Nirux/NiruxApp+MenuActions.swift b/Sources/Nirux/NiruxApp+MenuActions.swift index f887a2b..ca2e2f7 100644 --- a/Sources/Nirux/NiruxApp+MenuActions.swift +++ b/Sources/Nirux/NiruxApp+MenuActions.swift @@ -88,22 +88,31 @@ extension NiruxApp { shell?.showGlobalSearch() } - // The find items act on the main window's focused terminal column only: - // with a panel or a detached Web Inspector key, the bar would open - // behind it. - @objc func showTerminalFind(_ sender: Any?) { - guard NSApp.keyWindow === mainWindow else { return } - shell?.showTerminalFind() + // The find items act on the main window's focused terminal or browser + // column only: with a panel or a detached Web Inspector key, the bar + // would open behind it. + @objc func showFind(_ sender: Any?) { + guard canFind else { return } + shell?.showFind() } - @objc func findNextInTerminal(_ sender: Any?) { - guard NSApp.keyWindow === mainWindow else { return } - shell?.findNextInTerminal() + @objc func findNextMatch(_ sender: Any?) { + guard canFind else { return } + shell?.findNextMatch() } - @objc func findPreviousInTerminal(_ sender: Any?) { - guard NSApp.keyWindow === mainWindow else { return } - shell?.findPreviousInTerminal() + @objc func findPreviousMatch(_ sender: Any?) { + guard canFind else { return } + shell?.findPreviousMatch() + } + + /// The find items' state: no other window of the app is key (as Close + /// Column reads it), the main window isn't in the Dock, and a column can + /// search. Editor columns keep ⌘F for Monaco's find widget, which gets it + /// first. + var canFind: Bool { + let isMainWindowInFront = NSApp.keyWindow.map { $0 === mainWindow } ?? (mainWindow?.isMiniaturized != true) + return isMainWindowInFront && shell?.findTargetColumn != nil } @objc func toggleEditorDiff(_ sender: Any?) { @@ -212,11 +221,11 @@ extension NiruxApp { editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editMenu.addItem(NSMenuItem.separator()) - editMenu.addItem(withTitle: "Find in Terminal…", action: #selector(showTerminalFind(_:)), keyEquivalent: "f") - editMenu.addItem(withTitle: "Find Next", action: #selector(findNextInTerminal(_:)), keyEquivalent: "g") + editMenu.addItem(withTitle: "Find…", action: #selector(showFind(_:)), keyEquivalent: "f") + editMenu.addItem(withTitle: "Find Next", action: #selector(findNextMatch(_:)), keyEquivalent: "g") let findPreviousItem = editMenu.addItem( withTitle: "Find Previous", - action: #selector(findPreviousInTerminal(_:)), + action: #selector(findPreviousMatch(_:)), keyEquivalent: "g" ) findPreviousItem.keyEquivalentModifierMask = [.command, .shift] diff --git a/Sources/Nirux/NiruxApp.swift b/Sources/Nirux/NiruxApp.swift index 06fbe4f..78d29f6 100644 --- a/Sources/Nirux/NiruxApp.swift +++ b/Sources/Nirux/NiruxApp.swift @@ -285,6 +285,9 @@ final class NiruxApp: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, NSMen if menuItem.action == #selector(resumeAllAgents(_:)) { return (shell?.deferredAgentCount ?? 0) > 0 } + if [#selector(showFind(_:)), #selector(findNextMatch(_:)), #selector(findPreviousMatch(_:))].contains(menuItem.action) { + return canFind + } if menuItem.action == #selector(toggleInactiveWorkspaces(_:)) { guard let shell else { return false } menuItem.state = shell.sidebar.isInactiveSectionCollapsed ? .off : .on diff --git a/Sources/Nirux/Views/ColumnState+TerminalFind.swift b/Sources/Nirux/Views/ColumnState+Find.swift similarity index 58% rename from Sources/Nirux/Views/ColumnState+TerminalFind.swift rename to Sources/Nirux/Views/ColumnState+Find.swift index e2973b7..98076ca 100644 --- a/Sources/Nirux/Views/ColumnState+TerminalFind.swift +++ b/Sources/Nirux/Views/ColumnState+Find.swift @@ -1,30 +1,39 @@ import AppKit -// MARK: - Terminal find bar (⌘F, ⌘G, ⇧⌘G) +// MARK: - Find bar (⌘F, ⌘G, ⇧⌘G) of terminal and browser columns extension ColumnState { var isFindBarOpen: Bool { findBar.map { !$0.isHidden } ?? false } /// True while the find field has the keyboard focus: the key - /// interceptor must then leave keys to the field, not the PTY. + /// interceptor must then leave keys to the field, not the PTY or page. var isEditingFind: Bool { isFindBarOpen && findBar?.isEditing == true } + /// A browser column, or a terminal whose shell runs: a shell-exited + /// overlay hides the scrollback. + var canFind: Bool { + isWebView || (terminalView != nil && pty?.hasExited != true) + } + /// ⌘F: open the find bar, or focus it again with its text selected. - /// Reopening searches the kept text again. Not over a shell-exited - /// overlay, which hides the scrollback. + /// Reopening searches the kept text again, once the field has the + /// keyboard: a page that loses it drops its selection, so its search + /// starts over from the top. func showFindBar() { - guard terminalView != nil, pty?.hasExited != true else { return } + guard canFind else { return } let bar = findBar ?? makeFindBar() - // Above the terminal and a shell-exited overlay added since. + // Above the terminal or page, and a shell-exited overlay added since. if view.subviews.last !== bar { view.addSubview(bar, positioned: .above, relativeTo: nil) } - if bar.isHidden { - bar.isHidden = false - terminalSearch?.update(bar.field.stringValue, immediately: true) - } + let reopens = bar.isHidden + bar.isHidden = false layoutFindBar() bar.focusField() + if reopens { + terminalSearch?.update(bar.field.stringValue, immediately: true) + webViewColumn?.pageFind.update(bar.field.stringValue) + } } /// Search Everywhere's pick: the find bar opened on `needle` as a new @@ -53,59 +62,70 @@ extension ColumnState { func findNext() { guard isFindBarOpen else { return } terminalSearch?.next() + webViewColumn?.pageFind.next() } /// ⇧⌘G / Shift+Return: previous match. Only while the bar is open. func findPrevious() { guard isFindBarOpen else { return } terminalSearch?.previous() + webViewColumn?.pageFind.previous() } - /// Escape or the close button: end the search (clearing Ghostty's - /// highlights), hide the bar and hand the keyboard back to the terminal. - /// The field keeps its text for the next ⌘F, the viewport its position - /// until the next keystroke (TerminalSearchSession.returnToPrompt). + /// Escape or the close button: end the search (clearing the + /// highlights), hide the bar and hand the keyboard back to the terminal + /// or the page. The field keeps its text for the next ⌘F, a terminal's + /// viewport its position until the next keystroke + /// (TerminalSearchSession.returnToPrompt), a page its selected match. func closeFindBar() { guard let bar = findBar, !bar.isHidden else { return } let wasEditing = bar.isEditing terminalSearch?.end() + webViewColumn?.pageFind.end() bar.isHidden = true - if wasEditing, let terminal = terminalView { - terminal.window?.makeFirstResponder(terminal) + let content: NSView? = isWebView ? webViewColumn?.webView : terminalView + if wasEditing, let content { + content.window?.makeFirstResponder(content) } } - /// Top-right corner of the terminal, under the header. + /// Top-right corner of the terminal or page, under the header. func layoutFindBar() { guard let bar = findBar, !bar.isHidden else { return } let margin: CGFloat = 10 - let width = min(TerminalFindBar.preferredWidth, max(0, view.bounds.width - margin * 2)) + // A browser column draws its header itself. + let top = isWebView ? ColumnHeaderView.height : headerHeight + let width = min(FindBar.preferredWidth, max(0, view.bounds.width - margin * 2)) bar.frame = NSRect( x: view.bounds.width - margin - width, - y: view.bounds.height - headerHeight - margin - TerminalFindBar.height, + y: view.bounds.height - top - margin - FindBar.height, width: width, - height: TerminalFindBar.height + height: FindBar.height ) } - private func makeFindBar() -> TerminalFindBar { - let bar = TerminalFindBar(frame: .zero) + private func makeFindBar() -> FindBar { + let bar = FindBar(target: isWebView ? .page : .terminal) bar.isHidden = true - let search = TerminalSearchSession { [weak self] command in - _ = self?.terminalView?.performBindingAction(command.bindingAction) + if let browser = webViewColumn { + browser.pageFind.onStatusChange = { [weak bar] status in bar?.status = status } + bar.onNeedleChange = { [weak browser] text in browser?.pageFind.update(text) } + } else { + terminalSearch = TerminalSearchSession { [weak self] command in + _ = self?.terminalView?.performBindingAction(command.bindingAction) + } + bar.onNeedleChange = { [weak self] text in self?.terminalSearch?.update(text) } } - bar.onNeedleChange = { [weak self] text in self?.terminalSearch?.update(text) } bar.onNext = { [weak self] in self?.findNext() } bar.onPrevious = { [weak self] in self?.findPrevious() } bar.onClose = { [weak self] in self?.closeFindBar() } findBar = bar - terminalSearch = search return bar } } extension WorkspaceState { - /// The terminal column whose find field has the keyboard focus, if any. + /// The column whose find field has the keyboard focus, if any. /// Usually the focused column, but a resize drag or an agent opening an /// editor column moves the focus index without moving the keyboard. var findFieldColumn: ColumnState? { @@ -115,28 +135,26 @@ extension WorkspaceState { extension NiruxShellView { /// The column Edit > Find acts on: the one whose find field is being - /// edited, else the focused column when it is a terminal. + /// edited, else the focused column when it can search (`canFind`). var findTargetColumn: ColumnState? { guard let workspace = activeWorkspace else { return nil } if let column = workspace.findFieldColumn { return column } - guard let column = workspace.columns[safe: workspace.focusedIndex], - column.terminalView != nil - else { return nil } + guard let column = workspace.columns[safe: workspace.focusedIndex], column.canFind else { return nil } return column } - /// Edit > Find in Terminal (⌘F). No-op unless a terminal column has the - /// focus: editor and browser columns get ⌘F themselves + /// Edit > Find (⌘F). No-op unless a terminal or browser column has the + /// focus: editor columns keep ⌘F for Monaco's find widget /// (WebContentKeyRouting). - func showTerminalFind() { + func showFind() { findTargetColumn?.showFindBar() } - func findNextInTerminal() { + func findNextMatch() { findTargetColumn?.findNext() } - func findPreviousInTerminal() { + func findPreviousMatch() { findTargetColumn?.findPrevious() } } diff --git a/Sources/Nirux/Views/TerminalFindBar.swift b/Sources/Nirux/Views/FindBar.swift similarity index 58% rename from Sources/Nirux/Views/TerminalFindBar.swift rename to Sources/Nirux/Views/FindBar.swift index 1247a17..91769d9 100644 --- a/Sources/Nirux/Views/TerminalFindBar.swift +++ b/Sources/Nirux/Views/FindBar.swift @@ -1,18 +1,28 @@ import AppKit -/// The find bar floating over a terminal column's top-right corner (⌘F): -/// a field, next/previous buttons and a close button. Return goes to the -/// next match, Shift+Return to the previous one, Escape closes the bar. -/// Ghostty searches from the bottom, so "next" moves up through older -/// output, and the up arrow is the next-match button, as in Ghostty. +/// The find bar floating over a terminal or browser column's top-right +/// corner (⌘F): a field, a match counter, next/previous buttons and a +/// close button. Return goes to the next match, Shift+Return to the +/// previous one, Escape closes the bar. /// -/// libghostty runs the search and highlights the matches in the terminal -/// (see TerminalSearchSession). There is no match counter: libghostty-spm -/// does not forward Ghostty's match totals to its delegate. +/// In a terminal, Ghostty searches from the bottom, so "next" moves up +/// through older output, and the up arrow is the next-match button, as in +/// Ghostty. libghostty runs the search and highlights the matches (see +/// TerminalSearchSession). There is no match counter: libghostty-spm does +/// not forward Ghostty's match totals to its delegate. On a page, "next" +/// moves down, and WebKit counts the matches (WebPageFind). @MainActor -final class TerminalFindBar: NSView, NSTextFieldDelegate { +final class FindBar: NSView, NSTextFieldDelegate { + /// What the bar searches: it sets the way "next" goes and the field's + /// accessibility label. + enum Target { + case terminal, page + } + static let height: CGFloat = 32 static let preferredWidth: CGFloat = 320 + /// The counter hides rather than leave the field narrower. + static let minimumFieldWidth: CGFloat = 80 var onNeedleChange: ((String) -> Void)? var onNext: (() -> Void)? @@ -22,18 +32,29 @@ final class TerminalFindBar: NSView, NSTextFieldDelegate { var currentModifierFlags: () -> NSEvent.ModifierFlags = { NSApp.currentEvent?.modifierFlags ?? [] } let field = NSTextField() - private let nextButton = TerminalFindBar.makeButton( - symbol: "chevron.up", label: "Next Match", toolTip: "Next Match (\u{2318}G)" - ) - private let previousButton = TerminalFindBar.makeButton( - symbol: "chevron.down", label: "Previous Match", toolTip: "Previous Match (\u{21E7}\u{2318}G)" - ) - private let closeButton = TerminalFindBar.makeButton( + /// The match counter ("12 matches", "Not found"); nil hides it. + var status: String? { + didSet { + statusLabel.stringValue = status ?? "" + needsLayout = true + } + } + private let statusLabel = NSTextField(labelWithString: "") + private let upButton: NSButton + private let downButton: NSButton + private let closeButton = FindBar.makeButton( symbol: "xmark", label: "Close Find Bar", toolTip: "Close (Esc)" ) - override init(frame: NSRect) { - super.init(frame: frame) + init(target: Target) { + let next = (label: "Next Match", toolTip: "Next Match (\u{2318}G)", action: #selector(nextClicked)) + let previous = (label: "Previous Match", toolTip: "Previous Match (\u{21E7}\u{2318}G)", action: #selector(previousClicked)) + let (up, down) = target == .terminal ? (next, previous) : (previous, next) + upButton = Self.makeButton(symbol: "chevron.up", label: up.label, toolTip: up.toolTip) + upButton.action = up.action + downButton = Self.makeButton(symbol: "chevron.down", label: down.label, toolTip: down.toolTip) + downButton.action = down.action + super.init(frame: .zero) wantsLayer = true layer?.backgroundColor = Theme.Color.surface.withAlphaComponent(0.98).cgColor layer?.cornerRadius = 7 @@ -53,16 +74,18 @@ final class TerminalFindBar: NSView, NSTextFieldDelegate { field.cell?.isScrollable = true field.cell?.wraps = false field.delegate = self - field.setAccessibilityLabel("Find in Terminal") + field.setAccessibilityLabel(target == .terminal ? "Find in Terminal" : "Find in Page") addSubview(field) - previousButton.target = self - previousButton.action = #selector(previousClicked) - nextButton.target = self - nextButton.action = #selector(nextClicked) - closeButton.target = self + statusLabel.font = Theme.Font.caption + statusLabel.textColor = Theme.Color.textSecondary + statusLabel.lineBreakMode = .byClipping + statusLabel.isHidden = true + addSubview(statusLabel) + closeButton.action = #selector(closeClicked) - for button in [nextButton, previousButton, closeButton] { + for button in [upButton, downButton, closeButton] { + button.target = self addSubview(button) } } @@ -71,7 +94,7 @@ final class TerminalFindBar: NSView, NSTextFieldDelegate { required init?(coder: NSCoder) { fatalError() } /// True while the field holds the keyboard focus. The key interceptor - /// then lets keys reach the field instead of the PTY. + /// then lets keys reach the field instead of the PTY or the page. var isEditing: Bool { guard let editor = field.currentEditor() else { return false } return window?.firstResponder === editor @@ -87,11 +110,21 @@ final class TerminalFindBar: NSView, NSTextFieldDelegate { let inset: CGFloat = 5 let side: CGFloat = 22 var buttonX = bounds.width - inset - side - for button in [closeButton, previousButton, nextButton] { + for button in [closeButton, downButton, upButton] { button.frame = NSRect(x: buttonX, y: (bounds.height - side) / 2, width: side, height: side) buttonX -= side + 2 } - let fieldMaxX = nextButton.frame.minX - 6 + var fieldMaxX = upButton.frame.minX - 6 + let statusSize = statusLabel.cell?.cellSize ?? .zero + let statusWidth = ceil(statusSize.width) + statusLabel.isHidden = status == nil || fieldMaxX - statusWidth - 4 - inset < Self.minimumFieldWidth + if !statusLabel.isHidden { + statusLabel.frame = NSRect( + x: fieldMaxX - statusWidth, y: (bounds.height - statusSize.height) / 2, + width: statusWidth, height: statusSize.height + ) + fieldMaxX = statusLabel.frame.minX - 4 + } field.frame = NSRect( x: inset, y: (bounds.height - side) / 2, diff --git a/Sources/Nirux/Views/NiruxShellView.swift b/Sources/Nirux/Views/NiruxShellView.swift index 820a098..14f6eb4 100644 --- a/Sources/Nirux/Views/NiruxShellView.swift +++ b/Sources/Nirux/Views/NiruxShellView.swift @@ -815,7 +815,12 @@ extension NiruxShellView { func focusActiveTerminal(in window: NSWindow?, editorTakesKeyboard: Bool = false) { guard let col = activeWorkspace?.columns[safe: activeWorkspace?.focusedIndex ?? 0], let window else { return } - if let webView = col.webViewColumn { + if col.isFindBarOpen { + // An open find bar keeps the keyboard: text typed for it must + // reach neither the agent nor the page's shortcuts. A click on + // the terminal or page takes it back. + if !col.isEditingFind { col.findBar?.focusField() } + } else if let webView = col.webViewColumn { window.makeFirstResponder(webView.webView) } else if let editor = col.editorColumn { if editorTakesKeyboard { editor.takeKeyboard() } @@ -823,10 +828,6 @@ extension NiruxShellView { window.makeFirstResponder(board.view) } else if let review = col.branchReview { review.view.takeKeyboard() - } else if col.isFindBarOpen { - // An open find bar keeps the keyboard: text typed for it must - // not reach the agent. A click on the terminal takes it back. - if !col.isEditingFind { col.findBar?.focusField() } } else if let terminal = col.terminalView { window.makeFirstResponder(terminal) } diff --git a/Sources/Nirux/Views/WebPageFind.swift b/Sources/Nirux/Views/WebPageFind.swift new file mode 100644 index 0000000..59e5760 --- /dev/null +++ b/Sources/Nirux/Views/WebPageFind.swift @@ -0,0 +1,159 @@ +import WebKit + +/// Find in page for a browser column (⌘F), with WebKit's own find, the one +/// Safari runs: it selects the match and scrolls to it, highlights every +/// match and counts them. The counter reads like Safari's ("12 matches"): +/// WebKit's index of the current match drifts as the needle is edited. +/// +/// WKWebView's public `find(_:configuration:completionHandler:)` neither +/// highlights nor counts, so this drives its private find calls while +/// WebKit has them, and falls back on the public one otherwise, which only +/// tells "Not found". +@MainActor +final class WebPageFind: NSObject { + /// Matches counted at most, as in Safari. + nonisolated static let maxCount = 1000 + nonisolated static let notFound = "Not found" + + /// The counter changed; nil shows none. + var onStatusChange: ((String?) -> Void)? + private(set) var status: String? { + didSet { if status != oldValue { onStatusChange?(status) } } + } + private(set) var needle = "" + /// From a search to `end` or a new document: an answer arriving after + /// those is dropped. + private var isSearching = false + /// The column owns both: WebKit holds this object weakly. + private let webView: WKWebView + private let privateFind: WebKitPrivateFind? + + init(webView: WKWebView, usesPrivateFind: Bool = true) { + self.webView = webView + privateFind = usesPrivateFind ? Self.privateFind(of: webView) : nil + super.init() + // A weak reference: the browser column keeps this object. + privateFind?.setFindDelegate(self) + } + + /// The field's text changed, or the bar reopened: search from the + /// selected match, which stays selected while it still matches. + func update(_ text: String) { + needle = text + guard !text.isEmpty else { + clearHighlights() + return + } + // WebKit searches from the selection, and past it when the match it + // finds is the selection itself: from the selection's start, the + // same needle (a case change, a bar reopened) finds it again. In the + // app's own JavaScript world, before the find: WebKit keeps the + // order of both. Main frame only: a match in a frame may still move. + webView.evaluateJavaScript( + "{ const selection = getSelection(); if (selection.rangeCount) selection.collapseToStart(); }", + in: nil, in: .defaultClient + ) + find(backwards: false) + } + + func next() { + find(backwards: false) + } + + func previous() { + find(backwards: true) + } + + /// The bar closed: the highlights go, the match stays selected, as in + /// Safari. + func end() { + isSearching = false + clearHighlights() + } + + /// A new document replaced the one searched, with none of its matches: + /// an answer about the old one is dropped. + func pageDidChange() { + isSearching = false + status = nil + } + + private func clearHighlights() { + privateFind?.hideFindUI() + status = nil + } + + private func find(backwards: Bool) { + guard !needle.isEmpty else { return } + isSearching = true + if let privateFind { + var options: PrivateFindOptions = [.caseInsensitive, .wrapAround, .showFindIndicator, .showHighlight] + if backwards { options.insert(.backwards) } + privateFind.findString(needle, options: options.rawValue, maxCount: UInt(Self.maxCount)) + return + } + let configuration = WKFindConfiguration() + configuration.backwards = backwards + let searched = needle + webView.find(searched, configuration: configuration) { [weak self] result in + guard let self, isSearching, needle == searched else { return } + status = result.matchFound ? nil : Self.notFound + } + } + + nonisolated static func status(matches: UInt) -> String { + switch matches { + case 0: notFound + case 1: "1 match" + // Past `maxCount`, WebKit reports UInt32.max. + case ...UInt(maxCount): "\(matches) matches" + default: "\(maxCount)+ matches" + } + } + + // MARK: - _WKFindDelegate + + @objc(_webView:didFindMatches:forString:withMatchIndex:) + func webView(_ webView: WKWebView, didFindMatches matches: UInt, forString string: String, withMatchIndex index: Int) { + guard isSearching, string == needle else { return } + status = Self.status(matches: matches) + } + + @objc(_webView:didFailToFindString:) + func webView(_ webView: WKWebView, didFailToFindString string: String) { + guard isSearching, string == needle else { return } + status = Self.notFound + } + + // MARK: - Private find calls + + /// `_WKFindOptions` (WebKit's _WKFindOptions.h). + private struct PrivateFindOptions: OptionSet { + let rawValue: UInt + static let caseInsensitive = Self(rawValue: 1 << 0) + static let backwards = Self(rawValue: 1 << 3) + static let wrapAround = Self(rawValue: 1 << 4) + /// The bounce on the match found. + static let showFindIndicator = Self(rawValue: 1 << 6) + /// Every match highlighted; also makes WebKit count them. + static let showHighlight = Self(rawValue: 1 << 7) + } + + /// The web view as its private find calls, when it answers all of them. + private static func privateFind(of webView: WKWebView) -> WebKitPrivateFind? { + let selectors = ["_findString:options:maxCount:", "_hideFindUI", "_setFindDelegate:"].map(NSSelectorFromString) + guard selectors.allSatisfy(webView.responds(to:)) else { return nil } + return unsafeBitCast(webView, to: WebKitPrivateFind.self) + } +} + +/// WKWebView's private find calls (WKWebViewPrivate.h), which Safari's +/// find bar drives. +@objc private protocol WebKitPrivateFind { + @objc(_findString:options:maxCount:) + func findString(_ string: String, options: UInt, maxCount: UInt) + @objc(_hideFindUI) + func hideFindUI() + @objc(_setFindDelegate:) + func setFindDelegate(_ delegate: AnyObject?) +} diff --git a/Sources/Nirux/Views/WebViewColumn.swift b/Sources/Nirux/Views/WebViewColumn.swift index 868b507..7d0d216 100644 --- a/Sources/Nirux/Views/WebViewColumn.swift +++ b/Sources/Nirux/Views/WebViewColumn.swift @@ -21,6 +21,8 @@ final class WebViewColumn: NSView, WKNavigationDelegate, WKUIDelegate { var onURLChanged: (() -> Void)? private(set) var pageTitle: String = "" private var observations: [NSKeyValueObservation] = [] + /// Find in page (⌘F), driven by the column's find bar. + let pageFind: WebPageFind /// Destination URLs of in-flight downloads, for the completion /// notification's reveal-in-Finder action. private var downloadDestinations: [ObjectIdentifier: URL] = [:] @@ -39,6 +41,7 @@ final class WebViewColumn: NSView, WKNavigationDelegate, WKUIDelegate { config.mediaTypesRequiringUserActionForPlayback = [] webView = WKWebView(frame: .zero, configuration: config) + pageFind = WebPageFind(webView: webView) progressBar = NSView() super.init(frame: .zero) @@ -217,6 +220,12 @@ final class WebViewColumn: NSView, WKNavigationDelegate, WKUIDelegate { } } + /// A new document has none of the matches the find bar counted. + @MainActor + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + pageFind.pageDidChange() + } + nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { DispatchQueue.main.async { [weak self] in self?.progressBar.isHidden = true diff --git a/Tests/NiruxTests/MenuShortcutTests.swift b/Tests/NiruxTests/MenuShortcutTests.swift index 6f217f1..39539c4 100644 --- a/Tests/NiruxTests/MenuShortcutTests.swift +++ b/Tests/NiruxTests/MenuShortcutTests.swift @@ -115,10 +115,10 @@ final class MenuShortcutTests: XCTestCase { } @MainActor - func testTerminalFindUsesTheStandardFindChords() { - let find = items(withAction: #selector(NiruxApp.showTerminalFind(_:))) - let next = items(withAction: #selector(NiruxApp.findNextInTerminal(_:))) - let previous = items(withAction: #selector(NiruxApp.findPreviousInTerminal(_:))) + func testFindUsesTheStandardFindChords() { + let find = items(withAction: #selector(NiruxApp.showFind(_:))) + let next = items(withAction: #selector(NiruxApp.findNextMatch(_:))) + let previous = items(withAction: #selector(NiruxApp.findPreviousMatch(_:))) XCTAssertEqual(find.compactMap(chord(of:)), [KeyChord("f")]) XCTAssertEqual(next.compactMap(chord(of:)), [KeyChord("g")]) XCTAssertEqual(previous.compactMap(chord(of:)), [KeyChord("g", [.command, .shift])]) diff --git a/Tests/NiruxTests/SidebarPanelFlowTests.swift b/Tests/NiruxTests/SidebarPanelFlowTests.swift index ec8c0d2..0e28e25 100644 --- a/Tests/NiruxTests/SidebarPanelFlowTests.swift +++ b/Tests/NiruxTests/SidebarPanelFlowTests.swift @@ -337,13 +337,13 @@ final class SidebarPanelFlowTests: UIFlowTestCase { let column = try XCTUnwrap(workspace.columns[safe: workspace.focusedIndex]) // ⌘F, ⌘G and ⇧⌘G from the Edit menu. - shell.showTerminalFind() + shell.showFind() XCTAssertTrue(column.isFindBarOpen) let bar = try XCTUnwrap(column.findBar) harness.type("flow", into: bar.field) XCTAssertEqual(column.terminalSearch?.needle, "flow") - shell.findNextInTerminal() - shell.findPreviousInTerminal() + shell.findNextMatch() + shell.findPreviousMatch() @MainActor func button(_ label: String) throws -> NSButton { try XCTUnwrap(bar.subviews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == label }, label) } diff --git a/Tests/NiruxTests/TerminalSearchTests.swift b/Tests/NiruxTests/TerminalSearchTests.swift index 5a150fb..cbfc9c1 100644 --- a/Tests/NiruxTests/TerminalSearchTests.swift +++ b/Tests/NiruxTests/TerminalSearchTests.swift @@ -246,7 +246,7 @@ final class TerminalSearchTests: XCTestCase { @MainActor func testReturnGoesToTheNextMatchAndShiftReturnToThePreviousOne() { - let bar = TerminalFindBar(frame: .zero) + let bar = FindBar(target: .terminal) var calls: [String] = [] bar.onNext = { calls.append("next") } bar.onPrevious = { calls.append("previous") } @@ -273,7 +273,7 @@ final class TerminalSearchTests: XCTestCase { @MainActor func testEditingTheFieldReportsItsText() { - let bar = TerminalFindBar(frame: .zero) + let bar = FindBar(target: .terminal) var needles: [String] = [] bar.onNeedleChange = { needles.append($0) } bar.field.stringValue = "panic" @@ -291,7 +291,8 @@ final class TerminalSearchTests: XCTestCase { styleMask: [.titled], backing: .buffered, defer: true ) window.isReleasedWhenClosed = false - let bar = TerminalFindBar(frame: NSRect(x: 0, y: 0, width: 320, height: TerminalFindBar.height)) + let bar = FindBar(target: .terminal) + bar.frame = NSRect(x: 0, y: 0, width: 320, height: FindBar.height) let other = NSTextView(frame: NSRect(x: 0, y: 100, width: 100, height: 50)) window.contentView?.addSubview(bar) window.contentView?.addSubview(other) @@ -319,7 +320,7 @@ final class TerminalSearchTests: XCTestCase { XCTAssertTrue(column.view.subviews.last === bar, "drawn above the terminal") XCTAssertEqual(bar?.frame.maxX, 590) XCTAssertEqual(bar?.frame.maxY, 400 - column.headerHeight - 10) - XCTAssertEqual(bar?.frame.width, TerminalFindBar.preferredWidth) + XCTAssertEqual(bar?.frame.width, FindBar.preferredWidth) // A narrow column shrinks the bar instead of overflowing it. column.view.frame = NSRect(x: 0, y: 0, width: 200, height: 400) @@ -443,13 +444,4 @@ final class TerminalSearchTests: XCTestCase { XCTAssertFalse(closes(0x35, [.shift])) XCTAssertFalse(closes(0x24, [])) } - - @MainActor - func testBrowserColumnsHaveNoFindBar() { - let column = ColumnState(url: "about:blank") - column.showFindBar() - XCTAssertNil(column.findBar) - XCTAssertFalse(column.isFindBarOpen) - XCTAssertFalse(column.isEditingFind) - } } diff --git a/Tests/NiruxTests/TextNavigationKeyFlowTests.swift b/Tests/NiruxTests/TextNavigationKeyFlowTests.swift index add374b..2072c26 100644 --- a/Tests/NiruxTests/TextNavigationKeyFlowTests.swift +++ b/Tests/NiruxTests/TextNavigationKeyFlowTests.swift @@ -24,7 +24,7 @@ final class TextNavigationKeyFlowTests: XCTestCase { func testCommandArrowsEditTheEditorText() throws { try UIFlowHarness.run { harness in - try withApp(on: harness) { + try harness.withApp { let workspace = try XCTUnwrap(harness.shell.activeWorkspace) let (editor, webView) = try openEditorWithKeyboard(in: harness) let editorIndex = workspace.focusedIndex @@ -50,7 +50,7 @@ final class TextNavigationKeyFlowTests: XCTestCase { /// The diff hides Monaco: Cmd+Arrow navigates again. func testCommandArrowsNavigateFromTheEditorDiff() throws { try UIFlowHarness.run { harness in - try withApp(on: harness) { + try harness.withApp { let workspace = try XCTUnwrap(harness.shell.activeWorkspace) let (editor, webView) = try openEditorWithKeyboard(in: harness) let editorIndex = workspace.focusedIndex @@ -66,7 +66,7 @@ final class TextNavigationKeyFlowTests: XCTestCase { func testCommandArrowsEditTheAddressBar() throws { try UIFlowHarness.run { harness in - try withApp(on: harness) { + try harness.withApp { let workspace = try XCTUnwrap(harness.shell.activeWorkspace) let page = URL(fileURLWithPath: harness.repo + "/README.md").absoluteString workspace.addColumn(webViewURL: page) @@ -91,7 +91,7 @@ final class TextNavigationKeyFlowTests: XCTestCase { /// take it: on a page that scrolls, Cmd+Down switches workspace. func testCommandArrowsNavigateOutsideText() throws { try UIFlowHarness.run { harness in - try withApp(on: harness) { + try harness.withApp { let shell = harness.shell let workspace = try XCTUnwrap(shell.activeWorkspace) let page = harness.repo + "/long.html" @@ -241,26 +241,6 @@ final class TextNavigationKeyFlowTests: XCTestCase { )) } - /// Runs `body` with the app's key interceptor and menu bar on the - /// harness window, as `applicationDidFinishLaunching` sets them up. - private func withApp(on harness: UIFlowHarness, _ body: () throws -> Void) throws { - let app = NiruxApp() - app.shell = harness.shell - app.mainWindow = harness.window - let previousMenu = NSApp.mainMenu - let previousDelegate = NSApp.delegate - NSApp.mainMenu = app.makeMainMenu() - // Menu items without a target reach the app delegate. - NSApp.delegate = app - let monitors = app.setupKeyInterceptor() - defer { - monitors.forEach(NSEvent.removeMonitor) - NSApp.delegate = previousDelegate - NSApp.mainMenu = previousMenu - } - try body() - } - /// Opens README.md in a new editor column, its Monaco with the keyboard. private func openEditorWithKeyboard(in harness: UIFlowHarness) throws -> (EditorColumn, WKWebView) { let workspace = try XCTUnwrap(harness.shell.activeWorkspace) @@ -274,26 +254,11 @@ final class TextNavigationKeyFlowTests: XCTestCase { return (editor, webView) } - /// A key press, through the app's event dispatch and so its key - /// interceptor. The test process never has a key window: a key the - /// interceptor returns to AppKit reaches the menu, not the window's - /// views. private func press(_ arrow: Arrow, _ modifiers: NSEvent.ModifierFlags, in harness: UIFlowHarness) { - let characters = String(Character(UnicodeScalar(arrow.character)!)) - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - // Arrow keys always carry these flags. - modifierFlags: modifiers.union([.function, .numericPad]), - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: harness.window.windowNumber, - context: nil, - characters: characters, - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: arrow.rawValue - ) else { return XCTFail("no key event") } - NSApp.sendEvent(event) + // Arrow keys always carry these flags. + harness.press( + String(Character(UnicodeScalar(arrow.character)!)), keyCode: arrow.rawValue, modifiers.union([.function, .numericPad]) + ) } /// Asks Monaco for its selection until it is `expected`: the page diff --git a/Tests/NiruxTests/UIFlowHarness.swift b/Tests/NiruxTests/UIFlowHarness.swift index 97f73c8..97ad590 100644 --- a/Tests/NiruxTests/UIFlowHarness.swift +++ b/Tests/NiruxTests/UIFlowHarness.swift @@ -256,6 +256,28 @@ final class UIFlowHarness { return shell.commandPalette?.actions ?? [] } + // MARK: - App + + /// Runs `body` with the app's key interceptor and menu bar on the + /// harness window, as `applicationDidFinishLaunching` sets them up. + func withApp(_ body: () throws -> Void) throws { + let app = NiruxApp() + app.shell = shell + app.mainWindow = window + let previousMenu = NSApp.mainMenu + let previousDelegate = NSApp.delegate + NSApp.mainMenu = app.makeMainMenu() + // Menu items without a target reach the app delegate. + NSApp.delegate = app + let monitors = app.setupKeyInterceptor() + defer { + monitors.forEach(NSEvent.removeMonitor) + NSApp.delegate = previousDelegate + NSApp.mainMenu = previousMenu + } + try body() + } + // MARK: - Keyboard and fields enum Key { @@ -296,6 +318,26 @@ final class UIFlowHarness { NSApp.sendEvent(event) } + /// A key press with modifiers, through the app's event dispatch and so + /// its key interceptor. The test process never has a key window: a key + /// the interceptor returns to AppKit reaches the menu, not the window's + /// views. + func press(_ characters: String, keyCode: UInt16, _ modifiers: NSEvent.ModifierFlags) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { return XCTFail("no key event") } + NSApp.sendEvent(event) + } + /// Replaces a field's text and tells its delegate, as typing does. func type(_ text: String, into field: NSTextField) { field.stringValue = text diff --git a/Tests/NiruxTests/WebContentKeyRoutingTests.swift b/Tests/NiruxTests/WebContentKeyRoutingTests.swift index dea55ac..ab2c60f 100644 --- a/Tests/NiruxTests/WebContentKeyRoutingTests.swift +++ b/Tests/NiruxTests/WebContentKeyRoutingTests.swift @@ -82,8 +82,8 @@ final class WebContentKeyRoutingTests: XCTestCase { XCTAssertFalse(passes(editor: false, "\r", [.command, .option], keyCode: 0x24)) } - /// Edit > Find in Terminal and Find Next/Previous act on terminal - /// columns only; Monaco's find widget and web pages keep the chords. + /// Monaco's find widget and web pages get the find chords first; Edit > + /// Find and Find Next/Previous only get what a page leaves unhandled. func testFindChordsReachWebContentInEditorAndBrowser() { for editor in [true, false] { XCTAssertTrue(passes(editor: editor, "f", .command)) diff --git a/Tests/NiruxTests/WebPageFindTests.swift b/Tests/NiruxTests/WebPageFindTests.swift new file mode 100644 index 0000000..f6556e0 --- /dev/null +++ b/Tests/NiruxTests/WebPageFindTests.swift @@ -0,0 +1,287 @@ +import AppKit +import WebKit +import XCTest +@testable import Nirux + +/// Find in a browser column's page: ⌘F, through the app's key interceptor +/// and menu bar, opens the column's find bar, which finds a word with +/// WebKit's find, counts it and steps through its matches. +@MainActor +final class WebPageFindTests: XCTestCase { + private static let page = """ +
alpha needle
+beta
+gamma NEEDLE needle
+ + """ + + func testCommandFFindsAWordInTheBrowserPage() throws { + try UIFlowHarness.run { harness in + try harness.withApp { + let workspace = try XCTUnwrap(harness.shell.activeWorkspace) + let page = harness.repo + "/find.html" + let otherPage = harness.repo + "/other.html" + try Self.page.write(toFile: page, atomically: true, encoding: .utf8) + try "Nothing to see
".write(toFile: otherPage, atomically: true, encoding: .utf8) + workspace.addColumn(webViewURL: URL(fileURLWithPath: page).absoluteString) + harness.shell.relayout(animated: false) + let column = try XCTUnwrap(workspace.columns[safe: workspace.focusedIndex]) + let browser = try XCTUnwrap(column.webViewColumn) + let webView = browser.webView + harness.waitUntil("the page to load") { webView.url != nil && !webView.isLoading } + harness.window.makeFirstResponder(webView) + + // A page with its own search keeps ⌘F. + run("ownSearch = true", in: webView, harness: harness) + pressCommandFInPage(webView) + harness.waitUntil("the page's own search", timeout: 10) { webView.title == "own search" } + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + XCTAssertNil(column.findBar, "the find bar took ⌘F from the page") + + // Unhandled, ⌘F comes back from WebKit to Edit > Find. + run("ownSearch = false", in: webView, harness: harness) + pressCommandFInPage(webView) + harness.waitUntil("the find bar", timeout: 10) { column.isFindBarOpen } + let bar = try XCTUnwrap(column.findBar) + XCTAssertTrue(bar.isEditing, "the find field doesn't have the keyboard") + XCTAssertGreaterThan(bar.frame.width, 0) + + harness.type("needle", into: bar.field) + harness.waitUntil("WebKit to count the matches", timeout: 10) { bar.status == "3 matches" } + waitForSelection("first@6:needle", in: webView) + // Still the same match, which WebKit would pass by. + harness.type("Needle", into: bar.field) + waitForSelection("first@6:needle", in: webView) + harness.shell.focusActiveTerminal(in: harness.window) + XCTAssertTrue(bar.isEditing, "the page took the keyboard from the open find bar") + harness.press("g", keyCode: 0x05, [.command]) + waitForSelection("third@6:NEEDLE", in: webView) + typeInField("\r", keyCode: 0x24, in: harness) + waitForSelection("third@13:needle", in: webView) + // ⇧⌘G reaches Find Previous only from the keyboard: a + // synthetic one matches ⌘G's item or none (MenuShortcutTests + // checks the chord). + try XCTUnwrap( + bar.subviews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Previous Match" } + ).performClick(nil) + waitForSelection("third@6:NEEDLE", in: webView) + + typeInField("\u{1b}", keyCode: 0x35, in: harness) + XCTAssertFalse(column.isFindBarOpen) + XCTAssertNil(bar.status) + XCTAssertIdentical(harness.window.firstResponder, webView, "the page didn't get the keyboard back") + + // Reopened, the bar counts the matches again, from the + // selected one, or from the top where WebKit drops a page's + // selection as the field takes the keyboard; never past it. + pressCommandFInPage(webView) + harness.waitUntil("the find bar to reopen", timeout: 10) { column.isFindBarOpen && bar.status == "3 matches" } + waitForSelection("third@6:NEEDLE", "first@6:needle", in: webView) + + // A new page has none of the matches counted. + browser.navigate(to: URL(fileURLWithPath: otherPage).absoluteString) + harness.waitUntil("the other page", timeout: 10) { webView.url?.lastPathComponent == "other.html" && bar.status == nil } + harness.type("absent", into: bar.field) + harness.waitUntil("WebKit to find nothing", timeout: 10) { bar.status == WebPageFind.notFound } + } + } + } + + /// Edit > Find and Find Next/Previous act on a terminal or browser + /// column; an editor column leaves ⌘F to Monaco. + func testTheFindItemsFollowTheFocusedColumn() throws { + try UIFlowHarness.run { harness in + try harness.withApp { + let app = try XCTUnwrap(NSApp.delegate as? NiruxApp) + let workspace = try XCTUnwrap(harness.shell.activeWorkspace) + let items = [#selector(NiruxApp.showFind(_:)), #selector(NiruxApp.findNextMatch(_:)), #selector(NiruxApp.findPreviousMatch(_:))] + .compactMap { action in NSApp.mainMenu?.items.compactMap(\.submenu).flatMap(\.items).first { $0.action == action } } + XCTAssertEqual(items.count, 3) + let enabled = { @MainActor in items.map { app.validateMenuItem($0) } } + + XCTAssertNotNil(workspace.columns[safe: workspace.focusedIndex]?.terminalView) + XCTAssertEqual(enabled(), [true, true, true]) + workspace.addColumn(webViewURL: "about:blank") + XCTAssertEqual(enabled(), [true, true, true]) + harness.shell.openEditorColumn() + XCTAssertNotNil(workspace.columns[safe: workspace.focusedIndex]?.editorColumn) + XCTAssertEqual(enabled(), [false, false, false]) + } + } + } + + /// Without WebKit's private find calls, the public one still finds, + /// steps through the matches and tells "Not found", with no count. + func testThePublicFindFindsWithoutCounting() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + window.contentView = webView + window.orderFront(nil) + defer { window.close() } + webView.loadHTMLString(Self.page, baseURL: nil) + wait("the page to load") { !webView.isLoading && webView.url != nil } + let find = WebPageFind(webView: webView, usesPrivateFind: false) + + find.update("needle") + waitForSelection("first@6:needle", in: webView) + find.next() + waitForSelection("third@6:NEEDLE", in: webView) + find.previous() + waitForSelection("first@6:needle", in: webView) + XCTAssertNil(find.status) + find.update("absent") + wait("no match") { find.status == WebPageFind.notFound } + } + + /// A browser column's bar sits under the header the column draws + /// itself, and goes down the page with its down arrow. + func testABrowserColumnsBarOpensUnderItsHeader() throws { + let column = ColumnState(url: "about:blank") + column.view.frame = NSRect(x: 0, y: 0, width: 600, height: 400) + column.layoutWithHeader(width: 600, height: 400) + column.showFindBar() + let bar = try XCTUnwrap(column.findBar) + XCTAssertTrue(column.view.subviews.last === bar, "drawn above the page") + XCTAssertEqual(bar.frame.maxY, 400 - ColumnHeaderView.height - 10) + XCTAssertEqual(bar.frame.maxX, 590) + XCTAssertEqual(bar.field.accessibilityLabel(), "Find in Page") + + bar.layoutSubtreeIfNeeded() + func button(_ label: String) throws -> NSButton { + try XCTUnwrap(bar.subviews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == label }, label) + } + XCTAssertLessThan(try button("Previous Match").frame.minX, try button("Next Match").frame.minX, "the up arrow goes back") + + // The counter leaves the field room, or hides on a narrow column. + bar.status = "1000+ matches" + bar.layoutSubtreeIfNeeded() + XCTAssertGreaterThanOrEqual(bar.field.frame.width, FindBar.minimumFieldWidth) + bar.frame.size.width = 180 + bar.layoutSubtreeIfNeeded() + XCTAssertGreaterThanOrEqual(bar.field.frame.width, FindBar.minimumFieldWidth) + } + + func testTheCounterReadsLikeSafaris() { + XCTAssertEqual(WebPageFind.status(matches: 1), "1 match") + XCTAssertEqual(WebPageFind.status(matches: 12), "12 matches") + XCTAssertEqual(WebPageFind.status(matches: 1000), "1000 matches") + // WebKit's report past the most it counts. + XCTAssertEqual(WebPageFind.status(matches: UInt(UInt32.max)), "1000+ matches") + } + + // MARK: - Helpers + + /// ⌘F as AppKit sends it on once the key interceptor returned it, as + /// it does every find chord of a browser column: to the key window's + /// views, where the web view takes it for the page. The test process + /// has no key window, so this step is taken here. WebKit resends a key + /// the page left unhandled through NSApp.sendEvent: to the interceptor, + /// then the menu. + private func pressCommandFInPage(_ webView: WKWebView, file: StaticString = #filePath, line: UInt = #line) { + let passes = WebContentKeyRouting.passesToWebContent( + isEditor: false, characters: "f", charactersIgnoringModifiers: "f", keyCode: 0x03, modifierFlags: .command + ) + XCTAssertTrue(passes, "the key interceptor keeps ⌘F from the page", file: file, line: line) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .command, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: webView.window?.windowNumber ?? 0, + context: nil, + characters: "f", + charactersIgnoringModifiers: "f", + isARepeat: false, + keyCode: 0x03 + ) else { return XCTFail("no key event", file: file, line: line) } + XCTAssertTrue(webView.performKeyEquivalent(with: event), "the web view didn't take ⌘F", file: file, line: line) + } + + /// Runs `script` in the page and waits for it. + private func run(_ script: String, in webView: WKWebView, harness: UIFlowHarness) { + let answer = SelectionAnswer() + answer.isAsking = true + webView.evaluateJavaScript(script) { _, _ in + MainActor.assumeIsolated { answer.isAsking = false } + } + harness.waitUntil("the page to run \(script)") { !answer.isAsking } + } + + /// A plain key typed into the focused find field. The key interceptor + /// leaves those to the field (TerminalFindKeyRouting.routeInField), + /// and with no key window AppKit would drop it: the window delivers it + /// to its first responder instead. + private func typeInField(_ characters: String, keyCode: UInt16, in harness: UIFlowHarness) { + XCTAssertEqual(TerminalFindKeyRouting.routeInField(keyCode: keyCode, modifierFlags: []), .field) + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: harness.window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { return XCTFail("no key event") } + harness.window.sendEvent(event) + } + + /// Asks the page for its selection, as "id@offset:text", until it is + /// one of `expected`: WebKit finds after the call returns. + private func waitForSelection( + _ expected: String..., in webView: WKWebView, file: StaticString = #filePath, line: UInt = #line + ) { + let script = """ + (() => { + const selection = getSelection(); + const node = selection.anchorNode; + return node ? `${node.parentElement.id}@${selection.anchorOffset}:${selection}` : ""; + })() + """ + let answer = SelectionAnswer() + wait("the page to select \(expected)", file: file, line: line) { + if !answer.isAsking { + answer.isAsking = true + webView.evaluateJavaScript(script) { result, _ in + MainActor.assumeIsolated { + answer.latest = result as? String + answer.isAsking = false + } + } + } + return answer.latest.map(expected.contains) == true + } + if answer.latest.map(expected.contains) != true { + XCTFail("the page selected \(answer.latest ?? "nothing")", file: file, line: line) + } + } + + @MainActor + private final class SelectionAnswer { + var latest: String? + var isAsking = false + } + + /// Spins the main run loop until `condition` holds, 10 s at most. + private func wait( + _ description: String, file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool + ) { + let deadline = Date().addingTimeInterval(10) + while !condition(), Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + XCTAssertTrue(condition(), "timed out waiting for \(description)", file: file, line: line) + } +}