diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 95746d0f..83865c8a 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -10,6 +10,7 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var app @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.locale) private var locale @State private var window: WindowModel @State private var layoutWidth = Metrics.Window.defaultWidth @State private var automaticQuickOpenReady = false @@ -74,7 +75,10 @@ struct RootView: View { .background { FullHeightWindowContent() } .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { layoutWidth = $0 } .toolbarBackground(.hidden, for: .windowToolbar) - .navigationTitle(WindowTitleCatalog.standard(hosts: app.hostLibrary).title(in: window)) + .navigationTitle( + WindowTitleCatalog.standard(hosts: app.hostLibrary) + .title(in: window, locale: locale) + ) // The window's only toolbar. Creation on the left, one Find on the // right, both following the content in front rather than the sidebar — // see `WindowToolbarLeadingActions` and `WindowModel.findKind`. diff --git a/Termind/Application/Commands/AppCommandRegistry.swift b/Termind/Application/Commands/AppCommandRegistry.swift index b73c7f1f..5d18c5c1 100644 --- a/Termind/Application/Commands/AppCommandRegistry.swift +++ b/Termind/Application/Commands/AppCommandRegistry.swift @@ -119,7 +119,7 @@ final class AppCommandRegistry { VaultPage.allCases.map { page in AppCommandDescriptor( id: "vault.\(page.rawValue)", - title: page.title, + title: page.title(), subtitle: String(localized: "Vault"), symbol: page.symbol, section: .navigation, diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index dcea32cd..8f638573 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -264,6 +264,7 @@ private struct AssistantConversationList: View { typealias Scope = AssistantLibraryModel.SearchScope @Environment(WindowModel.self) private var window + @Environment(\.locale) private var locale @AppStorage(SettingsKey.pinnedChatsSectionExpanded) private var pinnedExpanded = true @AppStorage(SettingsKey.chatsSectionExpanded) private var chatsExpanded = true @State private var deleteTarget: ConversationModel? @@ -334,6 +335,7 @@ private struct AssistantConversationList: View { // object of type WindowModel found". The list is handed the same // reference so it can put it back into each row's hosting view. let window = window + let locale = locale return AssistantConversationNativeList( items: activeItems, @@ -351,7 +353,7 @@ private struct AssistantConversationList: View { ) window.dragging = nil }, - blankAreaMenu: { Self.blankAreaMenu(in: window) } + blankAreaMenu: { Self.blankAreaMenu(in: window, locale: locale) } ) { item in renderedItem(item, window: window) } @@ -360,13 +362,14 @@ private struct AssistantConversationList: View { private var archivedList: some View { let window = window + let locale = locale return AssistantConversationNativeList( items: archivedItems, window: window, topInset: Metrics.Space.m, bottomInset: Metrics.Space.m, - blankAreaMenu: { Self.blankAreaMenu(in: window) } + blankAreaMenu: { Self.blankAreaMenu(in: window, locale: locale) } ) { item in renderedItem(item, window: window) .frame(maxWidth: archiveMaxWidth ?? .infinity) @@ -382,13 +385,17 @@ private struct AssistantConversationList: View { /// /// Static, and given the window rather than reading it: AppKit asks for this /// when the pointer is already down, long after the body that built the list. - private static func blankAreaMenu(in window: WindowModel) -> [NativeMenuEntry] { + private static func blankAreaMenu( + in window: WindowModel, locale: Locale + ) -> [NativeMenuEntry] { [ - NativeMenuEntry(title: String(localized: "New Chat")) { + NativeMenuEntry( + title: AppLocalization.string("New Chat", locale: locale) + ) { window.openNewAssistantChat() }, NativeMenuEntry( - title: String(localized: "Show Archive"), + title: AppLocalization.string("Show Archive", locale: locale), isOn: window.isAssistantArchivePresented ) { window.showAssistantArchive() }, ] @@ -1131,6 +1138,7 @@ struct AssistantConversationRow: View { @Environment(WindowModel.self) private var window @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.locale) private var locale @State private var hovering = false @FocusState private var titleFocused: Bool @@ -1182,7 +1190,7 @@ struct AssistantConversationRow: View { private var contextMenu: [NativeMenuEntry] { let managementDisabled = conversation.isStreaming || conversation.isCompacting var entries: [NativeMenuEntry] = [ - NativeMenuEntry(title: String(localized: "Open Full Screen")) { + NativeMenuEntry(title: localized("Open Full Screen")) { window.openAssistantFullScreen(conversation.id) }, .separator, @@ -1191,18 +1199,18 @@ struct AssistantConversationRow: View { entries.append( NativeMenuEntry( title: conversation.isPinned - ? String(localized: "Unpin") : String(localized: "Pin"), + ? localized("Unpin") : localized("Pin"), isEnabled: !managementDisabled ) { window.toggleAssistantPin(conversation.id) } ) } entries.append( - NativeMenuEntry(title: String(localized: "Rename"), action: onBeginRename) + NativeMenuEntry(title: localized("Rename"), action: onBeginRename) ) entries.append( NativeMenuEntry( title: conversation.isArchived - ? String(localized: "Unarchive") : String(localized: "Archive"), + ? localized("Unarchive") : localized("Archive"), isEnabled: conversation.isArchived || !managementDisabled ) { window.setAssistantArchived( @@ -1212,7 +1220,7 @@ struct AssistantConversationRow: View { ) entries.append(.separator) entries.append( - NativeMenuEntry(title: String(localized: "Delete"), isDestructive: true) { + NativeMenuEntry(title: localized("Delete"), isDestructive: true) { onDelete() } ) @@ -1262,11 +1270,15 @@ struct AssistantConversationRow: View { .buttonStyle(.plain) .help(Text( conversation.isArchived - ? String(localized: "Unarchive") : String(localized: "Archive") + ? localized("Unarchive") : localized("Archive") )) } } } + + private func localized(_ key: String.LocalizationValue) -> String { + AppLocalization.string(key, locale: locale) + } } /// The second pinned destination's main conversation surface. diff --git a/Termind/Features/HostChooser/HostChooserView.swift b/Termind/Features/HostChooser/HostChooserView.swift index 06b99d70..a7ca51ed 100644 --- a/Termind/Features/HostChooser/HostChooserView.swift +++ b/Termind/Features/HostChooser/HostChooserView.swift @@ -31,11 +31,11 @@ nonisolated enum HostConnectionProtocol: String, CaseIterable, Identifiable, Sen nonisolated var id: Self { self } - var displayName: String { + func displayName(locale: Locale = .current) -> String { switch self { - case .ssh: String(localized: "SSH") - case .sftp: String(localized: "SFTP") - case .serial: String(localized: "Serial") + case .ssh: AppLocalization.string("SSH", locale: locale) + case .sftp: AppLocalization.string("SFTP", locale: locale) + case .serial: AppLocalization.string("Serial", locale: locale) } } @@ -159,6 +159,7 @@ struct HostChooserView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.dismiss) private var dismiss + @Environment(\.locale) private var locale @State private var chooser: ManagedChooserState @State private var connectionProtocol = HostConnectionProtocol.ssh @State private var serialConfiguration = SerialConnectionConfiguration() @@ -260,7 +261,7 @@ struct HostChooserView: View { private var connectionProtocolPicker: some View { Picker("Connection Protocol", selection: $connectionProtocol) { ForEach(availableConnectionProtocols) { connectionProtocol in - Text(connectionProtocol.displayName).tag(connectionProtocol) + Text(connectionProtocol.displayName(locale: locale)).tag(connectionProtocol) } } .pickerStyle(.segmented) @@ -390,12 +391,16 @@ struct HostChooserView: View { private var footer: some View { HStack { if purpose.allowsMultiple, connectionProtocol != .serial { - Text("\(chooser.selected.count) selected") + Text( + AppLocalization.string( + "\(chooser.selected.count) selected", locale: locale + ) + ) .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() - Button(allResultsChecked ? "Deselect All" : "Select All") { + Button(selectAllLabel) { toggleAllResults() } .disabled(results.isEmpty) @@ -463,11 +468,15 @@ struct HostChooserView: View { let path = serialConfiguration.normalizedDevicePath var info = stat() guard lstat(path, &info) == 0 else { - serialError = String(localized: "The device path does not exist.") + serialError = AppLocalization.string( + "The device path does not exist.", locale: locale + ) return } guard (info.st_mode & S_IFMT) == S_IFCHR else { - serialError = String(localized: "The device path is not a character device.") + serialError = AppLocalization.string( + "The device path is not a character device.", locale: locale + ) return } serialConfiguration.devicePath = path @@ -546,12 +555,28 @@ struct HostChooserView: View { private var workspaceHelp: Text { if chooser.selected.count < 2 { - Text("Select at least two hosts. A Workspace can contain one or more hosts.") + Text( + AppLocalization.string( + "Select at least two hosts. A Workspace can contain one or more hosts.", + locale: locale + ) + ) } else { - Text("Open \(chooser.selected.count) hosts as panes in one workspace.") + Text( + AppLocalization.string( + "Open \(chooser.selected.count) hosts as panes in one workspace.", + locale: locale + ) + ) } } + private var selectAllLabel: String { + AppLocalization.string( + allResultsChecked ? "Deselect All" : "Select All", locale: locale + ) + } + private func move(_ delta: Int) -> KeyPress.Result { guard !results.isEmpty else { return .ignored } chooser.moveCursor(by: delta, through: results.map(\.id)) diff --git a/Termind/Features/Shell/WindowRoute.swift b/Termind/Features/Shell/WindowRoute.swift index 3578cb62..0406589d 100644 --- a/Termind/Features/Shell/WindowRoute.swift +++ b/Termind/Features/Shell/WindowRoute.swift @@ -19,12 +19,12 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab } } - var title: String { + func title(locale: Locale = .current) -> String { switch self { - case .welcome: String(localized: "Welcome") - case .hosts: String(localized: "Hosts") - case .keychain: String(localized: "Keychain") - case .snippets: String(localized: "Snippets") + case .welcome: AppLocalization.string("Welcome", locale: locale) + case .hosts: AppLocalization.string("Hosts", locale: locale) + case .keychain: AppLocalization.string("Keychain", locale: locale) + case .snippets: AppLocalization.string("Snippets", locale: locale) } } diff --git a/Termind/Features/Shell/WindowTitle.swift b/Termind/Features/Shell/WindowTitle.swift index 495c31f1..7dd1c805 100644 --- a/Termind/Features/Shell/WindowTitle.swift +++ b/Termind/Features/Shell/WindowTitle.swift @@ -10,17 +10,19 @@ import Foundation /// window only asks, and gains nothing to update when a new destination is added. protocol WindowTitleProviding { /// nil when the destination in front is not this provider's to name. - func title(in window: WindowModel) -> String? + func title(in window: WindowModel, locale: Locale) -> String? } struct AnyWindowTitleProvider: WindowTitleProviding { - private let resolve: (WindowModel) -> String? + private let resolve: (WindowModel, Locale) -> String? init(_ provider: P) { - resolve = provider.title(in:) + resolve = provider.title(in:locale:) } - func title(in window: WindowModel) -> String? { resolve(window) } + func title(in window: WindowModel, locale: Locale) -> String? { + resolve(window, locale) + } } struct WindowTitleCatalog { @@ -32,8 +34,8 @@ struct WindowTitleCatalog { /// The first provider that claims the destination. Empty when none does, /// which AppKit renders as no title at all rather than as a blank slot. - func title(in window: WindowModel) -> String { - providers.lazy.compactMap { $0.title(in: window) }.first ?? "" + func title(in window: WindowModel, locale: Locale) -> String { + providers.lazy.compactMap { $0.title(in: window, locale: locale) }.first ?? "" } static func standard(hosts: HostLibraryModel) -> Self { @@ -48,11 +50,12 @@ struct WindowTitleCatalog { /// Vault pages are named after themselves — except Welcome, which is the app's /// front door and so is named after the app. struct VaultWindowTitleProvider: WindowTitleProviding { - func title(in window: WindowModel) -> String? { + func title(in window: WindowModel, locale: Locale) -> String? { guard window.selectedDestination == .vault, let page = window.vaultDestination.page else { return nil } - return page == .welcome ? ApplicationIdentity.displayName : page.title + return page == .welcome + ? ApplicationIdentity.displayName : page.title(locale: locale) } } @@ -60,11 +63,13 @@ struct VaultWindowTitleProvider: WindowTitleProviding { /// where you are; which chat you are in is the part worth reading from across /// the desk. struct AssistantWindowTitleProvider: WindowTitleProviding { - func title(in window: WindowModel) -> String? { + func title(in window: WindowModel, locale: Locale) -> String? { guard window.selectedDestination == .assistant else { return nil } - if window.isAssistantArchivePresented { return String(localized: "Archived") } + if window.isAssistantArchivePresented { + return AppLocalization.string("Archived", locale: locale) + } return window.assistantLibrary.selectedConversation?.derivedTitle - ?? String(localized: "New Chat") + ?? AppLocalization.string("New Chat", locale: locale) } } @@ -72,8 +77,8 @@ struct AssistantWindowTitleProvider: WindowTitleProviding { struct WorkspaceWindowTitleProvider: WindowTitleProviding { let hosts: HostLibraryModel - func title(in window: WindowModel) -> String? { + func title(in window: WindowModel, locale: Locale) -> String? { guard let workspace = window.selectedWorkspace else { return nil } - return workspace.title(in: hosts) + return workspace.title(in: hosts, locale: locale) } } diff --git a/Termind/Features/Workspaces/Pane.swift b/Termind/Features/Workspaces/Pane.swift index 2533e111..11e5029c 100644 --- a/Termind/Features/Workspaces/Pane.swift +++ b/Termind/Features/Workspaces/Pane.swift @@ -287,9 +287,9 @@ final class Pane: Identifiable { /// Resolved against the store so a renamed host renames its workspaces — unless /// the user named this pane, in which case their word wins everywhere. - func title(in store: HostLibraryModel) -> String { + func title(in store: HostLibraryModel, locale: Locale = .current) -> String { if let customTitle { return customTitle } - return numbered(derivedTitle(in: store)) + return numbered(derivedTitle(in: store, locale: locale)) } /// The number only ever decorates a *derived* name. A name the user typed is @@ -299,16 +299,17 @@ final class Pane: Identifiable { duplicateIndex > 0 ? "\(base) (\(duplicateIndex))" : base } - private func derivedTitle(in store: HostLibraryModel) -> String { + private func derivedTitle(in store: HostLibraryModel, locale: Locale) -> String { switch content { case .sshSession(let sshSession): sshSession.title case .sftpSession(let sftpSession): - store.host(sftpSession.hostID)?.name ?? String(localized: "SFTP") + store.host(sftpSession.hostID)?.name + ?? AppLocalization.string("SFTP", locale: locale) case .serialSession(let serialSession): serialSession.title case .empty: - String(localized: "Empty") + AppLocalization.string("Empty", locale: locale) } } @@ -316,14 +317,14 @@ final class Pane: Identifiable { /// /// SFTP and SSH panes share Sessions, with their symbols distinguishing the /// protocol. The row therefore keeps the host name concise and unprefixed. - func sidebarTitle(in store: HostLibraryModel) -> String { + func sidebarTitle(in store: HostLibraryModel, locale: Locale = .current) -> String { if let customTitle { return customTitle } if case .sftpSession(let sftpSession) = content, let host = store.host(sftpSession.hostID) { return numbered(host.name) } - return numbered(derivedTitle(in: store)) + return numbered(derivedTitle(in: store, locale: locale)) } func subtitle(in store: HostLibraryModel) -> String? { diff --git a/Termind/Features/Workspaces/Workspace.swift b/Termind/Features/Workspaces/Workspace.swift index 52ef80b9..c59fa4b7 100644 --- a/Termind/Features/Workspaces/Workspace.swift +++ b/Termind/Features/Workspaces/Workspace.swift @@ -127,9 +127,11 @@ final class Workspace: Identifiable { /// (see `WindowModel.rename`), and one thing has one name. It also means a /// renamed workspace reads "Workspace" the moment it is split, since the name it /// was given went to the pane rather than to the group. - func title(in store: HostLibraryModel) -> String { - if isSplit { return customTitle ?? String(localized: "Workspace") } - return focusedPane.title(in: store) + func title(in store: HostLibraryModel, locale: Locale = .current) -> String { + if isSplit { + return customTitle ?? AppLocalization.string("Workspace", locale: locale) + } + return focusedPane.title(in: store, locale: locale) } func subtitle(in store: HostLibraryModel) -> String? { diff --git a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift index 1442ae9c..6f68d8e5 100644 --- a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift @@ -7,6 +7,7 @@ import UniformTypeIdentifiers struct WorkspaceNavigatorView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window + @Environment(\.locale) private var locale @AppStorage(SettingsKey.vaultSectionExpanded) private var vaultExpanded = true @AppStorage(SettingsKey.workspaceSectionExpanded) private var workspaceExpanded = true @@ -88,7 +89,7 @@ struct WorkspaceNavigatorView: View { && window.vaultDestination.page == page, // The page's own New verbs, so adding a host does not // require visiting Hosts first. - menu: { window.creationMenu(for: page) } + menu: { window.creationMenu(for: page, locale: locale) } ) { window.dragging = nil vaultExpanded = true @@ -279,7 +280,7 @@ struct WorkspaceNavigatorView: View { private func beginWorkspaceRename(_ workspace: Workspace) { guard workspace.isRenameable else { return } renamingWorkspaceID = workspace.id - workspaceRenameTitle = workspace.title(in: app.hostLibrary) + workspaceRenameTitle = workspace.title(in: app.hostLibrary, locale: locale) } private func commitWorkspaceRename(_ workspace: Workspace) { @@ -334,6 +335,7 @@ private struct WorkspaceNavigatorRow: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.locale) private var locale @State private var hovering = false @FocusState private var titleFocused: Bool @@ -355,7 +357,7 @@ private struct WorkspaceNavigatorRow: View { .onExitCommand(perform: onCancelRename) } else { VStack(alignment: .leading, spacing: 1) { - Text(workspace.title(in: app.hostLibrary)) + Text(workspace.title(in: app.hostLibrary, locale: locale)) .fontWeight(isSelected ? .semibold : .regular) .lineLimit(1) if let subtitle = workspace.subtitle(in: app.hostLibrary) { @@ -387,9 +389,11 @@ private struct WorkspaceNavigatorRow: View { window.dragging = .workspace(workspace.id) return NSItemProvider(object: workspace.id.uuidString as NSString) } - .help(Text(workspace.title(in: app.hostLibrary))) + .help(Text(workspace.title(in: app.hostLibrary, locale: locale))) .accessibilityElement(children: .combine) - .accessibilityLabel(Text(workspace.title(in: app.hostLibrary))) + .accessibilityLabel( + Text(workspace.title(in: app.hostLibrary, locale: locale)) + ) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) .onChange(of: isRenaming, initial: true) { if isRenaming { DispatchQueue.main.async { titleFocused = true } } @@ -424,11 +428,11 @@ private struct WorkspaceNavigatorRow: View { var entries: [NativeMenuEntry] = [] if workspace.isRenameable { entries.append( - NativeMenuEntry(title: String(localized: "Rename"), action: onBeginRename) + NativeMenuEntry(title: localized("Rename"), action: onBeginRename) ) if namedTitle != nil { entries.append( - NativeMenuEntry(title: String(localized: "Reset Name")) { + NativeMenuEntry(title: localized("Reset Name")) { window.rename(workspace.id, to: "") } ) @@ -441,7 +445,7 @@ private struct WorkspaceNavigatorRow: View { // detach from. if workspace.isSplit { entries.append( - NativeMenuEntry(title: String(localized: "Separate Panes")) { + NativeMenuEntry(title: localized("Separate Panes")) { window.separatePanes(in: workspace.id) } ) @@ -450,19 +454,19 @@ private struct WorkspaceNavigatorRow: View { if workspace.isCombinable { if workspace.isSplit { entries.append( - NativeMenuEntry(title: String(localized: "Duplicate in New Workspace")) { + NativeMenuEntry(title: localized("Duplicate in New Workspace")) { act { window.duplicateSelectedInNewWorkspace() } } ) } else { entries.append( NativeMenuEntry( - title: String(localized: "Duplicate"), + title: localized("Duplicate"), children: [ - NativeMenuEntry(title: String(localized: "In Current Workspace")) { + NativeMenuEntry(title: localized("In Current Workspace")) { act { window.duplicateSelected(axis: .horizontal) } }, - NativeMenuEntry(title: String(localized: "In New Workspace")) { + NativeMenuEntry(title: localized("In New Workspace")) { act { window.duplicateSelectedInNewWorkspace() } }, ] @@ -471,29 +475,29 @@ private struct WorkspaceNavigatorRow: View { } entries.append(.separator) entries.append( - NativeMenuEntry(title: String(localized: "Split Right")) { + NativeMenuEntry(title: localized("Split Right")) { act { window.splitSelected(axis: .horizontal) } } ) entries.append( - NativeMenuEntry(title: String(localized: "Split Down")) { + NativeMenuEntry(title: localized("Split Down")) { act { window.splitSelected(axis: .vertical) } } ) entries.append(.separator) } entries.append( - NativeMenuEntry(title: String(localized: "Close Workspace")) { + NativeMenuEntry(title: localized("Close Workspace")) { window.close(workspace.id) } ) entries.append( - NativeMenuEntry(title: String(localized: "Close Other Workspaces")) { + NativeMenuEntry(title: localized("Close Other Workspaces")) { window.closeOthers(than: workspace.id) } ) entries.append( - NativeMenuEntry(title: String(localized: "Close All Workspaces")) { + NativeMenuEntry(title: localized("Close All Workspaces")) { window.closeAll() } ) @@ -504,6 +508,10 @@ private struct WorkspaceNavigatorRow: View { workspace.isSplit ? workspace.customTitle : workspace.focusedPane.customTitle } + private func localized(_ key: String.LocalizationValue) -> String { + AppLocalization.string(key, locale: locale) + } + private func act(_ action: () -> Void) { window.select(workspace.id) action() diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 27da9671..b0466f97 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -638,7 +638,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%lld选中" + "value" : "已选择%lld项" } } } @@ -8225,7 +8225,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "固定" + "value" : "置顶" } } } @@ -8245,7 +8245,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "绑定" + "value" : "置顶" } } } @@ -13652,7 +13652,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "撤销" + "value" : "取消置顶" } } } diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index ddaeac96..450ec13d 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -464,6 +464,7 @@ struct WindowToolbarSearchSlot: View { /// Welcome and on a workspace that has no searchable pane in front. struct WindowToolbarFindField: View { @Environment(WindowModel.self) private var window + @Environment(\.locale) private var locale @State private var searchFieldOwner = NativeSearchFieldOwner() var body: some View { @@ -471,7 +472,7 @@ struct WindowToolbarFindField: View { if let context = window.findContext() { NativeSearchField( text: context.query, - placeholder: String(localized: "Search"), + placeholder: AppLocalization.string("Search", locale: locale), owner: searchFieldOwner, focusRequest: context.focusRequest, maintainsKeyboardFocus: window.isToolbarFindFocused, @@ -481,8 +482,10 @@ struct WindowToolbarFindField: View { onFocusChange: { window.isToolbarFindFocused = $0 } ) .frame(width: Metrics.Toolbar.searchFieldWidth) - .help(Text(String(localized: "Search"))) - .accessibilityLabel(Text(String(localized: "Search"))) + .help(Text(AppLocalization.string("Search", locale: locale))) + .accessibilityLabel( + Text(AppLocalization.string("Search", locale: locale)) + ) } else { // Zero width, full height. A toolbar with nothing in it is // shorter than one holding a search field, and the titlebar @@ -507,19 +510,26 @@ struct WindowToolbarFindField: View { /// something the page in front cannot show. struct WindowToolbarLeadingActions: View { @Environment(WindowModel.self) private var window + @Environment(\.locale) private var locale var body: some View { let actions = window.creationActions if actions.count == 1, let only = actions.first { Button(action: only.perform) { - Label(only.title, systemImage: "square.and.pencil") + Label( + only.localizedTitle(locale: locale), + systemImage: "square.and.pencil" + ) } - .help(Text(only.title)) + .help(Text(only.localizedTitle(locale: locale))) } else if !actions.isEmpty { Menu { ForEach(actions) { action in - Button(action.title, action: action.perform) + Button( + action.localizedTitle(locale: locale), + action: action.perform + ) } } label: { Label("New", systemImage: "square.and.pencil") @@ -535,17 +545,21 @@ struct WindowToolbarLeadingActions: View { /// One thing the titlebar's New control can make. struct WindowToolbarCreationAction: Identifiable { let id: String - let title: LocalizedStringKey - /// The same string for AppKit, which takes a `String` rather than a key. - let localizedTitle: String + private let titleKey: String.LocalizationValue let perform: () -> Void - init(id: String, title: String, perform: @escaping () -> Void) { + init( + id: String, title: String.LocalizationValue, + perform: @escaping () -> Void + ) { self.id = id - self.title = LocalizedStringKey(title) - self.localizedTitle = title + titleKey = title self.perform = perform } + + func localizedTitle(locale: Locale) -> String { + AppLocalization.string(titleKey, locale: locale) + } } // MARK: - Resolving the context @@ -614,9 +628,11 @@ extension WindowModel { } /// Those verbs as native menu entries, for the Navigator's own rows. - func creationMenu(for page: VaultPage) -> [NativeMenuEntry] { + func creationMenu(for page: VaultPage, locale: Locale) -> [NativeMenuEntry] { creationActions(for: page).map { action in - NativeMenuEntry(title: action.localizedTitle, action: action.perform) + NativeMenuEntry( + title: action.localizedTitle(locale: locale), action: action.perform + ) } } diff --git a/TermindTests/AppLanguageTests.swift b/TermindTests/AppLanguageTests.swift index 02660234..2d4e2305 100644 --- a/TermindTests/AppLanguageTests.swift +++ b/TermindTests/AppLanguageTests.swift @@ -103,6 +103,9 @@ struct AppLanguageTests { "Version": "版本", "Welcome": "欢迎", "Snippets": "片段", + "Pin": "置顶", + "Pinned": "置顶", + "Unpin": "取消置顶", "Approve for me": "帮我批准", "Full access": "完全访问权限", ] diff --git a/TermindTests/ManagedChooserTests.swift b/TermindTests/ManagedChooserTests.swift index cedc7244..b695adfc 100644 --- a/TermindTests/ManagedChooserTests.swift +++ b/TermindTests/ManagedChooserTests.swift @@ -78,4 +78,18 @@ struct ManagedChooserTests { #expect(HostChooserPurpose.batchStartup.allowsMultiple) #expect(HostChooserPurpose.batchStartup.showsWorkspaceChoice) } + + @Test + func batchStartupStringsResolveUsingThePresentedLocale() { + let chinese = Locale(identifier: "zh-Hans-CN") + let selectedCount = 2 + + #expect(HostConnectionProtocol.serial.displayName(locale: chinese) == "串口") + #expect(AppLocalization.string("Batch Startup", locale: chinese) == "批量启动") + #expect( + AppLocalization.string("\(selectedCount) selected", locale: chinese) + == "已选择2项" + ) + #expect(AppLocalization.string("Select All", locale: chinese) == "全部选择") + } } diff --git a/TermindTests/WindowToolbarFindTests.swift b/TermindTests/WindowToolbarFindTests.swift index 0e9daabe..41a813d3 100644 --- a/TermindTests/WindowToolbarFindTests.swift +++ b/TermindTests/WindowToolbarFindTests.swift @@ -143,6 +143,16 @@ struct WindowToolbarFindTests { #expect(window.creationActions.map(\.id) == ["snippet", "package"]) } + @Test + func creationActionsResolveUsingThePresentedLocale() throws { + let window = WindowModel() + window.openPage(.hosts) + + let host = try #require(window.creationActions.first { $0.id == "host" }) + #expect(host.localizedTitle(locale: Locale(identifier: "en")) == "New Host") + #expect(host.localizedTitle(locale: Locale(identifier: "zh-Hans-CN")) == "新建主机") + } + /// Each Vault page keeps its own query, and keeps it across a page switch — /// the field lives in the titlebar and outlives the page under it. @Test @@ -165,19 +175,22 @@ struct WindowToolbarFindTests { func titlebarNamesTheContentInFront() { let window = WindowModel() let title = VaultWindowTitleProvider() + let english = Locale(identifier: "en") + let chinese = Locale(identifier: "zh-Hans-CN") // Welcome is the app's front door, so it is named after the app. window.openPage(.welcome) - #expect(title.title(in: window) == ApplicationIdentity.displayName) + #expect(title.title(in: window, locale: chinese) == ApplicationIdentity.displayName) window.openPage(.hosts) - #expect(title.title(in: window) == VaultPage.hosts.title) + #expect(title.title(in: window, locale: english) == "Hosts") + #expect(title.title(in: window, locale: chinese) == "主机") window.openPage(.keychain) - #expect(title.title(in: window) == VaultPage.keychain.title) + #expect(title.title(in: window, locale: chinese) == "钥匙串") window.openPage(.snippets) - #expect(title.title(in: window) == VaultPage.snippets.title) + #expect(title.title(in: window, locale: chinese) == "片段") } /// Each provider answers only for its own destination, so the catalog can @@ -187,8 +200,9 @@ struct WindowToolbarFindTests { let window = WindowModel() window.selectAssistant() - #expect(VaultWindowTitleProvider().title(in: window) == nil) - #expect(AssistantWindowTitleProvider().title(in: window) != nil) + let locale = Locale(identifier: "zh-Hans-CN") + #expect(VaultWindowTitleProvider().title(in: window, locale: locale) == nil) + #expect(AssistantWindowTitleProvider().title(in: window, locale: locale) != nil) } /// The Assistant names the conversation, not the destination. @@ -200,15 +214,35 @@ struct WindowToolbarFindTests { store.insert(conversation) let window = WindowModel(assistantLibrary: AssistantLibraryModel(store: store)) let title = AssistantWindowTitleProvider() + let locale = Locale(identifier: "zh-Hans-CN") window.selectAssistant() - #expect(title.title(in: window) == String(localized: "New Chat")) + #expect(title.title(in: window, locale: locale) == "新建对话") window.selectAssistant(conversation.id) - #expect(title.title(in: window) == "Nginx rollout") + #expect(title.title(in: window, locale: locale) == "Nginx rollout") window.showAssistantArchive() - #expect(title.title(in: window) == String(localized: "Archived")) + #expect(title.title(in: window, locale: locale) == "已归档") + } + + @Test + func workspaceTitleLocalizesOnlyBuiltInFallbacks() { + let store = HostLibraryModel() + let locale = Locale(identifier: "zh-Hans-CN") + let first = Pane(.empty) + let second = Pane(.empty) + let workspace = Workspace(pane: first) + workspace.layout = .split( + .init( + axis: .horizontal, fraction: 0.5, + first: .leaf(first), second: .leaf(second) + ) + ) + + #expect(workspace.title(in: store, locale: locale) == "工作区") + workspace.customTitle = "Production" + #expect(workspace.title(in: store, locale: locale) == "Production") } /// A tag stops existing when its last host loses it, so a filter pinned to