diff --git a/include/ghostty.h b/include/ghostty.h index 9b7a918ec..6cafe8773 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -747,6 +747,21 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -811,6 +826,10 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, } ghostty_action_tag_e; typedef union { @@ -844,6 +863,9 @@ typedef union { ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; } ghostty_action_u; typedef struct { diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b05351bfd..da20c2124 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -44,6 +44,11 @@ class AppDelegate: NSObject, @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuFindParent: NSMenuItem? + @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuFindNext: NSMenuItem? + @IBOutlet private var menuFindPrevious: NSMenuItem? + @IBOutlet private var menuHideFindBar: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -553,6 +558,7 @@ class AppDelegate: NSObject, self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @@ -581,6 +587,9 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61..3e1084cd7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -26,7 +26,12 @@ + + + + + @@ -245,6 +250,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 537137fe6..79c3ca756 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -44,6 +44,7 @@ struct CommandPaletteView: View { @State private var query = "" @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? + @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from // the query. @@ -72,7 +73,7 @@ struct CommandPaletteView: View { } VStack(alignment: .leading, spacing: 0) { - CommandPaletteQuery(query: $query) { event in + CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in switch (event) { case .exit: isPresented = false @@ -144,6 +145,28 @@ struct CommandPaletteView: View { .shadow(radius: 32, x: 0, y: 12) .padding() .environment(\.colorScheme, scheme) + .onChange(of: isPresented) { newValue in + // Reset focus when quickly showing and hiding. + // macOS will destroy this view after a while, + // so task/onAppear will not be called again. + // If you toggle it rather quickly, we reset + // it here when dismissing. + isTextFieldFocused = newValue + if !isPresented { + // This is optional, since most of the time + // there will be a delay before the next use. + // To keep behavior the same as before, we reset it. + query = "" + } + } + .task { + // Grab focus on the first appearance. + // This happens right after onAppear, + // so we don’t need to dispatch it again. + // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 + // Also fixes initial focus while animating. + isTextFieldFocused = isPresented + } } } @@ -153,6 +176,12 @@ fileprivate struct CommandPaletteQuery: View { var onEvent: ((KeyboardEvent) -> Void)? = nil @FocusState private var isTextFieldFocused: Bool + init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { + _query = query + self.onEvent = onEvent + _isTextFieldFocused = isTextFieldFocused + } + enum KeyboardEvent { case exit case submit @@ -185,14 +214,6 @@ fileprivate struct CommandPaletteQuery: View { .frame(height: 48) .textFieldStyle(.plain) .focused($isTextFieldFocused) - .onAppear { - // We want to grab focus on appearance. We have to do this after a tick - // on macOS Tahoe otherwise this doesn't work. See: - // https://github.com/ghostty-org/ghostty/issues/8497 - DispatchQueue.main.async { - isTextFieldFocused = true - } - } .onChange(of: isTextFieldFocused) { focused in if !focused { onEvent?(.exit) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 673f5dd78..96ff3d0c1 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -90,19 +90,19 @@ struct TerminalCommandPaletteView: View { backgroundColor: ghosttyConfig.backgroundColor, options: commandOptions ) - .transition( - .move(edge: .top) - .combined(with: .opacity) - .animation(.spring(response: 0.4, dampingFraction: 0.8)) - ) // Spring animation .zIndex(1) // Ensure it's on top Spacer() } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) } + .transition( + .move(edge: .top) + .combined(with: .opacity) + ) } } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented) .onChange(of: isPresented) { newValue in // When the command palette disappears we need to send focus back to the // surface view we were overlaid on top of. There's probably a better way diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..9104e61ff 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1112,6 +1112,22 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } + + @IBAction func find(_ sender: Any) { + focusedSurface?.find(sender) + } + + @IBAction func findNext(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findPrevious(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findHide(_ sender: Any) { + focusedSurface?.findHide(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -1136,3 +1152,15 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4de0336ce..e1a98e598 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1403,8 +1403,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } @@ -1433,7 +1433,7 @@ extension TerminalController: NSMenuItemValidation { return true default: - return true + return super.validateMenuItem(item) } } } diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9d389a8c2..8fce2199d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -115,6 +115,18 @@ extension Ghostty.Action { len = c.len } } + + struct StartSearch { + let needle: String? + + init(c: ghostty_action_start_search_s) { + if let needleCString = c.needle { + self.needle = String(cString: needleCString) + } else { + self.needle = nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c19199e8..39ebbb51f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -180,14 +180,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -210,14 +210,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -238,21 +238,21 @@ extension Ghostty { case .reset: action = "reset_font_size" } - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -606,6 +606,18 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: closeAllWindows(app, target: target) + case GHOSTTY_ACTION_START_SEARCH: + startSearch(app, target: target, v: action.action.start_search) + + case GHOSTTY_ACTION_END_SEARCH: + endSearch(app, target: target) + + case GHOSTTY_ACTION_SEARCH_TOTAL: + searchTotal(app, target: target, v: action.action.search_total) + + case GHOSTTY_ACTION_SEARCH_SELECTED: + searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1641,6 +1653,100 @@ extension Ghostty { } } + private static func startSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_start_search_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("start_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let startSearch = Ghostty.Action.StartSearch(c: v) + DispatchQueue.main.async { + if surfaceView.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + } else { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } + } + + default: + assertionFailure() + } + } + + private static func endSearch( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("end_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + DispatchQueue.main.async { + surfaceView.searchState = nil + } + + default: + assertionFailure() + } + } + + private static func searchTotal( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_total_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_total does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let total: UInt? = v.total >= 0 ? UInt(v.total) : nil + DispatchQueue.main.async { + surfaceView.searchState?.total = total + } + + default: + assertionFailure() + } + } + + private static func searchSelected( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_selected_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_selected does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + DispatchQueue.main.async { + surfaceView.searchState?.selected = selected + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index f380345c7..2df0a8656 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -105,7 +105,7 @@ extension Ghostty { func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let cfg = self.config else { return nil } - let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) return Ghostty.keyboardShortcut(for: trigger) } #endif @@ -120,7 +120,7 @@ extension Ghostty { guard let config = self.config else { return .init() } var v: CUnsignedInt = 0 let key = "bell-features" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .init() } return .init(rawValue: v) } @@ -128,7 +128,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true; let key = "initial-window" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -136,7 +136,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "quit-after-last-window-closed" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -144,7 +144,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "title" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -153,7 +153,7 @@ extension Ghostty { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-save-state" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -162,21 +162,21 @@ extension Ghostty { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-x" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-y" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowNewTabPosition: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-new-tab-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -186,7 +186,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "window-decoration" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue @@ -196,7 +196,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-theme" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -205,7 +205,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "window-step-resize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -213,7 +213,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "fullscreen" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -223,7 +223,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-non-native-fullscreen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return switch str { @@ -245,7 +245,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-title-font-family" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -255,7 +255,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-window-buttons" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSWindowButtons(rawValue: str) ?? defaultValue @@ -266,7 +266,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } return String(cString: ptr) } @@ -276,7 +276,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-proxy-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSTitlebarProxyIcon(rawValue: str) ?? defaultValue @@ -287,7 +287,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-dock-drop-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacDockDropBehavior(rawValue: str) ?? defaultValue @@ -297,7 +297,7 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "macos-window-shadow" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -306,7 +306,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIcon(rawValue: str) ?? defaultValue @@ -318,7 +318,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-custom-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } guard let path = NSString(utf8String: ptr) else { return defaultValue } return path.expandingTildeInPath @@ -332,7 +332,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon-frame" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIconFrame(rawValue: str) ?? defaultValue @@ -342,7 +342,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_s = .init() let key = "macos-icon-ghost-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } return .init(ghostty: v) } @@ -350,7 +350,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_list_s = .init() let key = "macos-icon-screen-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard v.len > 0 else { return nil } let buffer = UnsafeBufferPointer(start: v.colors, count: v.len) return buffer.map { .init(ghostty: $0) } @@ -360,7 +360,7 @@ extension Ghostty { guard let config = self.config else { return .never } var v: UnsafePointer? = nil let key = "macos-hidden" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .never } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } guard let ptr = v else { return .never } let str = String(cString: ptr) return MacHidden(rawValue: str) ?? .never @@ -370,14 +370,14 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "focus-follows-mouse" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } var backgroundColor: Color { var color: ghostty_config_color_s = .init(); let bg_key = "background" - if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.count))) { + if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8)))) { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -398,7 +398,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Double = 1 let key = "background-opacity" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -406,7 +406,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Int = 0 let key = "background-blur" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -414,7 +414,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var opacity: Double = 0.85 let key = "unfocused-split-opacity" - _ = ghostty_config_get(config, &opacity, key, UInt(key.count)) + _ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8))) return 1 - opacity } @@ -423,9 +423,9 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "unfocused-split-fill" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { let bg_key = "background" - _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.count)); + _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))); } return .init( @@ -444,7 +444,7 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "split-divider-color" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { return Color(newColor) } @@ -460,7 +460,7 @@ extension Ghostty { guard let config = self.config else { return .top } var v: UnsafePointer? = nil let key = "quick-terminal-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .top } guard let ptr = v else { return .top } let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top @@ -470,7 +470,7 @@ extension Ghostty { guard let config = self.config else { return .main } var v: UnsafePointer? = nil let key = "quick-terminal-screen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .main } guard let ptr = v else { return .main } let str = String(cString: ptr) return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main @@ -480,7 +480,7 @@ extension Ghostty { guard let config = self.config else { return 0.2 } var v: Double = 0.2 let key = "quick-terminal-animation-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -488,7 +488,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true let key = "quick-terminal-autohide" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -496,7 +496,7 @@ extension Ghostty { guard let config = self.config else { return .move } var v: UnsafePointer? = nil let key = "quick-terminal-space-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .move } guard let ptr = v else { return .move } let str = String(cString: ptr) return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move @@ -506,7 +506,7 @@ extension Ghostty { guard let config = self.config else { return QuickTerminalSize() } var v = ghostty_config_quick_terminal_size_s() let key = "quick-terminal-size" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return QuickTerminalSize() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() } return QuickTerminalSize(from: v) } #endif @@ -515,7 +515,7 @@ extension Ghostty { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil let key = "resize-overlay" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .after_first } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .after_first } guard let ptr = v else { return .after_first } let str = String(cString: ptr) return ResizeOverlay(rawValue: str) ?? .after_first @@ -526,7 +526,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "resize-overlay-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return ResizeOverlayPosition(rawValue: str) ?? defaultValue @@ -536,7 +536,7 @@ extension Ghostty { guard let config = self.config else { return 1000 } var v: UInt = 0 let key = "resize-overlay-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -544,7 +544,7 @@ extension Ghostty { guard let config = self.config else { return .seconds(5) } var v: UInt = 0 let key = "undo-timeout" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return .milliseconds(v) } @@ -552,7 +552,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } let str = String(cString: ptr) return AutoUpdate(rawValue: str) @@ -563,7 +563,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "auto-update-channel" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return AutoUpdateChannel(rawValue: str) ?? defaultValue @@ -573,7 +573,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-auto-secure-input" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -581,7 +581,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-secure-input-indication" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -589,7 +589,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "maximize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -598,7 +598,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-shortcuts" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue @@ -609,7 +609,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "scrollbar" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f36b486ba..7ee815caa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -396,6 +396,9 @@ extension Notification.Name { /// Notification sent when scrollbar updates static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" + + /// Focus the search field + static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0358f765b..6f21c997b 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,7 +197,16 @@ extension Ghostty { SecureInputOverlay() } #endif - + + // Search overlay + if let searchState = surfaceView.searchState { + SurfaceSearchOverlay( + surfaceView: surfaceView, + searchState: searchState, + onClose: { surfaceView.searchState = nil } + ) + } + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -382,6 +391,173 @@ extension Ghostty { } } + /// Search overlay view that displays a search bar with input field and navigation buttons. + struct SurfaceSearchOverlay: View { + let surfaceView: SurfaceView + @ObservedObject var searchState: SurfaceView.SearchState + let onClose: () -> Void + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 8) { + TextField("Search", text: $searchState.needle) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text("-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } +#if canImport(AppKit) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } +#endif + .backport.onKeyPress(.return) { modifiers in + guard let surface = surfaceView.surface else { return .ignored } + let action = modifiers.contains(.shift) + ? "navigate_search:previous" + : "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + return .handled + } + + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + }) { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:previous" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + }) { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + + Button(action: onClose) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + .padding(8) + .background(.background) + .clipShape(clipShape) + .shadow(radius: 4) + .onAppear { + isSearchFieldFocused = true + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + isSearchFieldFocused = true + } + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } + } + ) + } + } + + private var clipShape: some Shape { + if #available(iOS 26.0, macOS 26.0, *) { + return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) + } else { + return RoundedRectangle(cornerRadius: 8) + } + } + + enum Corner { + case topLeft, topRight, bottomLeft, bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } else { + return point.y < midY ? .topRight : .bottomRight + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. @@ -658,3 +834,17 @@ extension FocusedValues { typealias Value = OSSize } } + +// MARK: Search State + +extension Ghostty.SurfaceView { + class SearchState: ObservableObject { + @Published var needle: String = "" + @Published var selected: UInt? = nil + @Published var total: UInt? = nil + + init(from startSearch: Ghostty.Action.StartSearch) { + self.needle = startSearch.needle ?? "" + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e3597fd3..03ef293af 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CoreText import UserNotifications @@ -64,6 +65,43 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil { + didSet { + if let searchState { + // I'm not a Combine expert so if there is a better way to do this I'm + // all ears. What we're doing here is grabbing the latest needle. If the + // needle is less than 3 chars, we debounce it for a few hundred ms to + // avoid kicking off expensive searches. + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .map { needle -> AnyPublisher in + if needle.isEmpty || needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } else { + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + } + .switchToLatest() + .sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + } + } else if oldValue != nil { + searchNeedleCancellable = nil + guard let surface = self.surface else { return } + let action = "end_search" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + } + } + } + + // Cancellable for search state needle changes + private var searchNeedleCancellable: AnyCancellable? + // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil @@ -1410,7 +1448,7 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1418,7 +1456,7 @@ extension Ghostty { @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1427,7 +1465,7 @@ extension Ghostty { @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1435,7 +1473,7 @@ extension Ghostty { @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1443,7 +1481,39 @@ extension Ghostty { @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func find(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "start_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findNext(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:next" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findPrevious(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:previous" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findHide(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "end_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1471,7 +1541,7 @@ extension Ghostty { @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1479,7 +1549,7 @@ extension Ghostty { @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1920,6 +1990,9 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty + + case #selector(findHide): + return searchState != nil default: return true diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..09c41c0b5 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false + + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index a28be15ae..8c43652e4 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -18,6 +18,12 @@ extension Backport where Content: Scene { // None currently } +/// Result type for backported onKeyPress handler +enum BackportKeyPressResult { + case handled + case ignored +} + extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { #if canImport(AppKit) @@ -42,6 +48,24 @@ extension Backport where Content: View { return content #endif } + + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. + func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { + #if canImport(AppKit) + if #available(macOS 14, *) { + return content.onKeyPress(key, phases: .down, action: { keyPress in + switch action(keyPress.modifiers) { + case .handled: return .handled + case .ignored: return .ignored + } + }) + } else { + return content + } + #else + return content + #endif + } } enum BackportVisibility { diff --git a/src/Surface.zig b/src/Surface.zig index 989495309..d0866e901 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -804,6 +804,14 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -1069,6 +1077,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to notify command finish={}", .{err}); }; }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, } } @@ -1363,8 +1387,57 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + + // Reset the selected index + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + // When we quit, tell our renderer to reset any search state. .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); _ = self.renderer_thread.mailbox.push( .{ .search_viewport_matches = .{ .arena = .init(self.alloc), @@ -1373,12 +1446,20 @@ fn searchCallback_( .forever, ); try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); }, // Unhandled, so far. - .total_matches, - .complete, - => {}, + .complete => {}, } } @@ -4851,11 +4932,42 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .start_search => { + // To save resources, we don't actually start a search here, + // we just notify the apprt. The real thread will start when + // the first needles are set. + return try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + }, + + .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + + return performed; + }, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) break :search; + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4889,9 +5001,25 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( - .{ .change_needle = text }, + .{ .change_needle = try .init( + self.alloc, + text, + ) }, .forever, ); + s.state.wakeup.notify() catch {}; + }, + + .navigate_search => |nav| { + const s: *Search = if (self.search) |*s| s else return false; + _ = s.state.mailbox.push( + .{ .select = switch (nav) { + .next => .next, + .previous => .prev, + } }, + .forever, + ); + s.state.wakeup.notify() catch {}; }, .copy_to_clipboard => |format| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 11186f059..00bf8685a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -301,6 +301,18 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, + /// Start the search overlay with an optional initial needle. + start_search: StartSearch, + + /// End the search overlay, clearing the search state and hiding it. + end_search, + + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -358,6 +370,10 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, + end_search, + search_total, + search_selected, }; /// Sync with: ghostty_action_u @@ -770,3 +786,48 @@ pub const CommandFinished = struct { }; } }; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..9c22782c7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -743,6 +743,10 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .start_search, + .end_search, + .search_total, + .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 3f9c0d741..6dae08a79 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3373,12 +3373,16 @@ const Clipboard = struct { // text/plain type. The default charset when there is // none is ASCII, and lots of things look for UTF-8 // specifically. + // The specs are not clear about the order here, but + // some clients apparently pick the first match in the + // order we set here then garble up bare 'text/plain' + // with non-ASCII UTF-8 content, so offer UTF-8 first. // // Note that under X11, GTK automatically adds the // UTF8_STRING atom when this is present. const text_provider_atoms = [_][:0]const u8{ - "text/plain", "text/plain;charset=utf-8", + "text/plain", }; var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; for (text_provider_atoms, 0..) |atom, j| { diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b71bf1e6e..45a847493 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); -const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; /// The message types that can be sent to a single surface. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the max size /// we want this union to be. - pub const WriteReq = termio.MessageData(u8, 255); + pub const WriteReq = MessageData(u8, 255); /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in @@ -107,6 +107,12 @@ pub const Message = union(enum) { /// The scrollbar state changed for the surface. scrollbar: terminal.Scrollbar, + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index 552df8d1f..effabb036 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig index e09ce85dd..8278c5c2f 100644 --- a/src/benchmark/GraphemeBreak.zig +++ b/src/benchmark/GraphemeBreak.zig @@ -91,7 +91,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -114,7 +114,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index c4667b333..5ba2da907 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -90,7 +90,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -117,7 +117,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 7225aff4e..380379bc3 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -109,7 +109,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { var stream = self.terminal.vtStream(); defer stream.deinit(); - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = data_f.reader(&read_buf); const r = &f_reader.interface; diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig index f13b44552..e00081763 100644 --- a/src/benchmark/TerminalParser.zig +++ b/src/benchmark/TerminalParser.zig @@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // the benchmark results and... I know writing this that we // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 0a993c42b..7cf28217f 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; diff --git a/src/config/Config.zig b/src/config/Config.zig index 89254b93f..18412ff0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -988,10 +988,25 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is +/// The default value is black text on a golden yellow background. @"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, @"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a soft peach background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: @@ -6388,6 +6403,38 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 14ee0e504..64a29269e 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const MessageData = @import("message_data.zig").MessageData; pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; diff --git a/src/datastruct/message_data.zig b/src/datastruct/message_data.zig new file mode 100644 index 000000000..3e5cdae66 --- /dev/null +++ b/src/datastruct/message_data.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +/// Creates a union that can be used to accommodate data that fit within an array, +/// are a stable pointer, or require deallocation. This is helpful for thread +/// messaging utilities. +pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { + return union(enum) { + pub const Self = @This(); + + pub const Small = struct { + pub const Max = small_size; + pub const Array = [Max]Elem; + pub const Len = std.math.IntFittingRange(0, small_size); + data: Array = undefined, + len: Len = 0, + }; + + pub const Alloc = struct { + alloc: Allocator, + data: []Elem, + }; + + pub const Stable = []const Elem; + + /// A small write where the data fits into this union size. + small: Small, + + /// A stable pointer so we can just pass the slice directly through. + /// This is useful i.e. for const data. + stable: Stable, + + /// Allocated and must be freed with the provided allocator. This + /// should be rarely used. + alloc: Alloc, + + /// Initializes the union for a given data type. This will + /// attempt to fit into a small value if possible, otherwise + /// will allocate and put into alloc. + /// + /// This can't and will never detect stable pointers. + pub fn init(alloc: Allocator, data: anytype) !Self { + switch (@typeInfo(@TypeOf(data))) { + .pointer => |info| { + assert(info.size == .slice); + assert(info.child == Elem); + + // If it fits in our small request, do that. + if (data.len <= Small.Max) { + var buf: Small.Array = undefined; + @memcpy(buf[0..data.len], data); + return Self{ + .small = .{ + .data = buf, + .len = @intCast(data.len), + }, + }; + } + + // Otherwise, allocate + const buf = try alloc.dupe(Elem, data); + errdefer alloc.free(buf); + return Self{ + .alloc = .{ + .alloc = alloc, + .data = buf, + }, + }; + }, + + else => unreachable, + } + } + + pub fn deinit(self: Self) void { + switch (self) { + .small, .stable => {}, + .alloc => |v| v.alloc.free(v.data), + } + } + + /// Returns a const slice of the data pointed to by this request. + pub fn slice(self: *const Self) []const Elem { + return switch (self.*) { + .small => |*v| v.data[0..v.len], + .stable => |v| v, + .alloc => |v| v.data, + }; + } + }; +} + +test "MessageData init small" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello!"; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .small); +} + +test "MessageData init alloc" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello! " ** 100; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .alloc); + io.alloc.alloc.free(io.alloc.data); +} + +test "MessageData small fits non-u8 sized data" { + const testing = std.testing; + const alloc = testing.allocator; + + const len = 500; + const Data = MessageData(u8, len); + const input: []const u8 = "X" ** len; + const io = try Data.init(alloc, input); + try testing.expect(io == .small); +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1b681e725..1e7db3592 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -333,9 +333,24 @@ pub const Action = union(enum) { set_font_size: f32, /// Start a search for the given text. If the text is empty, then - /// the search is canceled. If a previous search is active, it is replaced. + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. search: []const u8, + /// Navigate the search results. If there is no active search, this + /// is not performed. + navigate_search: NavigateSearch, + + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + + /// End the current search if any and hide any GUI elements. + end_search, + /// Clear the screen and all scrollback. clear_screen, @@ -826,6 +841,11 @@ pub const Action = union(enum) { } }; + pub const NavigateSearch = enum { + previous, + next, + }; + pub const AdjustSelection = enum { left, right, @@ -1157,6 +1177,9 @@ pub const Action = union(enum) { .text, .cursor_key, .search, + .navigate_search, + .start_search, + .end_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 11f65cea3..7cbff405a 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,34 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + + .navigate_search => comptime &.{ .{ + .action = .{ .navigate_search = .next }, + .title = "Next Search Result", + .description = "Navigate to the next search result, if any.", + }, .{ + .action = .{ .navigate_search = .previous }, + .title = "Previous Search Result", + .description = "Navigate to the previous search result, if any.", + } }, + + .search => comptime &.{.{ + .action = .{ .search = "" }, + .title = "End Search", + .description = "End a search if one is active.", + }}, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -604,7 +632,6 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, - .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 738dce61c..7316ac51d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -459,6 +459,14 @@ fn drainMailbox(self: *Thread) !void { self.renderer.search_matches_dirty = true; }, + .search_selected_match => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_selected_match) |*m| m.arena.deinit(); + self.renderer.search_selected_match = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 7701a5418..8c55da602 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Note that the selections MAY BE INVALID (point to PageList nodes /// that do not exist anymore). These must be validated prior to use. search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, search_matches_dirty: bool, /// The current set of cells to render. This is rebuilt on every frame @@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { selection_foreground: ?configpkg.Config.TerminalColor, search_background: configpkg.Config.TerminalColor, search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_foreground = config.@"selection-foreground", .search_background = config.@"search-background", .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .scrollbar = .zero, .scrollbar_dirty = false, .search_matches = null, + .search_selected_match = null, .search_matches_dirty = false, // Render state @@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); @@ -1205,13 +1217,41 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (self.search_matches_dirty or self.terminal_state.dirty != .false) { self.search_matches_dirty = false; - for (self.terminal_state.row_data.items(.highlights)) |*highlights| { - highlights.clearRetainingCapacity(); + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; + } + + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + &.{m.match}, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; } if (self.search_matches) |m| { self.terminal_state.updateHighlightsFlattened( self.alloc, + @intFromEnum(HighlightTag.search_match), m.matches, ) catch |err| { // Not a critical error, we just won't show highlights. @@ -2560,24 +2600,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type { false, selection, search, + search_selected, } = selected: { - // If we're highlighted, then we're selected. In the - // future we want to use a different style for this - // but this to get started. - for (highlights.items) |hl| { - if (x >= hl[0] and x <= hl[1]) { - break :selected .search; - } - } + // Order below matters for precedence. - const sel = selection orelse break :selected .false; + // Selection should take the highest precedence. const x_compare = if (wide == .spacer_tail) x -| 1 else x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } - if (x_compare >= sel[0] and - x_compare <= sel[1]) break :selected .selection; + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; + } + } break :selected .false; }; @@ -2614,6 +2664,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, }, + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + // Not selected .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) @@ -2652,6 +2708,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, }, + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + .false => if (style.flags.inverse) final_bg else diff --git a/src/renderer/message.zig b/src/renderer/message.zig index 8a319166b..8d4db32cd 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -58,6 +58,10 @@ pub const Message = union(enum) { /// viewport. The renderer must handle this gracefully. search_viewport_matches: SearchMatches, + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + /// Activate or deactivate the inspector. inspector: bool, @@ -69,6 +73,11 @@ pub const Message = union(enum) { matches: []const terminal.highlight.Flattened, }; + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 53c0c346b..3673cf1f4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -43,6 +43,7 @@ const Node = struct { prev: ?*Node = null, next: ?*Node = null, data: Page, + serial: u64, }; /// The memory pool we get page nodes from. @@ -113,6 +114,24 @@ pool_owned: bool, /// The list of pages in the screen. pages: List, +/// A monotonically increasing serial number that is incremented each +/// time a page is allocated or reused as new. The serial is assigned to +/// the Node. +/// +/// The serial number can be used to detect whether the page is identical +/// to the page that was originally referenced by a pointer. Since we reuse +/// and pool memory, pointer stability is not guaranteed, but the serial +/// will always be different for different allocations. +/// +/// Developer note: we never do overflow checking on this. If we created +/// a new page every second it'd take 584 billion years to overflow. We're +/// going to risk it. +page_serial: u64, + +/// The lowest still valid serial number that could exist. This allows +/// for quick comparisons to find invalid pages in references. +page_serial_min: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -264,7 +283,13 @@ pub fn init( // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); errdefer pool.deinit(); - const page_list, const page_size = try initPages(&pool, cols, rows); + var page_serial: u64 = 0; + const page_list, const page_size = try initPages( + &pool, + &page_serial, + cols, + rows, + ); // Get our minimum max size, see doc comments for more details. const min_max_size = try minMaxSize(cols, rows); @@ -282,6 +307,8 @@ pub fn init( .pool = pool, .pool_owned = true, .pages = page_list, + .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -297,6 +324,7 @@ pub fn init( fn initPages( pool: *MemoryPool, + serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) !struct { List, usize } { @@ -323,6 +351,7 @@ fn initPages( .init(page_buf), Page.layout(cap), ), + .serial = serial.*, }; node.data.size.rows = @min(rem, node.data.capacity.rows); rem -= node.data.size.rows; @@ -330,6 +359,9 @@ fn initPages( // Add the page to the list page_list.append(node); page_size += page_buf.len; + + // Increment our serial + serial.* += 1; } assert(page_list.first != null); @@ -363,6 +395,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { const IntegrityError = error{ TotalRowsMismatch, ViewportPinOffsetMismatch, + PageSerialInvalid, }; /// Verify the integrity of the PageList. This is expensive and should @@ -374,8 +407,27 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { // Our viewport pin should never be garbage assert(!self.viewport_pin.garbage); + // Grab our total rows + var actual_total: usize = 0; + { + var node_ = self.pages.first; + while (node_) |node| { + actual_total += node.data.size.rows; + node_ = node.next; + + // While doing this traversal, verify no node has a serial + // number lower than our min. + if (node.serial < self.page_serial_min) { + log.warn( + "PageList integrity violation: page serial too low serial={} min={}", + .{ node.serial, self.page_serial_min }, + ); + return IntegrityError.PageSerialInvalid; + } + } + } + // Verify that our cached total_rows matches the actual row count - const actual_total = self.totalRows(); if (actual_total != self.total_rows) { log.warn( "PageList integrity violation: total_rows mismatch cached={} actual={}", @@ -523,6 +575,7 @@ pub fn reset(self: *PageList) void { // we retained the capacity for the minimum number of pages we need. self.pages, self.page_size = initPages( &self.pool, + &self.page_serial, self.cols, self.rows, ) catch @panic("initPages failed"); @@ -638,6 +691,7 @@ pub fn clone( } // Copy our pages + var page_serial: u64 = 0; var total_rows: usize = 0; var page_size: usize = 0; while (it.next()) |chunk| { @@ -646,6 +700,7 @@ pub fn clone( const node = try createPageExt( pool, chunk.node.data.capacity, + &page_serial, &page_size, ); assert(node.data.capacity.rows >= chunk.end - chunk.start); @@ -690,6 +745,8 @@ pub fn clone( .alloc => true, }, .pages = page_list, + .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2431,6 +2488,14 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); + // We also need to reset the serial number. Since this is the only + // place we ever reuse a serial number, we also can safely set + // page_serial_min to be one more than the old serial because we + // only ever prune the oldest pages. + self.page_serial_min = first.serial + 1; + first.serial = self.page_serial; + self.page_serial += 1; + // Update any tracked pins that point to this page to point to the // new first page to the top-left. const pin_keys = self.tracked_pins.keys(); @@ -2570,12 +2635,18 @@ inline fn createPage( cap: Capacity, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{cap}); - return try createPageExt(&self.pool, cap, &self.page_size); + return try createPageExt( + &self.pool, + cap, + &self.page_serial, + &self.page_size, + ); } inline fn createPageExt( pool: *MemoryPool, cap: Capacity, + serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); @@ -2605,8 +2676,12 @@ inline fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = .initBuf(.init(page_buf), layout) }; + page.* = .{ + .data = .initBuf(.init(page_buf), layout), + .serial = serial.*, + }; page.data.size.rows = 0; + serial.* += 1; if (total_size) |v| { // Accumulate page size now. We don't assert or check max size diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 772d4d54b..4db5e31e7 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -32,6 +32,21 @@ const Screen = @import("Screen.zig"); pub const Untracked = struct { start: Pin, end: Pin, + + pub fn track( + self: *const Untracked, + screen: *Screen, + ) Allocator.Error!Tracked { + return try .init( + screen, + self.start, + self.end, + ); + } + + pub fn eql(self: Untracked, other: Untracked) bool { + return self.start.eql(other.start) and self.end.eql(other.end); + } }; /// A tracked highlight is a highlight that stores its highlighted @@ -99,7 +114,7 @@ pub const Flattened = struct { /// The page chunks that make up this highlight. This handles the /// y bounds since chunks[0].start is the first highlighted row /// and chunks[len - 1].end is the last highlighted row (exclsive). - chunks: std.MultiArrayList(PageChunk), + chunks: std.MultiArrayList(Chunk), /// The x bounds of the highlight. `bot_x` may be less than `top_x` /// for typical left-to-right highlights: can start the selection right @@ -107,8 +122,16 @@ pub const Flattened = struct { top_x: size.CellCountInt, bot_x: size.CellCountInt, - /// Exposed for easier type references. - pub const Chunk = PageChunk; + /// A flattened chunk is almost identical to a PageList.Chunk but + /// we also flatten the serial number. This lets the flattened + /// highlight more robust for comparisons and validity checks with + /// the PageList. + pub const Chunk = struct { + node: *PageList.List.Node, + serial: u64, + start: size.CellCountInt, + end: size.CellCountInt, + }; pub const empty: Flattened = .{ .chunks = .empty, @@ -124,7 +147,12 @@ pub const Flattened = struct { var result: std.MultiArrayList(PageChunk) = .empty; errdefer result.deinit(alloc); var it = start.pageIterator(.right_down, end); - while (it.next()) |chunk| try result.append(alloc, chunk); + while (it.next()) |chunk| try result.append(alloc, .{ + .node = chunk.node, + .serial = chunk.node.serial, + .start = chunk.start, + .end = chunk.end, + }); return .{ .chunks = result, .top_x = start.x, @@ -144,8 +172,19 @@ pub const Flattened = struct { }; } + pub fn startPin(self: Flattened) Pin { + const slice = self.chunks.slice(); + return .{ + .node = slice.items(.node)[0], + .x = self.top_x, + .y = slice.items(.start)[0], + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { + // Note: we don't use startPin/endPin here because it is slightly + // faster to reuse the slices. const slice = self.chunks.slice(); const nodes = slice.items(.node); const starts = slice.items(.start); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8f4da12eb..296360381 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -193,9 +193,17 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, - /// The x ranges of highlights within this row. Highlights are - /// applied after the update by calling `updateHighlights`. - highlights: std.ArrayList([2]size.CellCountInt), + /// The highlights within this row. + highlights: std.ArrayList(Highlight), + }; + + pub const Highlight = struct { + /// A special tag that can be used by the caller to differentiate + /// different highlight types. The value is opaque to the RenderState. + tag: u8, + + /// The x ranges of highlights within this row. + range: [2]size.CellCountInt, }; pub const Cell = struct { @@ -433,6 +441,7 @@ pub const RenderState = struct { // faster than iterating pages again later. if (last_dirty_page) |last_p| last_p.dirty = false; last_dirty_page = p; + break :dirty; } // If our row is dirty then we're dirty. @@ -646,6 +655,7 @@ pub const RenderState = struct { pub fn updateHighlightsFlattened( self: *RenderState, alloc: Allocator, + tag: u8, hls: []const highlight.Flattened, ) Allocator.Error!void { // Fast path, we have no highlights! @@ -691,8 +701,11 @@ pub const RenderState = struct { try row_highlights.append( arena_alloc, .{ - if (i == 0) hl.top_x else 0, - if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + .tag = tag, + .range = .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, }, ); diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2c5607809..8addd6ba9 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -17,8 +17,10 @@ const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; +const UntrackedHighlight = @import("../highlight.zig").Untracked; const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -241,7 +243,32 @@ fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { - .change_needle => |v| try self.changeNeedle(v), + .change_needle => |v| { + defer v.deinit(); + try self.changeNeedle(v.slice()); + }, + .select => |v| try self.select(v), + } + } +} + +fn select(self: *Thread, sel: ScreenSearch.Select) !void { + const s = if (self.search) |*s| s else return; + const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; + + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + + // The selection will trigger a selection change notification + // if it did change. + if (try screen_search.select(sel)) scroll: { + if (screen_search.selected) |m| { + // Selection changed, let's scroll the viewport to see it + // since we have the lock anyways. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse break :scroll; + screen.scroll(.{ .pin = m.highlight.start.* }); } } } @@ -252,6 +279,9 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { + // If our search is unchanged, do nothing. + if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + s.deinit(); self.search = null; @@ -261,6 +291,10 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { .{ .total_matches = 0 }, self.opts.event_userdata, ); + cb( + .{ .selected_match = null }, + self.opts.event_userdata, + ); cb( .{ .viewport_matches = &.{} }, self.opts.event_userdata, @@ -391,10 +425,17 @@ pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + /// Change the search term. If no prior search term is given this /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. - change_needle: []const u8, + change_needle: WriteReq, + + /// Select a search result. + select: ScreenSearch.Select, }; /// Events that can be emitted from the search thread. The caller @@ -409,9 +450,17 @@ pub const Event = union(enum) { /// Total matches on the current active screen have changed. total_matches: usize, + /// Selected match changed. + selected_match: ?SelectedMatch, + /// Matches in the viewport have changed. The memory is owned by the /// search thread and is only valid during the callback. viewport_matches: []const FlattenedHighlight, + + pub const SelectedMatch = struct { + idx: usize, + highlight: FlattenedHighlight, + }; }; /// Search state. @@ -422,11 +471,9 @@ const Search = struct { /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), - /// The last active screen - last_active_screen: ScreenSet.Key, - - /// The last total matches reported. - last_total: ?usize, + /// All state related to screen switches, collected so that when + /// we switch screens it makes everything related stale, too. + last_screen: ScreenState, /// True if we sent the complete notification yet. last_complete: bool, @@ -434,6 +481,22 @@ const Search = struct { /// The last viewport matches we found. stale_viewport_matches: bool, + const ScreenState = struct { + /// Last active screen key + key: ScreenSet.Key, + + /// Last notified total matches count + total: ?usize = null, + + /// Last notified selected match index + selected: ?SelectedMatch = null, + + const SelectedMatch = struct { + idx: usize, + highlight: UntrackedHighlight, + }; + }; + pub fn init( alloc: Allocator, needle: []const u8, @@ -448,8 +511,7 @@ const Search = struct { return .{ .viewport = vp, .screens = .init(.{}), - .last_active_screen = .primary, - .last_total = null, + .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; @@ -528,9 +590,10 @@ const Search = struct { t: *Terminal, ) void { // Update our active screen - if (t.screens.active_key != self.last_active_screen) { - self.last_active_screen = t.screens.active_key; - self.last_total = null; // force notification + if (t.screens.active_key != self.last_screen.key) { + // The default values will force resets of a bunch of other + // state too to force recalculations and notifications. + self.last_screen = .{ .key = t.screens.active_key }; } // Reconcile our screens with the terminal screens. Remove @@ -584,8 +647,20 @@ const Search = struct { // found the viewport/active area dirty, so we should mark it as // dirty in our viewport searcher so it forces a re-search. if (t.flags.search_viewport_dirty) { - self.viewport.active_dirty = true; t.flags.search_viewport_dirty = false; + + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } } // Check our viewport for changes. @@ -621,13 +696,13 @@ const Search = struct { cb: EventCallback, ud: ?*anyopaque, ) void { - const screen_search = self.screens.get(self.last_active_screen) orelse return; + const screen_search = self.screens.get(self.last_screen.key) orelse return; // Check our total match data const total = screen_search.matchesLen(); - if (total != self.last_total) { + if (total != self.last_screen.total) { log.debug("notifying total matches={}", .{total}); - self.last_total = total; + self.last_screen.total = total; cb(.{ .total_matches = total }, ud); } @@ -666,6 +741,40 @@ const Search = struct { cb(.{ .viewport_matches = results.items }, ud); } + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (self.last_screen.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + self.last_screen.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (self.last_screen.selected != null) { + log.debug("notifying selection cleared", .{}); + self.last_screen.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + // Send our complete notification if we just completed. if (!self.last_complete and self.isComplete()) { log.debug("notifying search complete", .{}); @@ -675,40 +784,42 @@ const Search = struct { } }; +const TestUserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + selected: ?Event.SelectedMatch = null, + viewport: []FlattenedHighlight = &.{}, + + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } + + fn callback(event: Event, userdata: ?*anyopaque) void { + const ud: *Self = @ptrCast(@alignCast(userdata.?)); + switch (event) { + .quit => {}, + .complete => ud.reset.set(), + .total_matches => |v| ud.total = v, + .selected_match => |v| ud.selected = v, + .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(ud.viewport); + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, + ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } + }, + } + } +}; + test { - const UserData = struct { - const Self = @This(); - reset: std.Thread.ResetEvent = .{}, - total: usize = 0, - viewport: []FlattenedHighlight = &.{}, - - fn deinit(self: *Self) void { - for (self.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(self.viewport); - } - - fn callback(event: Event, userdata: ?*anyopaque) void { - const ud: *Self = @ptrCast(@alignCast(userdata.?)); - switch (event) { - .quit => {}, - .complete => ud.reset.set(), - .total_matches => |v| ud.total = v, - .viewport_matches => |v| { - for (ud.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(ud.viewport); - - ud.viewport = testing.allocator.alloc( - FlattenedHighlight, - v.len, - ) catch unreachable; - for (ud.viewport, v) |*dst, src| { - dst.* = src.clone(testing.allocator) catch unreachable; - } - }, - } - } - }; - const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); @@ -718,12 +829,12 @@ test { defer stream.deinit(); try stream.nextSlice("Hello, world"); - var ud: UserData = .{}; + var ud: TestUserData = .{}; defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, - .event_cb = &UserData.callback, + .event_cb = &TestUserData.callback, .event_userdata = &ud, }); defer thread.deinit(); @@ -736,7 +847,10 @@ test { // Start our search _ = thread.mailbox.push( - .{ .change_needle = "world" }, + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, .forever, ); try thread.wakeup.notify(); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 071ccd090..97784e97e 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -3,7 +3,10 @@ const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); -const FlattenedHighlight = @import("../highlight.zig").Flattened; +const highlight = @import("../highlight.zig"); +const size = @import("../size.zig"); +const FlattenedHighlight = highlight.Flattened; +const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); @@ -13,6 +16,8 @@ const ActiveSearch = @import("active.zig").ActiveSearch; const PageListSearch = @import("pagelist.zig").PageListSearch; const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const log = std.log.scoped(.search_screen); + /// Searches for a needle within a Screen, handling active area updates, /// pages being pruned from the screen (e.g. scrollback limits), and more. /// @@ -41,6 +46,11 @@ pub const ScreenSearch = struct { /// Current state of the search, a state machine. state: State, + /// The currently selected match, if any. As the screen contents + /// change or get pruned, the screen search will do its best to keep + /// this accurate. + selected: ?SelectedMatch = null, + /// The results found so far. These are stored separately because history /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a @@ -48,6 +58,23 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), + /// The dimensions of the screen. When this changes we need to + /// restart the whole search, currently. + rows: size.CellCountInt, + cols: size.CellCountInt, + + pub const SelectedMatch = struct { + /// Index from the end of the match list (0 = most recent match) + idx: usize, + + /// Tracked highlight so we can detect movement. + highlight: TrackedHighlight, + + pub fn deinit(self: *SelectedMatch, screen: *Screen) void { + self.highlight.deinit(screen); + } + }; + /// History search state. const HistorySearch = struct { /// The actual searcher state. @@ -90,6 +117,11 @@ pub const ScreenSearch = struct { pub fn needsFeed(self: State) bool { return switch (self) { .history_feed => true, + + // Not obvious but complete search states will prune + // stale history results on feed. + .complete => true, + else => false, }; } @@ -103,6 +135,8 @@ pub const ScreenSearch = struct { ) Allocator.Error!ScreenSearch { var result: ScreenSearch = .{ .screen = screen, + .rows = screen.pages.rows, + .cols = screen.pages.cols, .active = try .init(alloc, needle_unowned), .history = null, .state = .active, @@ -121,6 +155,7 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + if (self.selected) |*m| m.deinit(self.screen); for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); for (self.history_results.items) |*hl| hl.deinit(alloc); @@ -216,7 +251,33 @@ pub const ScreenSearch = struct { /// Feed more data to the searcher so it can continue searching. This /// accesses the screen state, so the caller must hold the necessary locks. + /// + /// Feed on a complete screen search will perform some cleanup of + /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { + // If the screen resizes, we have to reset our entire search. That + // isn't ideal but we don't have a better way right now to handle + // reflowing the search results beyond putting a tracked pin for + // every single result. + if (self.screen.pages.rows != self.rows or + self.screen.pages.cols != self.cols) + { + // Reinit + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + // Deinit/reinit + self.deinit(); + self.* = new; + + // New result should have matching dimensions + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + } + const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. self.state = .complete; @@ -228,6 +289,11 @@ pub const ScreenSearch = struct { if (!try history.feed()) { // No more data to feed, search is complete. self.state = .complete; + + // We use this opportunity to also clean up older history + // results that may be gone due to scrollback pruning, though. + self.pruneHistory(); + return; } @@ -246,6 +312,25 @@ pub const ScreenSearch = struct { } } + fn pruneHistory(self: *ScreenSearch) void { + // Go through our history results in order (newest to oldest) to find + // any result that contains an invalid serial. Prune up to that + // point. + for (0..self.history_results.items.len) |i| { + const hl = &self.history_results.items[i]; + const serials = hl.chunks.items(.serial); + const lowest = serials[0]; + if (lowest < self.screen.pages.page_serial_min) { + // Everything from here forward we assume is invalid because + // our history results only get older. + const alloc = self.allocator(); + for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); + self.history_results.shrinkAndFree(alloc, i); + return; + } + } + } + fn tickActive(self: *ScreenSearch) Allocator.Error!void { // For the active area, we consume the entire search in one go // because the active area is generally small. @@ -284,6 +369,10 @@ pub const ScreenSearch = struct { var hl_cloned = try hl.clone(alloc); errdefer hl_cloned.deinit(alloc); try self.history_results.append(alloc, hl_cloned); + + // Since history only appends to our results in reverse order, + // we don't need to update any selected match state. The index + // and prior results are unaffected. } // We need to be fed more data. @@ -298,6 +387,24 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // If our selection pin became garbage it means we scrolled off + // the end. Clear our selection and on exit of this function, + // try to select the last match. + const select_prev: bool = select_prev: { + const m = if (self.selected) |*m| m else break :select_prev false; + if (!m.highlight.start.garbage and + !m.highlight.end.garbage) break :select_prev false; + + m.deinit(self.screen); + self.selected = null; + break :select_prev true; + }; + defer if (select_prev) { + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + }; + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { @@ -386,14 +493,72 @@ pub const ScreenSearch = struct { // in our history (fast path) if (results.items.len == 0) break :history; + // The number added to our history. Needed for updating + // our selection if we have one. + const added_len = results.items.len; + // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. std.mem.reverse(FlattenedHighlight, results.items); try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; + + // If our prior selection was in the history area, update + // the offset. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.idx += added_len; + + // Moving the idx should not change our targeted result + // since the history is immutable. + if (comptime std.debug.runtime_safety) { + const hl = self.history_results.items[m.idx - active_len]; + assert(m.highlight.start.eql(hl.startPin())); + } + } + } else { + // No history node means we have no history + if (self.history) |*h| { + h.deinit(self.screen); + self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); + self.history_results.clearRetainingCapacity(); + } + + // If we have a selection in the history area, we need to + // move it to the end of the active area. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.deinit(self.screen); + self.selected = null; + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } + // Figure out if we need to fixup our selection later because + // it was in the active area. + const old_active_len = self.active_results.items.len; + const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null; + errdefer if (old_selection_idx != null and + old_selection_idx.? < old_active_len) + { + // This is the error scenario. If something fails below, + // our active area is probably gone, so we just go back + // to the first result because our selection can't be trusted. + if (self.selected) |*m| { + m.deinit(self.screen); + self.selected = null; + _ = self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + }; + // Reset our active search results and search again. for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); @@ -410,6 +575,203 @@ pub const ScreenSearch = struct { try self.tickActive(); }, } + + // Active area search was successful. Now we have to fixup our + // selection if we had one. + fixup: { + const old_idx = old_selection_idx orelse break :fixup; + const m = if (self.selected) |*m| m else break :fixup; + + // If our old selection wasn't in the active area, then we + // need to fix up our offsets. + if (old_idx >= old_active_len) { + m.idx -= old_active_len; + m.idx += self.active_results.items.len; + break :fixup; + } + + // We search for the matching highlight in the new active results. + for (0.., self.active_results.items) |i, hl| { + const untracked = hl.untracked(); + if (m.highlight.start.eql(untracked.start) and + m.highlight.end.eql(untracked.end)) + { + // Found it! Update our index. + m.idx = self.active_results.items.len - 1 - i; + break :fixup; + } + } + + // No match, just go back to the first match. + m.deinit(self.screen); + self.selected = null; + _ = self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + } + + /// Return the selected match. + /// + /// This does not require read/write access to the underlying screen. + pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { + const sel = self.selected orelse return null; + const active_len = self.active_results.items.len; + if (sel.idx < active_len) { + return self.active_results.items[active_len - 1 - sel.idx]; + } + + const history_len = self.history_results.items.len; + if (sel.idx < active_len + history_len) { + return self.history_results.items[sel.idx - active_len]; + } + + return null; + } + + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest), + /// non-wrapping. + next, + + /// Prev selection, in forward order (oldest to newest), + /// non-wrapping. + prev, + }; + + /// Select the next or previous search result. This requires read/write + /// access to the underlying screen, since we utilize tracked pins to + /// ensure our selection sticks with contents changing. + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { + // All selection requires valid pins so we prune history and + // reload our active area immediately. This ensures all search + // results point to valid nodes. + try self.reloadActive(); + self.pruneHistory(); + + return switch (to) { + .next => try self.selectNext(), + .prev => try self.selectPrev(), + }; + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!bool { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the first. + var prev = if (self.selected) |*m| m else { + // Get our highlight + const hl: FlattenedHighlight = hl: { + if (self.active_results.items.len > 0) { + // Active is in forward order + const len = self.active_results.items.len; + break :hl self.active_results.items[len - 1]; + } else if (self.history_results.items.len > 0) { + // History is in reverse order + break :hl self.history_results.items[0]; + } else { + // No matches at all. Can't select anything. + return false; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is index zero since we just started and + // we store our selection. + self.selected = .{ + .idx = 0, + .highlight = tracked, + }; + return true; + }; + + const next_idx = prev.idx + 1; + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + if (next_idx >= active_len + history_len) { + // No more matches. We don't wrap or reset the match currently. + return false; + } + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + + return true; + } + + fn selectPrev(self: *ScreenSearch) Allocator.Error!bool { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the last. + var prev = if (self.selected) |*m| m else { + // Get our highlight (oldest match) + const hl: FlattenedHighlight = hl: { + if (self.history_results.items.len > 0) { + // History is in reverse order, so last item is oldest + const len = self.history_results.items.len; + break :hl self.history_results.items[len - 1]; + } else if (self.active_results.items.len > 0) { + // Active is in forward order, so first item is oldest + break :hl self.active_results.items[0]; + } else { + // No matches at all. Can't select anything. + return false; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is the last index since we just started + // and we store our selection. + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + self.selected = .{ + .idx = active_len + history_len - 1, + .highlight = tracked, + }; + return true; + }; + + // Can't go below zero + if (prev.idx == 0) { + // No more matches. We don't wrap or reset the match currently. + return false; + } + + const next_idx = prev.idx - 1; + const active_len = self.active_results.items.len; + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + + return true; } }; @@ -623,3 +985,352 @@ test "active change contents" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select our next match (first) + try search.searchAll(); + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match (no wrap) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select in active changes contents completely" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + _ = try search.select(.next); + _ = try search.select(.next); + { + // Initial selection is the first fizz + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, move our cursor to the top, and change contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + + try search.reloadActive(); + { + // Our selection should move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + + try search.reloadActive(); + { + // Our selection should not move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select into history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Get all matches + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("yo yo"); + + try search.reloadActive(); + { + // Our selection should not move since the history is still active. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Create some new history by adding more lines. + try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + try search.reloadActive(); + { + // Our selection should not move since the history is still not + // pruned. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select prev (oldest first) + try search.searchAll(); + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (towards newest) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (no wrap, stays at newest) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev then next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select next (newest first) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select next (older) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select prev (back to newer) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } +} + +test "select prev with history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select prev (oldest first, should be in history) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Select prev (towards newer, should move to active area) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } +} diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index ff0fa0277..0d853b3a0 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -87,6 +87,7 @@ pub const SlidingWindow = struct { const MetaBuf = CircBuf(Meta, undefined); const Meta = struct { node: *PageList.List.Node, + serial: u64, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -221,10 +222,17 @@ pub const SlidingWindow = struct { ); } + // Special case 1-lengthed needles to delete the entire buffer. + if (self.needle.len == 1) { + self.clearAndRetainCapacity(); + self.assertIntegrity(); + return null; + } + // No match. We keep `needle.len - 1` bytes available to // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); prune: { + var meta_it = self.meta.iterator(.reverse); var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; @@ -345,6 +353,7 @@ pub const SlidingWindow = struct { result.bot_x = end_map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(start_map.y), .end = @intCast(end_map.y + 1), }); @@ -363,6 +372,7 @@ pub const SlidingWindow = struct { result.top_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(map.y), .end = meta.node.data.size.rows, }); @@ -397,6 +407,7 @@ pub const SlidingWindow = struct { // to our results because we want the full flattened list. self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = meta.node.data.size.rows, }); @@ -410,6 +421,7 @@ pub const SlidingWindow = struct { result.bot_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = @intCast(map.y + 1), }); @@ -513,6 +525,7 @@ pub const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, + .serial = node.serial, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -600,7 +613,7 @@ pub const SlidingWindow = struct { assert(data_len == self.data.len()); // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); + assert(self.data.len() == 0 or self.data_offset < self.data.len()); } }; @@ -703,6 +716,52 @@ test "SlidingWindow single append case insensitive ASCII" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +test "SlidingWindow single append single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; @@ -782,6 +841,61 @@ test "SlidingWindow two pages" { try testing.expect(w.next() == null); } +test "SlidingWindow two pages single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow two pages match across boundary" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/termio.zig b/src/termio.zig index c69785b25..b16885109 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -30,7 +30,6 @@ pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = mailbox.Mailbox; pub const Message = message.Message; -pub const MessageData = message.MessageData; pub const StreamHandler = stream_handler.StreamHandler; test { diff --git a/src/termio/message.zig b/src/termio/message.zig index de7ea16cb..23b9f2545 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -5,6 +5,7 @@ const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// @@ -97,95 +98,6 @@ pub const Message = union(enum) { }; }; -/// Creates a union that can be used to accommodate data that fit within an array, -/// are a stable pointer, or require deallocation. This is helpful for thread -/// messaging utilities. -pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { - return union(enum) { - pub const Self = @This(); - - pub const Small = struct { - pub const Max = small_size; - pub const Array = [Max]Elem; - pub const Len = std.math.IntFittingRange(0, small_size); - data: Array = undefined, - len: Len = 0, - }; - - pub const Alloc = struct { - alloc: Allocator, - data: []Elem, - }; - - pub const Stable = []const Elem; - - /// A small write where the data fits into this union size. - small: Small, - - /// A stable pointer so we can just pass the slice directly through. - /// This is useful i.e. for const data. - stable: Stable, - - /// Allocated and must be freed with the provided allocator. This - /// should be rarely used. - alloc: Alloc, - - /// Initializes the union for a given data type. This will - /// attempt to fit into a small value if possible, otherwise - /// will allocate and put into alloc. - /// - /// This can't and will never detect stable pointers. - pub fn init(alloc: Allocator, data: anytype) !Self { - switch (@typeInfo(@TypeOf(data))) { - .pointer => |info| { - assert(info.size == .slice); - assert(info.child == Elem); - - // If it fits in our small request, do that. - if (data.len <= Small.Max) { - var buf: Small.Array = undefined; - @memcpy(buf[0..data.len], data); - return Self{ - .small = .{ - .data = buf, - .len = @intCast(data.len), - }, - }; - } - - // Otherwise, allocate - const buf = try alloc.dupe(Elem, data); - errdefer alloc.free(buf); - return Self{ - .alloc = .{ - .alloc = alloc, - .data = buf, - }, - }; - }, - - else => unreachable, - } - } - - pub fn deinit(self: Self) void { - switch (self) { - .small, .stable => {}, - .alloc => |v| v.alloc.free(v.data), - } - } - - /// Returns a const slice of the data pointed to by this request. - pub fn slice(self: *const Self) []const Elem { - return switch (self.*) { - .small => |*v| v.data[0..v.len], - .stable => |v| v, - .alloc => |v| v.data, - }; - } - }; -} - test { std.testing.refAllDecls(@This()); } @@ -195,35 +107,3 @@ test { const testing = std.testing; try testing.expectEqual(@as(usize, 40), @sizeOf(Message)); } - -test "MessageData init small" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello!"; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .small); -} - -test "MessageData init alloc" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello! " ** 100; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .alloc); - io.alloc.alloc.free(io.alloc.data); -} - -test "MessageData small fits non-u8 sized data" { - const testing = std.testing; - const alloc = testing.allocator; - - const len = 500; - const Data = MessageData(u8, len); - const input: []const u8 = "X" ** len; - const io = try Data.init(alloc, input); - try testing.expect(io == .small); -}