Skip to content

Commit 8aff180

Browse files
committed
style: refine mac voice settings layout
1 parent b4fdd14 commit 8aff180

2 files changed

Lines changed: 219 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
1515
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
1616
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
17+
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
1718
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
1819
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
1920
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.

apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift

Lines changed: 218 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -251,67 +251,67 @@ struct VoiceWakeSettings: View {
251251

252252
private var triggerTable: some View {
253253
SettingsCardGroup("Trigger Words") {
254-
HStack {
255-
Text("Wake phrases")
256-
.font(.callout.weight(.semibold))
257-
Spacer()
258-
Button {
259-
self.addWord()
260-
} label: {
261-
Label("Add word", systemImage: "plus")
262-
}
263-
.disabled(self.triggerEntries
264-
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
254+
SettingsCardRow(
255+
title: "Wake phrases",
256+
subtitle: "Short phrases that start voice wake detection.")
257+
{
258+
HStack(spacing: 8) {
259+
Button {
260+
self.addWord()
261+
} label: {
262+
Label("Add word", systemImage: "plus")
263+
}
264+
.disabled(self.triggerEntries
265+
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
265266

266-
Button("Reset defaults") {
267-
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
268-
self.syncTriggerEntriesToState()
267+
Button("Reset") {
268+
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
269+
self.syncTriggerEntriesToState()
270+
}
269271
}
272+
.buttonStyle(.bordered)
270273
}
271-
.padding(.horizontal, 14)
272-
.padding(.top, 12)
273-
274-
VStack(spacing: 0) {
275-
ForEach(self.$triggerEntries) { $entry in
276-
HStack(spacing: 8) {
277-
TextField("Wake word", text: $entry.value)
278-
.textFieldStyle(.roundedBorder)
279-
.onSubmit {
280-
self.syncTriggerEntriesToState()
281-
}
282274

283-
Button {
284-
self.removeWord(id: entry.id)
285-
} label: {
286-
Image(systemName: "trash")
287-
}
288-
.buttonStyle(.borderless)
289-
.help("Remove trigger word")
290-
.frame(width: 24)
291-
}
292-
.padding(8)
275+
self.triggerPhraseRows
293276

294-
if entry.id != self.triggerEntries.last?.id {
295-
Divider()
277+
TriggerPhraseHelpRow()
278+
}
279+
}
280+
281+
private var triggerPhraseRows: some View {
282+
Group {
283+
if self.triggerEntries.isEmpty {
284+
HStack(spacing: 10) {
285+
Image(systemName: "text.badge.plus")
286+
.font(.callout)
287+
.foregroundStyle(.secondary)
288+
.frame(width: 22)
289+
Text("No wake phrases configured")
290+
.font(.callout.weight(.medium))
291+
.foregroundStyle(.secondary)
292+
Spacer()
293+
}
294+
.padding(.horizontal, 14)
295+
.padding(.vertical, 14)
296+
} else {
297+
VStack(spacing: 0) {
298+
ForEach(self.$triggerEntries) { $entry in
299+
TriggerPhraseRow(
300+
value: $entry.value,
301+
showsDivider: entry.id != self.triggerEntries.last?.id,
302+
onSubmit: {
303+
self.syncTriggerEntriesToState()
304+
},
305+
onRemove: {
306+
self.removeWord(id: entry.id)
307+
})
296308
}
297309
}
298310
}
299-
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
300-
.background(Color(nsColor: .textBackgroundColor))
301-
.clipShape(RoundedRectangle(cornerRadius: 6))
302-
.overlay(
303-
RoundedRectangle(cornerRadius: 6)
304-
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
305-
.padding(.horizontal, 14)
306-
307-
Text(
308-
"OpenClaw reacts when any trigger appears in a transcription. "
309-
+ "Keep them short to avoid false positives.")
310-
.font(.footnote)
311-
.foregroundStyle(.secondary)
312-
.fixedSize(horizontal: false, vertical: true)
313-
.padding(.horizontal, 14)
314-
.padding(.bottom, 12)
311+
}
312+
.overlay(alignment: .bottom) {
313+
Divider()
314+
.padding(.leading, 14)
315315
}
316316
}
317317

@@ -535,70 +535,82 @@ struct VoiceWakeSettings: View {
535535
.frame(width: self.controlWidth)
536536
}
537537

538-
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
539-
VStack(alignment: .leading, spacing: 8) {
540-
Text("Additional languages")
541-
.font(.footnote.weight(.semibold))
542-
ForEach(
543-
Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()),
544-
id: \.offset)
545-
{ idx, localeID in
546-
HStack(spacing: 8) {
547-
Picker("Extra \(idx + 1)", selection: Binding(
548-
get: { localeID },
549-
set: { newValue in
550-
guard self.state
551-
.voiceWakeAdditionalLocaleIDs.indices
552-
.contains(idx) else { return }
553-
self.state
554-
.voiceWakeAdditionalLocaleIDs[idx] =
555-
newValue
556-
})) {
557-
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
558-
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
559-
}
560-
}
561-
.labelsHidden()
562-
.frame(width: 220)
563-
564-
Button {
565-
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
566-
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
567-
} label: {
568-
Image(systemName: "trash")
569-
}
570-
.buttonStyle(.borderless)
571-
.help("Remove language")
572-
}
573-
}
574-
538+
SettingsCardRow(
539+
title: "Additional languages",
540+
subtitle: self.additionalLanguagesSubtitle,
541+
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
542+
{
543+
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
575544
Button {
576-
if let first = availableLocales.first {
577-
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
578-
}
545+
self.addAdditionalLocale()
579546
} label: {
580-
Label("Add language", systemImage: "plus")
547+
Label("Add", systemImage: "plus")
581548
}
549+
.buttonStyle(.bordered)
582550
.disabled(self.availableLocales.isEmpty)
583551
}
584-
.padding(.horizontal, 14)
585-
.padding(.bottom, 10)
586-
} else {
552+
}
553+
554+
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
555+
self.additionalLanguageRows
556+
}
557+
}
558+
}
559+
560+
private var additionalLanguagesSubtitle: String {
561+
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
562+
return "None configured."
563+
}
564+
return "Tried after the primary language."
565+
}
566+
567+
private var additionalLanguageRows: some View {
568+
VStack(alignment: .leading, spacing: 0) {
569+
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
570+
AdditionalLanguageRow(
571+
index: idx,
572+
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
573+
localeIDs: self.availableLocales.map(\.identifier),
574+
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
575+
showsDivider: true,
576+
onRemove: {
577+
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
578+
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
579+
})
580+
}
581+
582+
SettingsCardRow(title: "Add another language", showsDivider: false) {
587583
Button {
588-
if let first = availableLocales.first {
589-
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
590-
}
584+
self.addAdditionalLocale()
591585
} label: {
592-
Label("Add additional language", systemImage: "plus")
586+
Label("Add", systemImage: "plus")
593587
}
594-
.buttonStyle(.link)
588+
.buttonStyle(.bordered)
595589
.disabled(self.availableLocales.isEmpty)
596-
.padding(.horizontal, 14)
597-
.padding(.bottom, 10)
598590
}
599591
}
600592
}
601593

594+
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
595+
Binding(
596+
get: {
597+
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
598+
return self.state.voiceWakeAdditionalLocaleIDs[index]
599+
},
600+
set: { newValue in
601+
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
602+
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
603+
})
604+
}
605+
606+
private func addAdditionalLocale() {
607+
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
608+
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
609+
if let next {
610+
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
611+
}
612+
}
613+
602614
@MainActor
603615
private func loadMicsIfNeeded(force: Bool = false) async {
604616
guard force || self.availableMics.isEmpty, !self.loadingMics else { return }
@@ -716,6 +728,108 @@ struct VoiceWakeSettings: View {
716728
}
717729
}
718730

731+
private struct TriggerPhraseRow: View {
732+
@Binding var value: String
733+
let showsDivider: Bool
734+
let onSubmit: () -> Void
735+
let onRemove: () -> Void
736+
737+
var body: some View {
738+
HStack(spacing: 12) {
739+
Image(systemName: "quote.opening")
740+
.font(.callout.weight(.semibold))
741+
.foregroundStyle(.secondary)
742+
.frame(width: 24)
743+
744+
TextField("Wake phrase", text: self.$value)
745+
.textFieldStyle(.roundedBorder)
746+
.font(.callout.weight(.medium))
747+
.frame(maxWidth: 420)
748+
.onSubmit(self.onSubmit)
749+
750+
Spacer(minLength: 8)
751+
752+
Button(action: self.onRemove) {
753+
Image(systemName: "trash")
754+
.font(.callout)
755+
.symbolRenderingMode(.hierarchical)
756+
}
757+
.buttonStyle(.plain)
758+
.foregroundStyle(.secondary)
759+
.frame(width: 26, height: 26)
760+
.contentShape(Rectangle())
761+
.help("Remove trigger word")
762+
}
763+
.padding(.horizontal, 14)
764+
.padding(.vertical, 10)
765+
.overlay(alignment: .bottom) {
766+
if self.showsDivider {
767+
Divider()
768+
.padding(.leading, 50)
769+
}
770+
}
771+
}
772+
}
773+
774+
private struct AdditionalLanguageRow: View {
775+
let index: Int
776+
@Binding var selection: String
777+
let localeIDs: [String]
778+
let localeName: (String) -> String
779+
let showsDivider: Bool
780+
let onRemove: () -> Void
781+
782+
var body: some View {
783+
SettingsCardRow(
784+
title: "Language \(self.index + 2)",
785+
subtitle: "Fallback recognition language.",
786+
showsDivider: self.showsDivider)
787+
{
788+
HStack(spacing: 10) {
789+
Picker("Language \(self.index + 2)", selection: self.$selection) {
790+
ForEach(self.localeIDs, id: \.self) { id in
791+
Text(self.localeName(id)).tag(id)
792+
}
793+
}
794+
.labelsHidden()
795+
.frame(width: 220)
796+
797+
Button(action: self.onRemove) {
798+
Image(systemName: "trash")
799+
.font(.callout)
800+
.symbolRenderingMode(.hierarchical)
801+
}
802+
.buttonStyle(.plain)
803+
.foregroundStyle(.secondary)
804+
.frame(width: 26, height: 26)
805+
.contentShape(Rectangle())
806+
.help("Remove language")
807+
}
808+
}
809+
}
810+
}
811+
812+
private struct TriggerPhraseHelpRow: View {
813+
var body: some View {
814+
HStack(alignment: .top, spacing: 10) {
815+
Image(systemName: "info.circle")
816+
.font(.footnote.weight(.semibold))
817+
.foregroundStyle(.secondary)
818+
.frame(width: 18)
819+
.padding(.top, 1)
820+
821+
Text("OpenClaw reacts when any trigger appears in a transcription. Keep phrases short to avoid false positives.")
822+
.font(.footnote)
823+
.foregroundStyle(.secondary)
824+
.fixedSize(horizontal: false, vertical: true)
825+
826+
Spacer(minLength: 0)
827+
}
828+
.padding(.horizontal, 14)
829+
.padding(.vertical, 11)
830+
}
831+
}
832+
719833
#if DEBUG
720834
struct VoiceWakeSettings_Previews: PreviewProvider {
721835
static var previews: some View {

0 commit comments

Comments
 (0)