Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions mac-app/Lumo/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
6 changes: 4 additions & 2 deletions mac-app/Lumo/Models/TranslationMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
48 changes: 41 additions & 7 deletions mac-app/Lumo/Views/TranslationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading