macOS Search GUI (#9709)

#189 for macOS

This adds a search GUI for macOS, including macOS-standard menu bar
items, and keybindings that match other native macOS applications such
as Terminal app, Safari, and others. This introduces a new keybinding
action `start_search` to start a blank search for GUIs.

This PR also resolves a number of minor issues found in the search
subsystem and renderer related to search from prior PRs. This should
result in overall improved search stability. **Please note there are
still known issues (bottom of this PR).**

> [!WARNING]
>
> **A note on stability:** I know a lot of people are eager to have this
feature, and I'm excited
> for this feature to soon be available in tip releases. But note that
new features like this are
> always filled with performance issues, bugs, crashes, etc. That's the
point of tip releases:
> to find and address these before wider availability. We will do our
best to respond rapidly to
> major issues found in tip, but don't expect perfect functionality
immediately!

## Demo


https://github.com/user-attachments/assets/3b81752e-d7e5-4875-9864-92497333b23e

> [!NOTE]
>
> You can drag the search window to any of the four corners if its
getting in the way of reading the terminal.

## Known Issues

TODO prior to this PR merging:

- [x] Single-byte search terms cause a crash since our sliding window
can't handle it. This PR temporarily requires search terms with length
2+ before starting a search to avoid it. 😄
- [x] The way `ScreenSearch` prunes history is fundamental unsafe. With
a rapidly growing screen that could reach history limits and an active
search at the same time, Ghostty is almost guaranteed to crash
currently. The workaround is to not search actively scrolling screens
(new data) for now.
pull/9713/head
Mitchell Hashimoto 2025-11-26 09:09:55 -08:00 committed by GitHub
commit 71a2dad929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1038 additions and 147 deletions

View File

@ -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 {

View File

@ -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)

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24123.1" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24123.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -26,7 +26,12 @@
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
<outlet property="menuEqualizeSplits" destination="3gH-VD-vL9" id="SiZ-ce-FOF"/>
<outlet property="menuFind" destination="nwE-0w-30h" id="idg-Nc-apE"/>
<outlet property="menuFindNext" destination="XqU-X8-q32" id="vNh-AH-6gZ"/>
<outlet property="menuFindParent" destination="cE3-Bt-FcH" id="2dc-ok-hgH"/>
<outlet property="menuFindPrevious" destination="1hd-2Z-wVm" id="sSo-wO-2MW"/>
<outlet property="menuFloatOnTop" destination="uRj-7z-1Nh" id="94n-o9-Jol"/>
<outlet property="menuHideFindBar" destination="xzC-AG-HAc" id="HCo-o6-VWv"/>
<outlet property="menuIncreaseFontSize" destination="CIH-ey-Z6x" id="hkc-9C-80E"/>
<outlet property="menuMoveSplitDividerDown" destination="Zj7-2W-fdF" id="997-LL-nlN"/>
<outlet property="menuMoveSplitDividerLeft" destination="wSR-ny-j1a" id="HCZ-CI-2ob"/>
@ -245,6 +250,39 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VYS-RG-uZD"/>
<menuItem title="Find" id="cE3-Bt-FcH">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="vPo-Sd-cTP">
<items>
<menuItem title="Find..." id="nwE-0w-30h">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="find:" target="-1" id="PeY-3u-IxC"/>
</connections>
</menuItem>
<menuItem title="Find Next" id="XqU-X8-q32">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="findNext:" target="-1" id="Dka-ng-aSs"/>
</connections>
</menuItem>
<menuItem title="Find Previous" id="1hd-2Z-wVm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="findPrevious:" target="-1" id="Zvs-bs-ZR4"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="KlV-2C-wYr"/>
<menuItem title="Hide Find Bar" id="xzC-AG-HAc">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="Xbz-ms-irt"/>
</items>
</menu>
</menuItem>

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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,159 @@ 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(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
#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.count))
return .handled
}
if let selected = searchState.selected {
Text("\(selected + 1)/\(searchState.total, default: "?")")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
} else if let total = searchState.total {
Text("-/\(total)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
Button(action: {
guard let surface = surfaceView.surface else { return }
let action = "navigate_search:next"
ghostty_surface_binding_action(surface, action, UInt(action.count))
}) {
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.count))
}) {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.borderless)
}
.padding(8)
.background(.background)
.cornerRadius(8)
.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
)
corner = closestCorner(to: newCenter, in: geo.size)
dragOffset = .zero
}
)
.animation(.easeOut(duration: 0.2), value: corner)
}
}
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 +820,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 ?? ""
}
}
}

View File

@ -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<String, Never> 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.count))
}
} 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.count))
}
}
}
// 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
@ -1447,6 +1485,38 @@ extension Ghostty {
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.count))) {
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.count))) {
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.count))) {
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.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
@ -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

View File

@ -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.

View File

@ -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 {

View File

@ -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 },
);
},
}
}
@ -1378,19 +1402,42 @@ fn searchCallback_(
} },
.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),
@ -1399,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 => {},
}
}
@ -4877,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.
@ -4915,7 +5001,10 @@ 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 {};

View File

@ -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,
};
}
};

View File

@ -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;

View File

@ -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,

View File

@ -6403,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,

View File

@ -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;

View File

@ -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);
}

View File

@ -333,13 +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,
@ -1167,6 +1178,8 @@ pub const Action = union(enum) {
.cursor_key,
.search,
.navigate_search,
.start_search,
.end_search,
.reset,
.copy_to_clipboard,
.copy_url_to_clipboard,

View File

@ -163,6 +163,18 @@ 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",
@ -173,6 +185,12 @@ fn actionCommands(action: Action.Key) []const Command {
.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",
@ -614,7 +632,6 @@ fn actionCommands(action: Action.Key) []const Command {
.csi,
.esc,
.cursor_key,
.search,
.set_font_size,
.scroll_to_row,
.scroll_page_fractional,

View File

@ -1217,8 +1217,21 @@ 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
@ -1228,7 +1241,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.terminal_state.updateHighlightsFlattened(
self.alloc,
@intFromEnum(HighlightTag.search_match_selected),
(&m.match)[0..1],
&.{m.match},
) catch |err| {
// Not a critical error, we just won't show highlights.
log.warn("error updating search selected highlight err={}", .{err});

View File

@ -17,6 +17,7 @@ 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;
@ -242,7 +243,10 @@ 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),
}
}
@ -275,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;
@ -414,10 +421,14 @@ 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,
@ -632,8 +643,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.
@ -820,7 +843,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();

View File

@ -493,6 +493,10 @@ 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);
@ -505,7 +509,7 @@ pub const ScreenSearch = struct {
if (self.selected) |*m| selected: {
const active_len = self.active_results.items.len;
if (m.idx < active_len) break :selected;
m.idx += results.items.len;
m.idx += added_len;
// Moving the idx should not change our targeted result
// since the history is immutable.
@ -514,6 +518,26 @@ pub const ScreenSearch = struct {
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

View File

@ -222,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;
@ -606,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());
}
};
@ -709,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;
@ -788,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;

View File

@ -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 {

View File

@ -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);
}