diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index eccda4982..ef34fe405 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -250,6 +250,9 @@ "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; "Replace in File…" = "在文件中替换…"; +"Go to Line…" = "跳转到行…"; +"Go to Line:Column" = "跳转到行:列"; +"[Line] [:column]:" = "[行] [:列]:"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -909,6 +912,8 @@ "Search within the active editor" = "在当前编辑器中搜索"; "Replace in File" = "在文件中替换"; "Replace within the active editor" = "在当前编辑器中替换"; +"Go to Line" = "跳转到行"; +"Jump to a line and column in the active editor" = "在当前编辑器中跳转到指定的行和列"; "Navigate to a call site of the selected Java symbol" = "导航到所选 Java 符号的调用位置"; "Find references to the selected Java symbol" = "查找所选 Java 符号的引用"; "Open history for the active file" = "打开当前文件的历史记录"; diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1196dc253..1007e28fc 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,6 +8,9 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? + /// Consume the location with the whole target line selected (Go to Line); + /// symbol and find navigation keep a zero-length caret. + let selectsWholeLine: Bool init( url: URL, @@ -15,7 +18,8 @@ struct EditorNavigationLocation: Hashable, Sendable { utf16Column: Int, isReadOnly: Bool = false, displayPath: String? = nil, - virtualProviderID: String? = nil + virtualProviderID: String? = nil, + selectsWholeLine: Bool = false ) { self.url = url.isFileURL ? url.standardizedFileURL : url self.line = max(0, line) @@ -23,6 +27,7 @@ struct EditorNavigationLocation: Hashable, Sendable { self.isReadOnly = isReadOnly self.displayPath = displayPath self.virtualProviderID = virtualProviderID + self.selectsWholeLine = selectsWholeLine } } diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 7ae2d123b..95e22a1d0 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -416,6 +416,12 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) + + Button("Go to Line…") { + model.showGoToLine() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) + .disabled(model.activeDocument == nil) } Divider() @@ -674,7 +680,10 @@ private func settingsWindowTitle(for language: AppLanguage) -> String { ) } -private extension AppThemePreference { +extension AppThemePreference { + /// NSAppearance applied to app windows for the selected theme; `nil` + /// means follow the system appearance. Shared by every presenting + /// window, including the Go to Line dialog. var windowAppearance: NSAppearance? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 24a4bcc4c..f1f5f3ef5 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -766,7 +766,8 @@ extension AppModel { line: Int, utf16Column: Int, isReadOnly: Bool = false, - displayPath: String? = nil + displayPath: String? = nil, + selectsWholeLine: Bool = false ) { navigate( to: EditorNavigationLocation( @@ -775,7 +776,8 @@ extension AppModel { utf16Column: utf16Column, isReadOnly: isReadOnly, displayPath: displayPath, - virtualProviderID: nil + virtualProviderID: nil, + selectsWholeLine: selectsWholeLine ), recordsHistory: true ) @@ -796,7 +798,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) return } @@ -838,7 +841,8 @@ extension AppModel { self.editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) case .failure(let error): onFailure?() @@ -865,7 +869,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url.standardizedFileURL, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 76f2c65b3..9553dc344 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -333,7 +333,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "replace-in-file", "local-history", "reveal-in-finder": + case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift new file mode 100644 index 000000000..be7ca972f --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -0,0 +1,45 @@ +import Foundation + +/// AppModel facade for the Go to Line feature: drives the `EditorChromeModel` +/// visibility flag that the dialog presenter observes, and routes the +/// submitted "line" or "line:column" input through the existing +/// `navigateToEditorLocation` pathway so jumps enter the navigation history. +extension AppModel { + var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } + + func showGoToLine() { + guard activeDocument != nil else { return } + if isFindBarVisible { + hideFindBar() + } + editorChrome.setGoToLineVisible(true) + } + + func hideGoToLine() { + editorChrome.setGoToLineVisible(false) + } + + /// Parse "120" or "120:35" and jump; unparseable input or a missing + /// active document is a no-op. Line and column are converged against the + /// current document text right before jumping, without caching stale + /// line counts, and the jump enters the navigation history so Cmd+[ + /// returns to the departure position. Line-only jumps select the whole + /// target line; an explicitly entered column places the caret at that + /// column instead, so the entered position is never discarded. + func goToLine(_ text: String) { + guard let document = activeDocument, + let parsed = GoToLineInput.parse(text) else { return } + let target = GoToLineInput.clamped( + line: parsed.line, + column: parsed.column, + hasExplicitColumn: parsed.hasExplicitColumn, + in: document.text + ) + navigateToEditorLocation( + url: document.url, + line: target.line, + utf16Column: target.column, + selectsWholeLine: !target.hasExplicitColumn + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index e9baee239..164c7bbf1 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1069,6 +1069,7 @@ final class AppModel: ObservableObject, Identifiable { standaloneFileURL = nil documentFeature.reset() editorChrome.resetFindBar() + editorChrome.setGoToLineVisible(false) didCloseProject?() } diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 9931bc4b5..2e81a82a0 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -9,6 +9,7 @@ final class EditorChromeModel: ObservableObject { @Published private(set) var caret: EditorCaret? @Published private(set) var selectedText = "" @Published private(set) var isFindBarVisible = false + @Published private(set) var isGoToLineVisible = false @Published private(set) var findBarQuery = "" @Published private(set) var findOptions = FindInFileOptions() @Published private(set) var isReplaceVisible = false @@ -29,6 +30,19 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible + // The find bar and the go-to-line dialog are mutually exclusive; + // opening either dismisses the other. + if isVisible, isGoToLineVisible { + setGoToLineVisible(false) + } + } + + func setGoToLineVisible(_ isVisible: Bool) { + guard isGoToLineVisible != isVisible else { return } + isGoToLineVisible = isVisible + if isVisible, isFindBarVisible { + setFindBarVisible(false) + } } func setFindBarQuery(_ query: String) { @@ -70,5 +84,6 @@ final class EditorChromeModel: ObservableObject { update(caret: nil) update(selectedText: "") resetFindBar() + setGoToLineVisible(false) } } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift new file mode 100644 index 000000000..82c07a7d3 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Parsing and document-range convergence for the Go to Line input. The +/// input is 1-based text — "120" or "120:35" — and the parsed result is the +/// internal 0-based line and column. The 1-based → 0-based conversion happens +/// only here; the status bar adds 1 back for display, avoiding split offsets. +struct GoToLineInput: Equatable { + let line: Int + let column: Int + /// True when the input carried an explicit ":column" part. Line-only + /// jumps select the whole target line; column jumps place the caret at + /// the column so the entered position is never discarded. + let hasExplicitColumn: Bool + + init(line: Int, column: Int, hasExplicitColumn: Bool = false) { + self.line = line + self.column = column + self.hasExplicitColumn = hasExplicitColumn + } + + /// Parses "120", "120:35", or whitespace-padded equivalents. Empty input, + /// non-numeric text, multiple colons, zero, and negative values are all + /// invalid and yield `nil`, making the jump a no-op. + static func parse(_ text: String) -> GoToLineInput? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 1 || parts.count == 2 else { return nil } + guard let line = oneBasedNumber(in: parts[0]) else { return nil } + let hasExplicitColumn = parts.count == 2 + guard let column = hasExplicitColumn ? oneBasedNumber(in: parts[1]) : 1 else { + return nil + } + return GoToLineInput(line: line - 1, column: column - 1, hasExplicitColumn: hasExplicitColumn) + } + + /// Converges 0-based line and column into the given document content: + /// an out-of-range line collapses to the last line, an out-of-range + /// column to the end of that line's content (excluding its terminator), + /// negatives to the origin. An empty document only ever addresses the + /// document start. Lines break on LF, CRLF, and CR — the same + /// terminators the editor's `TextLineIndex` recognizes — and a trailing + /// terminator yields a final empty line. Callers must re-converge with + /// the live document text right before jumping; line counts are never + /// cached. + static func clamped( + line: Int, + column: Int, + hasExplicitColumn: Bool = false, + in content: String + ) -> GoToLineInput { + let text = content as NSString + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or ran past the last content line + // and converge onto this final line. + return GoToLineInput( + line: lineIndex, + column: min(max(column, 0), scan - lineStart), + hasExplicitColumn: hasExplicitColumn + ) + } + lineIndex += 1 + lineStart = scan + terminatorLength + } + } + + private static func oneBasedNumber(in part: Substring) -> Int? { + guard let value = Int(part.trimmingCharacters(in: .whitespaces)), value >= 1 else { + return nil + } + return value + } +} diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift new file mode 100644 index 000000000..699bf9edc --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Editor selection computation for a Go to Line navigation target. +/// Line-only targets select the whole target line's content with the line +/// terminator excluded (LF, CRLF, or CR); column targets place a zero-length +/// caret at the converged column so an explicitly entered column is never +/// discarded. Line breaks follow the same LF/CRLF/CR rules as +/// `TextLineIndex` and `GoToLineInput.clamped`, keeping jump targets, +/// gutter numbering, and the status bar caret on one line-index definition. +enum GoToLineSelection { + static func targetRange( + line: Int, + utf16Column: Int, + selectsWholeLine: Bool, + in text: NSString + ) -> NSRange { + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + var contentEnd = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + contentEnd = scan + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or converge onto the final line. + break + } + lineIndex += 1 + lineStart = scan + terminatorLength + } + if selectsWholeLine { + return NSRange(location: lineStart, length: contentEnd - lineStart) + } + let location = min(contentEnd, lineStart + max(utf16Column, 0)) + return NSRange(location: location, length: 0) + } +} diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755d9fb37..a8f4d624e 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,6 +12,9 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int + /// Select the whole target line on arrival (Go to Line); symbol and find + /// navigation keep a zero-length caret. + var selectsWholeLine: Bool = false } struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 521e44df7..a19e9868e 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -38,6 +38,7 @@ enum LitheCommandCatalog { command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), command("replace-in-file", "Replace in File", "Replace within the active editor", .navigation, "r", [.command]), + command("go-to-line", "Go to Line", "Jump to a line and column in the active editor", .navigation, "l", [.command]), command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 4cf06a432..7949535c9 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -81,6 +81,7 @@ enum LitheActionRegistry { action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, action("replace-in-file", model: model) { model.showReplaceBar() }, + action("go-to-line", model: model) { model.showGoToLine() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index f26492a13..4547e6b06 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -395,6 +395,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLine() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in @@ -1318,17 +1319,14 @@ struct CodeEditorView: NSViewRepresentable { appliedNavigationTargetID = target.id let text = textView.string as NSString - var lineStart = 0 - var currentLine = 0 - while currentLine < target.line, lineStart < text.length { - let range = text.lineRange(for: NSRange(location: lineStart, length: 0)) - lineStart = NSMaxRange(range) - currentLine += 1 - } - let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) - let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) - textView.setSelectedRange(NSRange(location: location, length: 0)) - textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + let selection = GoToLineSelection.targetRange( + line: target.line, + utf16Column: target.utf16Column, + selectsWholeLine: target.selectsWholeLine, + in: text + ) + textView.setSelectedRange(selection) + textView.scrollRangeToVisible(selection) textView.window?.makeFirstResponder(textView) scheduleCaretUpdate() } @@ -1489,6 +1487,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onGoToImplementation: (() -> Void)? var onFindUsages: (() -> Void)? var onFindRequested: (() -> Void)? + var onGoToLineRequested: (() -> Void)? var onFindNextRequested: (() -> Void)? var onFindPreviousRequested: (() -> Void)? var onFindStateChange: ((Int, Int) -> Void)? @@ -2862,6 +2861,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } let menu = super.menu(for: event) ?? NSMenu() + let goToLineItem = NSMenuItem( + title: NSLocalizedString("Go to Line…", comment: "Context menu item that opens the go-to-line dialog"), + action: #selector(goToLineFromMenu), + keyEquivalent: "" + ) + goToLineItem.target = self + menu.insertItem(goToLineItem, at: 0) + menu.insertItem(.separator(), at: 1) let languageItems = languageContextMenuItems() guard !languageItems.isEmpty else { return menu } menu.insertItem(.separator(), at: 0) @@ -2892,6 +2899,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { onGoToDefinition?() } + @objc private func goToLineFromMenu() { + onGoToLineRequested?() + } + @objc private func showQuickDocumentationFromMenu() { let position = languageServerPosition(at: selectedRange().location) onQuickDocumentation?(position.line, position.utf16Column) diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 1b5999b8f..3df8514f8 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -94,6 +94,7 @@ struct EditorAreaView: View { } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .background(GoToLineDialogPresenter()) .onChange(of: model.openDocuments.map(\.id)) { ids in if let splitDocumentID, !ids.contains(splitDocumentID) { self.splitDocumentID = nil diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift new file mode 100644 index 000000000..8694dc9f2 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -0,0 +1,223 @@ +import AppKit +import SwiftUI + +/// “Go to Line:Column”dialog: a small floating window with a single +/// `[Line] [:column]:` input that accepts a 1-based line or line:column, +/// prefilled with the current caret position and fully selected. Return or +/// OK jumps, Esc or Cancel dismisses without side effects. Presentation is +/// a view-layer capability: visibility state lives in `EditorChromeModel` +/// and the jump itself goes through the existing `AppModel` navigation path. +@MainActor +enum GoToLineDialog { + private static let okResponse = NSApplication.ModalResponse(rawValue: 1) + private static let cancelResponse = NSApplication.ModalResponse(rawValue: 0) + /// Both the workbench and the standalone editor window host a presenter + /// observing the same chrome flag; this keeps only one modal alive. + private static var isPresented = false + + /// Present the dialog modally over the editor window; on OK, parse the + /// input and jump. Invalid input keeps Return from jumping. + static func present(model: AppModel) { + guard !isPresented, model.activeDocument != nil else { return } + isPresented = true + defer { isPresented = false } + model.showGoToLine() + + let coordinator = DialogCoordinator() + coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } + coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } + let panel = makePanel( + coordinator: coordinator, + appearance: model.settings.themePreference.windowAppearance + ) + configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) + center(panel: panel) + panel.makeKeyAndOrderFront(nil) + if let field = coordinator.field { + panel.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + let response = NSApp.runModal(for: panel) + panel.orderOut(nil) + + model.hideGoToLine() + if response == okResponse, let input = coordinator.confirmedText { + model.goToLine(input) + } + } + + /// Prefill mirrors the status bar's 1-based line:column display. + private static func initialValue(for model: AppModel) -> String { + let caret = model.editorChrome.caret + return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" + } + + private static func makePanel(coordinator: DialogCoordinator, appearance: NSAppearance?) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = String(localized: "Go to Line:Column") + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.delegate = coordinator + // Follow the same theme preference as the workbench windows; without + // this the panel falls back to the system appearance and renders + // light inside a dark-themed editor. + panel.appearance = appearance + return panel + } + + private static func configureContent( + panel: NSPanel, + coordinator: DialogCoordinator, + initialValue: String + ) { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 96)) + + let label = NSTextField(labelWithString: String(localized: "[Line] [:column]:")) + label.font = .systemFont(ofSize: 13) + label.sizeToFit() + label.frame.origin = NSPoint(x: 16, y: 50) + content.addSubview(label) + + let field = NSTextField(frame: NSRect( + x: label.frame.maxX + 8, + y: 48, + width: 340 - label.frame.width - 16 - 8 - 16, + height: 24 + )) + field.stringValue = initialValue + field.font = .systemFont(ofSize: 13) + field.delegate = coordinator + field.target = coordinator + field.action = #selector(DialogCoordinator.confirmFromField) + coordinator.field = field + content.addSubview(field) + + let cancelButton = NSButton( + title: String(localized: "Cancel"), + target: coordinator, + action: #selector(DialogCoordinator.cancelFromButton) + ) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + cancelButton.frame = NSRect(x: 340 - 16 - 78 - 10 - 78, y: 12, width: 78, height: 30) + content.addSubview(cancelButton) + + let okButton = NSButton( + title: String(localized: "OK"), + target: coordinator, + action: #selector(DialogCoordinator.confirmFromButton) + ) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.frame = NSRect(x: 340 - 16 - 78, y: 12, width: 78, height: 30) + okButton.isEnabled = GoToLineInput.parse(initialValue) != nil + coordinator.okButton = okButton + content.addSubview(okButton) + + panel.contentView = content + } + + /// Prefer centering over the editor window so the jump origin stays visible. + private static func center(panel: NSPanel) { + let size = panel.frame.size + if let keyWindow = NSApp.keyWindow, keyWindow !== panel { + panel.setFrameOrigin( + NSPoint( + x: keyWindow.frame.midX - size.width / 2, + y: keyWindow.frame.midY - size.height / 2 + ) + ) + } else { + panel.center() + } + } +} + +@MainActor +private final class DialogCoordinator: NSObject, NSWindowDelegate, NSTextFieldDelegate { + weak var field: NSTextField? + weak var okButton: NSButton? + private(set) var confirmedText: String? + var onConfirm: (() -> Void)? + var onCancel: (() -> Void)? + + func windowShouldClose(_ sender: NSWindow) -> Bool { + cancel() + return true + } + + /// Gray out OK while the input is not a valid line or line:column. + func controlTextDidChange(_ notification: Notification) { + guard let field else { return } + okButton?.isEnabled = GoToLineInput.parse(field.stringValue) != nil + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + confirmFromField() + return true + case #selector(NSResponder.cancelOperation(_:)): + cancel() + return true + default: + return false + } + } + + @objc func confirmFromField() { + confirm() + } + + @objc func confirmFromButton() { + confirm() + } + + @objc func cancelFromButton() { + cancel() + } + + private func confirm() { + guard let field, + GoToLineInput.parse(field.stringValue) != nil else { return } + confirmedText = field.stringValue + onConfirm?() + } + + private func cancel() { + onCancel?() + } +} + +/// Presents the dialog when the chrome flag flips on, so every entry point +/// (menu, context menu, status bar, Cmd+L) funnels through the same state. +/// The presentation is deferred one runloop hop so it never runs inside a +/// SwiftUI view update. +struct GoToLineDialogPresenter: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @State private var isPresenting = false + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: chrome.isGoToLineVisible) { isVisible in + guard isVisible, !isPresenting else { return } + isPresenting = true + DispatchQueue.main.async { [model] in + defer { isPresenting = false } + GoToLineDialog.present(model: model) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 1b0e80976..505d858a4 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -14,6 +14,7 @@ struct StandaloneEditorView: View { content } .background(LitheTheme.editor) + .background(GoToLineDialogPresenter()) .confirmationDialog( "Save changes before closing?", isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 1dc9a7662..7c35c8537 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,11 +1,22 @@ import SwiftUI +/// Status bar line:column indicator. Visually unchanged from the plain text +/// label; clicking opens the Go to Line dialog (a no-op without an active +/// document). struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel + let onShowGoToLine: () -> Void var body: some View { - Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() + Button { + onShowGoToLine() + } label: { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } + .buttonStyle(.plain) + .lithePointer() + .help("Go to Line…") } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619be..10eb0703d 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1063,7 +1063,9 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLine() + } Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -1084,7 +1086,9 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLine() + } MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 0bd6773ab..3e5b039f0 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -118,4 +118,33 @@ struct EditorChromeModelTests { ) #expect(chrome.findReplaceText == "bar") } + + @Test + func goToLineDialogAndFindBarAreMutuallyExclusive() { + // The find bar and the go-to-line dialog are mutually exclusive: + // opening either dismisses the other. + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setGoToLineVisible(true) + #expect(chrome.isGoToLineVisible) + #expect(!chrome.isFindBarVisible) + + chrome.setFindBarVisible(true) + #expect(chrome.isFindBarVisible) + #expect(!chrome.isGoToLineVisible) + + chrome.setGoToLineVisible(false) + #expect(!chrome.isGoToLineVisible) + #expect(chrome.isFindBarVisible) + } + + @Test + func resetClosesGoToLineBar() { + let chrome = EditorChromeModel() + chrome.setGoToLineVisible(true) + + chrome.reset() + + #expect(!chrome.isGoToLineVisible) + } } diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift new file mode 100644 index 000000000..6a3a224df --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +@testable import Lithe + +struct GoToLineInputTests { + @Test + func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { + // "120" means 1-based line 120, line start; converted to 0-based here. + #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func parsesLineAndColumnInput() { + #expect( + GoToLineInput.parse("120:35") + == GoToLineInput(line: 119, column: 34, hasExplicitColumn: true) + ) + } + + @Test + func marksExplicitColumnOnlyForColonInput() { + // 只有显式输入了列号才标记 hasExplicitColumn:行号跳转整行选中, + // 行:列跳转把 caret 放到该列 + #expect(GoToLineInput.parse("120")?.hasExplicitColumn == false) + #expect(GoToLineInput.parse("120:35")?.hasExplicitColumn == true) + #expect(GoToLineInput.parse(" 120 : 35 ")?.hasExplicitColumn == true) + } + + @Test + func toleratesWhitespaceAroundAndBetweenNumbers() { + #expect(GoToLineInput.parse(" 120 ") == GoToLineInput(line: 119, column: 0)) + #expect( + GoToLineInput.parse("12 : 34") + == GoToLineInput(line: 11, column: 33, hasExplicitColumn: true) + ) + } + + @Test + func rejectsEmptyNonNumericAndMultiColonInput() { + #expect(GoToLineInput.parse("") == nil) + #expect(GoToLineInput.parse(" ") == nil) + #expect(GoToLineInput.parse("abc") == nil) + #expect(GoToLineInput.parse("12abc") == nil) + #expect(GoToLineInput.parse("1:2:3") == nil) + #expect(GoToLineInput.parse("120:") == nil) + #expect(GoToLineInput.parse(":35") == nil) + } + + @Test + func rejectsZeroAndNegativeNumbers() { + // In 1-based input, zero and negatives are invalid values. + #expect(GoToLineInput.parse("0") == nil) + #expect(GoToLineInput.parse("-1") == nil) + #expect(GoToLineInput.parse("0:5") == nil) + #expect(GoToLineInput.parse("5:0") == nil) + #expect(GoToLineInput.parse("5:-2") == nil) + } + + @Test + func keepsInBoundsLineAndColumnUnchanged() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 2, in: content) == GoToLineInput(line: 0, column: 2)) + #expect(GoToLineInput.clamped(line: 2, column: 4, in: content) == GoToLineInput(line: 2, column: 4)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 99, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsOutOfRangeColumnToLineEnd() { + // Columns are counted in UTF-16 units, matching the editor caret's + // utf16Column convention. + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) + } + + @Test + func clampsNegativeValuesToDocumentStart() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: -3, column: -1, in: content) == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsAnyInputInEmptyDocumentToOrigin() { + // An empty document (0 lines) only ever addresses the document start. + #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsToTrailingEmptyLineAfterFinalNewline() { + // "a\n" has an addressable second line (the trailing empty line). + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) + } + + @Test + func clampsLineAndColumnInCRLFContent() { + // CRLF 终止符不计入上一行的列上限 + let content = "first\r\nsecond\r\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 5)) + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 6)) + #expect(GoToLineInput.clamped(line: 2, column: 1, in: content) == GoToLineInput(line: 2, column: 1)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsLineAndColumnInCRonlyContent() { + // CR-only 换行与编辑器 TextLineIndex 的行索引规则一致 + let content = "a\rb" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 1)) + #expect(GoToLineInput.clamped(line: 1, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + } + + @Test + func preservesExplicitColumnThroughClamping() { + // 收敛不改变显式列号标记 + let parsed = GoToLineInput.parse("99:2") + // 与 AppModel.goToLine 一致:把解析出的显式列号标记一并传入收敛 + let clamped = parsed.map { + GoToLineInput.clamped( + line: $0.line, + column: $0.column, + hasExplicitColumn: $0.hasExplicitColumn, + in: "a\nb" + ) + } + #expect(clamped?.hasExplicitColumn == true) + #expect(clamped == GoToLineInput(line: 1, column: 1, hasExplicitColumn: true)) + } + + @Test + func clampsColumnUsingUTF16LengthOfEmojiLine() { + // An emoji spans two UTF-16 units, so convergence counts UTF-16 + // length rather than character count. + let content = "a\u{1F600}b" + #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) + #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) + #expect(GoToLineInput.clamped(line: 0, column: 5, in: content) == GoToLineInput(line: 0, column: 4)) + } +} diff --git a/macos/Tests/LitheTests/GoToLineSelectionTests.swift b/macos/Tests/LitheTests/GoToLineSelectionTests.swift new file mode 100644 index 000000000..2baeb32a6 --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineSelectionTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import Lithe + +/// 回归测试:Go to Line 跳转后编辑器的最终 caret/selection 范围。 +/// 只输入行号时整行选中(行尾终止符不计入选区);显式输入列号时 +/// caret 落在该列;行索引遵循 LF、CRLF、CR 三种终止符规则。 +struct GoToLineSelectionTests { + private let content = "first\nsecond line\nthird" as NSString + + @Test + func placesZeroLengthCaretAtExplicitColumn() { + // "120:35" 类输入:caret 落在第 2 行(0-based 1)第 4 列 + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 4, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 10, length: 0)) + } + + @Test + func selectsWholeLineContentWithoutTerminator() { + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + #expect(range == NSRange(location: 0, length: 5)) + } + + @Test + func clampsColumnBeyondLineEndToLastColumn() { + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 99, + selectsWholeLine: false, + in: content + ) + // "second line" 长 11,行起点 6 → caret 在行尾(UTF-16 位置 17) + #expect(range == NSRange(location: 17, length: 0)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let range = GoToLineSelection.targetRange( + line: 99, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + // 最后一行 "third" 从 18 开始,长 5 + #expect(range == NSRange(location: 18, length: 5)) + } + + @Test + func selectsWholeLineInCRLFContentWithoutCarriageReturn() { + // CRLF 文件的整行选区不能把 \r 带进来 + let crlf = "ab\r\ncd" as NSString + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: crlf + ) + #expect(range == NSRange(location: 0, length: 2)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 99, + selectsWholeLine: false, + in: crlf + ) + // caret 收敛到行内容末尾(\r 之前) + #expect(caret == NSRange(location: 2, length: 0)) + } + + @Test + func indexesCRonlyContentByLine() { + // CR-only 换行同样按行定位 + let crOnly = "a\rb" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 1, + utf16Column: 0, + selectsWholeLine: true, + in: crOnly + ) + #expect(wholeLine == NSRange(location: 2, length: 1)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: false, + in: crOnly + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func clampsAnyTargetInEmptyDocumentToOrigin() { + let empty = "" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: true, + in: empty + ) + #expect(wholeLine == NSRange(location: 0, length: 0)) + + let caret = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: false, + in: empty + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func trailingNewlineYieldsEmptyFinalLineSelection() { + // "a\n" 存在可定位的第 2 行(末尾空行),整行选区为零长度 + let trailing = "a\n" as NSString + let range = GoToLineSelection.targetRange( + line: 9, + utf16Column: 0, + selectsWholeLine: true, + in: trailing + ) + #expect(range == NSRange(location: 2, length: 0)) + } + + @Test + func clampsNegativeLineAndColumnToOrigin() { + let range = GoToLineSelection.targetRange( + line: -3, + utf16Column: -1, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 0, length: 0)) + } +} diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 5f03ede0a..26dc2506a 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 32) + #expect(commands.count == 33) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in