@@ -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
720834struct VoiceWakeSettings_Previews : PreviewProvider {
721835 static var previews : some View {
0 commit comments