mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-01-06 05:17:50 +00:00
Support native MacOS shortcuts to move to beginning and end of window (#10003)
Adds support to scroll to the top or bottom of the window by using macOS native shortcuts, testing in video. **AI disclaimer**: AI used to explore solutions, tested and edited solution proposed by Opus 4.5 https://github.com/user-attachments/assets/333a17e4-b496-4ce4-8650-5b11d5e2b94d
This commit is contained in:
@@ -9,7 +9,7 @@ extension Ghostty {
|
||||
/// The NSView implementation for a terminal surface.
|
||||
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
|
||||
typealias ID = UUID
|
||||
|
||||
|
||||
/// Unique ID per surface
|
||||
let id: UUID
|
||||
|
||||
@@ -44,14 +44,14 @@ extension Ghostty {
|
||||
|
||||
// The hovered URL string
|
||||
@Published var hoverUrl: String? = nil
|
||||
|
||||
|
||||
// The progress report (if any)
|
||||
@Published var progressReport: Action.ProgressReport? = nil {
|
||||
didSet {
|
||||
// Cancel any existing timer
|
||||
progressReportTimer?.invalidate()
|
||||
progressReportTimer = nil
|
||||
|
||||
|
||||
// If we have a new progress report, start a timer to remove it after 15 seconds
|
||||
if progressReport != nil {
|
||||
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
|
||||
@@ -101,7 +101,7 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cancellable for search state needle changes
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
|
||||
@@ -219,7 +219,7 @@ extension Ghostty {
|
||||
|
||||
// A timer to fallback to ghost emoji if no title is set within the grace period
|
||||
private var titleFallbackTimer: Timer?
|
||||
|
||||
|
||||
// Timer to remove progress report after 15 seconds
|
||||
private var progressReportTimer: Timer?
|
||||
|
||||
@@ -418,7 +418,7 @@ extension Ghostty {
|
||||
// Remove any notifications associated with this surface
|
||||
let identifiers = Array(self.notificationIdentifiers)
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
|
||||
|
||||
// Cancel progress report timer
|
||||
progressReportTimer?.invalidate()
|
||||
}
|
||||
@@ -555,16 +555,16 @@ extension Ghostty {
|
||||
// Add buttons
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
|
||||
// Make the text field the first responder so it gets focus
|
||||
alert.window.initialFirstResponder = textField
|
||||
|
||||
|
||||
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
// Check if the user clicked "OK"
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
|
||||
|
||||
// Get the input text
|
||||
let newTitle = textField.stringValue
|
||||
if newTitle.isEmpty {
|
||||
@@ -988,7 +988,7 @@ extension Ghostty {
|
||||
var x = event.scrollingDeltaX
|
||||
var y = event.scrollingDeltaY
|
||||
let precision = event.hasPreciseScrollingDeltas
|
||||
|
||||
|
||||
if precision {
|
||||
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
|
||||
x *= 2;
|
||||
@@ -1350,7 +1350,7 @@ extension Ghostty {
|
||||
|
||||
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
|
||||
key_ev.composing = composing
|
||||
|
||||
|
||||
// For text, we only encode UTF8 if we don't have a single control
|
||||
// character. Control characters are encoded by Ghostty itself.
|
||||
// Without this, `ctrl+enter` does the wrong thing.
|
||||
@@ -1509,7 +1509,7 @@ 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"
|
||||
@@ -1517,7 +1517,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func findNext(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "search:next"
|
||||
@@ -1533,7 +1533,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func findHide(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "end_search"
|
||||
@@ -1593,7 +1593,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func changeTitle(_ sender: Any) {
|
||||
promptTitle()
|
||||
}
|
||||
@@ -1703,7 +1703,7 @@ extension Ghostty {
|
||||
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
|
||||
|
||||
self.init(app, baseConfig: config, uuid: uuid)
|
||||
|
||||
|
||||
// Restore the saved title after initialization
|
||||
if let title = savedTitle {
|
||||
self.title = title
|
||||
@@ -1920,6 +1920,17 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
return
|
||||
}
|
||||
|
||||
guard let surfaceModel else { return }
|
||||
// Process MacOS native scroll events
|
||||
switch selector {
|
||||
case #selector(moveToBeginningOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_top")
|
||||
case #selector(moveToEndOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_bottom")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
print("SEL: \(selector)")
|
||||
}
|
||||
|
||||
@@ -1960,14 +1971,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
|
||||
// but get requested an image (we can't handle that at the time of writing this),
|
||||
// so we must bubble up.
|
||||
|
||||
|
||||
// Types we can receive
|
||||
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
|
||||
|
||||
|
||||
// Types that we can send. Currently the same as receivable but I'm separating
|
||||
// this out so we can modify this in the future.
|
||||
let sendable: [NSPasteboard.PasteboardType] = receivable
|
||||
|
||||
|
||||
// The sendable types that require a selection (currently all)
|
||||
let sendableRequiresSelection = sendable
|
||||
|
||||
@@ -1984,7 +1995,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
return super.validRequestor(forSendType: sendType, returnType: returnType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -2030,7 +2041,7 @@ 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
|
||||
|
||||
@@ -2135,7 +2146,7 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilitySelectedTextRange() -> NSRange {
|
||||
return selectedRange()
|
||||
}
|
||||
|
||||
|
||||
/// Returns the currently selected text as a string.
|
||||
/// This allows assistive technologies to read the selected content.
|
||||
override func accessibilitySelectedText() -> String? {
|
||||
@@ -2149,21 +2160,21 @@ extension Ghostty.SurfaceView {
|
||||
let str = String(cString: text.text)
|
||||
return str.isEmpty ? nil : str
|
||||
}
|
||||
|
||||
|
||||
/// Returns the number of characters in the terminal content.
|
||||
/// This helps assistive technologies understand the size of the content.
|
||||
override func accessibilityNumberOfCharacters() -> Int {
|
||||
let content = cachedScreenContents.get()
|
||||
return content.count
|
||||
}
|
||||
|
||||
|
||||
/// Returns the visible character range for the terminal.
|
||||
/// For terminals, we typically show all content as visible.
|
||||
override func accessibilityVisibleCharacterRange() -> NSRange {
|
||||
let content = cachedScreenContents.get()
|
||||
return NSRange(location: 0, length: content.count)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the line number for a given character index.
|
||||
/// This helps assistive technologies navigate by line.
|
||||
override func accessibilityLine(for index: Int) -> Int {
|
||||
@@ -2171,7 +2182,7 @@ extension Ghostty.SurfaceView {
|
||||
let substring = String(content.prefix(index))
|
||||
return substring.components(separatedBy: .newlines).count - 1
|
||||
}
|
||||
|
||||
|
||||
/// Returns a substring for the given range.
|
||||
/// This allows assistive technologies to read specific portions of the content.
|
||||
override func accessibilityString(for range: NSRange) -> String? {
|
||||
@@ -2179,7 +2190,7 @@ extension Ghostty.SurfaceView {
|
||||
guard let swiftRange = Range(range, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
|
||||
|
||||
/// Returns an attributed string for the given range.
|
||||
///
|
||||
/// Note: right now this only applies font information. One day it'd be nice to extend
|
||||
@@ -2190,9 +2201,9 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
|
||||
guard let surface = self.surface else { return nil }
|
||||
guard let plainString = accessibilityString(for: range) else { return nil }
|
||||
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
|
||||
|
||||
// Try to get the font from the surface
|
||||
if let fontRaw = ghostty_surface_quicklook_font(surface) {
|
||||
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
|
||||
|
||||
Reference in New Issue
Block a user