diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9831a..98dd451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Keyboard-first translation window: ⌘1 / ⌘2 / ⌘3 switch between Translate, + Polish, and Summary, and ⌘R regenerates the current result. Each mode segment + shows its shortcut hint. - Markdown tables in results now render as a bordered grid instead of raw `| --- |` pipe-and-dash text. All other output stays as plain, selectable, copyable text. ### Changed +- The translation window's action button is now labelled "Run" (it runs the + selected mode) instead of always "Translate", and the "Summarize" mode is now + labelled "Summary". - Translation, polish, and summarize now preserve the source structure (line breaks, lists, headings) in their results. diff --git a/mac-app/Lumo/Localizable.xcstrings b/mac-app/Lumo/Localizable.xcstrings index aa9ec60..3a95a10 100644 --- a/mac-app/Lumo/Localizable.xcstrings +++ b/mac-app/Lumo/Localizable.xcstrings @@ -311,6 +311,26 @@ } } }, + "Run" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行" + } + } + } + }, + "Run (⌘↵)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行(⌘↵)" + } + } + } + }, "Search source or result" : { "localizations" : { "zh-Hans" : { diff --git a/mac-app/Lumo/Models/TranslationMode.swift b/mac-app/Lumo/Models/TranslationMode.swift index c678d2e..03e919d 100644 --- a/mac-app/Lumo/Models/TranslationMode.swift +++ b/mac-app/Lumo/Models/TranslationMode.swift @@ -6,13 +6,15 @@ enum TranslationMode: String, CaseIterable, Identifiable { case polish case summarize - var id: String { rawValue } + var id: String { + rawValue + } var title: String { switch self { case .translate: return String(localized: "Translate") case .polish: return String(localized: "Polish") - case .summarize: return String(localized: "Summarize") + case .summarize: return String(localized: "Summary") } } diff --git a/mac-app/Lumo/Views/TranslationView.swift b/mac-app/Lumo/Views/TranslationView.swift index 3205fee..358d1ba 100644 --- a/mac-app/Lumo/Views/TranslationView.swift +++ b/mac-app/Lumo/Views/TranslationView.swift @@ -28,11 +28,14 @@ struct TranslationView: View { HStack { Spacer() - Button("Translate") { model.translate() } + // Mode-agnostic label: ⌘↵ runs the selected action (translate / + // polish / summarize), so a fixed "Run" stays correct in every + // mode. The current mode is shown by the picker and result label. + Button("Run") { model.translate() } .buttonStyle(.bordered) .keyboardShortcut(.return, modifiers: .command) .disabled(translateDisabled) - .help("Translate (⌘↵)") + .help("Run (⌘↵)") } Divider() @@ -54,13 +57,22 @@ struct TranslationView: View { } .padding(16) // minWidth must fit the header toolbar (fixed-size segmented picker + - // history/pin/close icons, ~416pt). NSHostingController sizes the window - // to this minWidth, so anything narrower clips the trailing buttons past - // the rounded-glass mask, making them unclickable. + // history/pin/close icons). The segments carry " ⌘1/⌘2/⌘3" hints, so the + // picker is wider than its bare titles — at 440 the toolbar still leaves a + // small trailing margin (this is why "Summarize" was shortened to + // "Summary"). NSHostingController sizes the window to this minWidth, so + // anything narrower clips the trailing buttons past the rounded-glass mask, + // making them unclickable. Keep window.minSize in TranslationWindowController + // in sync with this value. .frame(minWidth: 440, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity, alignment: .topLeading) // Liquid Glass: the whole popup is a single floating glass slab over the // desktop. Controls inside stay flat (borderless) to avoid glass-on-glass. .glassEffect(in: RoundedRectangle(cornerRadius: GlassPanelMetrics.cornerRadius, style: .continuous)) + // Command-key shortcuts that the visible controls can't carry: ⌘1/⌘2/⌘3 + // (the segmented Picker has no per-segment shortcut hook) and ⌘R (kept + // chromeless by choice). Hidden buttons stay in the hierarchy, so the + // shortcuts fire even while the TextEditor holds focus. + .background(keyboardShortcuts) // Focus the input when the panel first appears and whenever it's reopened // (the hosting view is reused, so onAppear alone fires only once). Defer // to the next runloop tick: setting @FocusState synchronously during @@ -96,8 +108,12 @@ struct TranslationView: View { get: { model.mode }, set: { model.setMode($0) } )) { - ForEach(TranslationMode.allCases) { mode in - Label(mode.title, systemImage: mode.symbol).tag(mode) + // Append the ⌘-number hint to each segment so the shortcut is + // self-documenting — a segmented Picker can't host a per-segment + // .help(...). The number is the 1-based position in allCases, + // which is exactly what `keyboardShortcuts` binds ⌘1/⌘2/⌘3 to. + ForEach(Array(TranslationMode.allCases.enumerated()), id: \.element.id) { index, mode in + Label("\(mode.title) ⌘\(index + 1)", systemImage: mode.symbol).tag(mode) } } .labelsHidden() @@ -124,6 +140,24 @@ struct TranslationView: View { .buttonStyle(IconButtonStyle()) } + /// Off-screen buttons that own the window's command-key shortcuts. ⌘1/⌘2/⌘3 + /// switch mode by position in `allCases` (matching the hints in the picker + /// segments); ⌘R re-runs the current mode. `.hidden()` keeps them in the + /// view tree — required for the shortcuts to register — without drawing. + /// ⌘R reuses `translateDisabled`, so it's inert on empty input or mid-stream. + private var keyboardShortcuts: some View { + Group { + ForEach(Array(TranslationMode.allCases.enumerated()), id: \.element.id) { index, mode in + Button("") { model.setMode(mode) } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + } + Button("") { model.translate() } + .keyboardShortcut("r", modifiers: .command) + .disabled(translateDisabled) + } + .hidden() + } + /// Editable "Original" field — bare text on the glass (no fill or border), so /// it reads as one slab with the result area below rather than a box sitting /// on top. TextEditor carries a small built-in text inset; the negative