// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package config import ( "reflect" "git.stormux.org/storm/cthulhu/internal/datetime" "git.stormux.org/storm/cthulhu/internal/input" ) // Application profiles store only values that differ from the global profile. // The writable schema is made of nested value structs and pointer fields, so // clearing equal leaves preserves its existing patch semantics. func applicationProfileDifference(global, application Settings) writableProfile { base := profileFromSettings(global) result := profileFromSettings(application) clearEqualProfileFields(reflect.ValueOf(&result).Elem(), reflect.ValueOf(base)) return result } func clearEqualProfileFields(result, base reflect.Value) { for index := 0; index < result.NumField(); index++ { field, baseline := result.Field(index), base.Field(index) if field.Kind() == reflect.Struct { clearEqualProfileFields(field, baseline) } else if reflect.DeepEqual(field.Interface(), baseline.Interface()) { field.SetZero() } } } // RebaseApplicationSettings keeps session-only application choices when the // global profile changes, while newly inherited values follow the new global. func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Settings { patch := applicationProfileDifference(oldGlobal, application) rebased := newGlobal applyProfile(&rebased, patch) rebased.ActiveProfile = application.ActiveProfile return rebased } func profileFromSettings(settings Settings) writableProfile { return writableProfile{ OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)}, ProgressBar: writableProgressBar{ OutputMode: stringPointer(settings.ProgressBar.OutputMode), ReportBackground: boolPointer(settings.ProgressBar.ReportBackground), BeepPercentageInterval: intPointer(settings.ProgressBar.BeepPercentageInterval), SpeechPercentageInterval: intPointer(settings.ProgressBar.SpeechPercentageInterval), }, Presentation: writablePresentation{ Pronunciations: pronunciationSlicePointer(settings.Pronunciations), TextAttributes: writableTextAttributes{Choices: textAttributeSlicePointer(settings.TextAttributes.Choices), Indicator: intPointer(settings.TextAttributes.Indicator)}, TimeFormat: stringPointer(settings.TimeFormat), DateFormat: stringPointer(settings.DateFormat), SpeakDescription: boolPointer(settings.SpeakDescription), SpeechVerbosityLevel: intPointer(settings.SpeechVerbosityLevel), SayAllStyle: stringPointer(settings.SayAllStyle), Reading: writableReading{ AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges), AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges), EnablePauseBreaks: boolPointer(settings.Reading.EnablePauseBreaks), SpeakNumbersAsDigits: boolPointer(settings.Reading.SpeakNumbersAsDigits), SpeakMultiCaseStringsAsWords: boolPointer(settings.Reading.SpeakMultiCaseStringsAsWords), EnableIndentation: boolPointer(settings.Reading.EnableIndentation), IndentationPresentationMode: stringPointer(settings.Reading.IndentationPresentationMode), OnlySpeakDisplayedText: boolPointer(settings.Reading.OnlySpeakDisplayedText), SpeakBlankLines: boolPointer(settings.Reading.SpeakBlankLines), EnableMnemonicSpeaking: boolPointer(settings.Reading.EnableMnemonicSpeaking), EnablePositionSpeaking: boolPointer(settings.Reading.EnablePositionSpeaking), EnableTutorialMessages: boolPointer(settings.Reading.EnableTutorialMessages), MessagesAreDetailed: boolPointer(settings.Reading.MessagesAreDetailed), UseColorNames: boolPointer(settings.Reading.UseColorNames), PresentTooltips: boolPointer(settings.Reading.PresentTooltips), EnableMouseReview: boolPointer(settings.Reading.EnableMouseReview), SpeakMisspelledIndicator: boolPointer(settings.Reading.SpeakMisspelledIndicator), SpeakContextBlockquote: boolPointer(settings.Reading.SpeakContextBlockquote), SpeakContextList: boolPointer(settings.Reading.SpeakContextList), SpeakContextTable: boolPointer(settings.Reading.SpeakContextTable), SpeakContextPanel: boolPointer(settings.Reading.SpeakContextPanel), SpeakContextLandmark: boolPointer(settings.Reading.SpeakContextLandmark), SpeakContextNonLandmarkForm: boolPointer(settings.Reading.SpeakContextNonLandmarkForm), SayAllContextBlockquote: boolPointer(settings.Reading.SayAllContextBlockquote), SayAllContextList: boolPointer(settings.Reading.SayAllContextList), SayAllContextTable: boolPointer(settings.Reading.SayAllContextTable), SayAllContextPanel: boolPointer(settings.Reading.SayAllContextPanel), SayAllContextLandmark: boolPointer(settings.Reading.SayAllContextLandmark), SayAllContextNonLandmarkForm: boolPointer(settings.Reading.SayAllContextNonLandmarkForm), ReadFullRowInGUITable: boolPointer(settings.Reading.ReadFullRowInGUITable), ReadFullRowInDocumentTable: boolPointer(settings.Reading.ReadFullRowInDocumentTable), ReadFullRowInSpreadSheet: boolPointer(settings.Reading.ReadFullRowInSpreadSheet), }, }, Speech: writableSpeech{ HardwareDevice: stringPointer(settings.HardwareSpeechDevice), HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate), Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory), HistoryLimit: intPointer(settings.SpeechHistoryLimit), Punctuation: intPointer(settings.VerbalizePunctuationStyle), Capitalization: stringPointer(settings.CapitalizationStyle), DefaultVoice: voicePointer(settings.DefaultVoice), UppercaseVoice: voicePointer(settings.UppercaseVoice), HyperlinkVoice: voicePointer(settings.HyperlinkVoice), SystemVoice: voicePointer(settings.SystemVoice), OutputModule: stringPointer(settings.SpeechOutputModule), }, Braille: writableBraille{Enabled: boolPointer(settings.EnableBraille), Contracted: boolPointer(settings.EnableContractedBraille), ContractionTable: stringPointer(settings.BrailleContractionTable), AbbreviateRoles: boolPointer(settings.Braille.AbbreviateRoles), DisableEOL: boolPointer(settings.Braille.DisableEOL), WordWrap: boolPointer(settings.Braille.WordWrap), Verbosity: intPointer(settings.Braille.Verbosity), SelectionIndicator: intPointer(settings.Braille.SelectionIndicator), LinkIndicator: intPointer(settings.Braille.LinkIndicator), FlashMessages: boolPointer(settings.Braille.FlashMessages), FlashTime: intPointer(settings.Braille.FlashTime), FlashPersistent: boolPointer(settings.Braille.FlashPersistent), FlashDetailed: boolPointer(settings.Braille.FlashDetailed)}, Sound: writableSound{ Enabled: boolPointer(settings.EnableSound), Volume: floatPointer(settings.SoundVolume), Sink: stringPointer(settings.SoundSink), Theme: stringPointer(settings.SoundTheme), PlayForRole: boolPointer(settings.PlaySoundForRole), PlayForState: boolPointer(settings.PlaySoundForState), PlayForPosition: boolPointer(settings.PlaySoundForPositionInSet), Spatialize: boolPointer(settings.SpatializeObjectSounds), RolePresentationStrategy: stringPointer(settings.RoleSoundPresentation), }, Keyboard: writableKeyboard{ Bindings: bindingSlicePointer(settings.KeyBindings), Layout: intPointer(settings.KeyboardLayout), ScreenReaderKeys: stringSlicePointer(settings.CthulhuModifierKeys), }, KeyEcho: writableKeyEcho{ UseCustomEchoVoice: boolPointer(settings.UseCustomEchoVoice), UseCustomEchoSpeechServer: boolPointer(settings.UseCustomEchoSpeechServer), UseCustomEchoForKey: boolPointer(settings.UseCustomEchoForKey), UseCustomEchoForCharacter: boolPointer(settings.UseCustomEchoForCharacter), UseCustomEchoForWord: boolPointer(settings.UseCustomEchoForWord), UseCustomEchoForSentence: boolPointer(settings.UseCustomEchoForSentence), EchoVoice: voicePointer(settings.EchoVoice), EchoSpeechServer: stringPointer(settings.EchoSpeechServer), Enabled: boolPointer(settings.EnableKeyEcho), ByCharacter: boolPointer(settings.EnableEchoByCharacter), ByWord: boolPointer(settings.EnableEchoByWord), BySentence: boolPointer(settings.EnableEchoBySentence), Alphabetic: boolPointer(settings.EnableAlphabeticKeys), Numeric: boolPointer(settings.EnableNumericKeys), Punctuation: boolPointer(settings.EnablePunctuationKeys), Space: boolPointer(settings.EnableSpace), Modifiers: boolPointer(settings.EnableModifierKeys), FunctionKeys: boolPointer(settings.EnableFunctionKeys), ActionKeys: boolPointer(settings.EnableActionKeys), NavigationKeys: boolPointer(settings.EnableNavigationKeys), Diacritical: boolPointer(settings.EnableDiacriticalKeys), }, } } func applyProfile(settings *Settings, profile writableProfile) { apply_profile_ocr(&settings.OCR, profile.OCR) // Keep the established patch order; absent fields leave prior values intact. apply_profile_formats(settings, profile) apply_profile_echo_voice(settings, profile) apply_profile_reading(settings, profile) apply_profile_output(settings, profile) apply_profile_speech(settings, profile) apply_profile_sound(settings, profile) if value := profile.ProgressBar.OutputMode; value != nil { settings.ProgressBar.OutputMode = *value } if value := profile.ProgressBar.ReportBackground; value != nil { settings.ProgressBar.ReportBackground = *value } if value := profile.ProgressBar.BeepPercentageInterval; value != nil { settings.ProgressBar.BeepPercentageInterval = *value } if value := profile.ProgressBar.SpeechPercentageInterval; value != nil { settings.ProgressBar.SpeechPercentageInterval = *value } apply_profile_keyboard(settings, profile) apply_profile_echo_keys(settings, profile) } func apply_profile_ocr(settings *OCRSettings, patch writableOCR) { if patch.Language != nil { settings.Language = *patch.Language } if patch.Layout != nil { settings.Layout = *patch.Layout } if patch.Scale != nil { settings.Scale = *patch.Scale } if patch.Grayscale != nil { settings.Grayscale = *patch.Grayscale } if patch.Invert != nil { settings.Invert = *patch.Invert } if patch.BlackWhite != nil { settings.BlackWhite = *patch.BlackWhite } if patch.Threshold != nil { settings.Threshold = *patch.Threshold } if patch.AutoRefresh != nil { settings.AutoRefresh = *patch.AutoRefresh } if patch.RefreshInterval != nil { settings.RefreshInterval = *patch.RefreshInterval } if patch.ConfirmClick != nil { settings.ConfirmClick = *patch.ConfirmClick } if patch.CopyFirstResult != nil { settings.CopyFirstResult = *patch.CopyFirstResult } if patch.AutoReadAll != nil { settings.AutoReadAll = *patch.AutoReadAll } } func apply_profile_formats(settings *Settings, profile writableProfile) { if v := profile.Presentation.TimeFormat; v != nil { settings.TimeFormat = *v } if v := profile.Presentation.DateFormat; v != nil { settings.DateFormat = datetime.NormalizeDate(*v) } } func apply_profile_echo_voice(settings *Settings, profile writableProfile) { if v := profile.KeyEcho.UseCustomEchoVoice; v != nil { settings.UseCustomEchoVoice = *v } if v := profile.KeyEcho.UseCustomEchoSpeechServer; v != nil { settings.UseCustomEchoSpeechServer = *v } if v := profile.KeyEcho.UseCustomEchoForKey; v != nil { settings.UseCustomEchoForKey = *v } if v := profile.KeyEcho.UseCustomEchoForCharacter; v != nil { settings.UseCustomEchoForCharacter = *v } if v := profile.KeyEcho.UseCustomEchoForWord; v != nil { settings.UseCustomEchoForWord = *v } if v := profile.KeyEcho.UseCustomEchoForSentence; v != nil { settings.UseCustomEchoForSentence = *v } if v := profile.KeyEcho.EchoVoice; v != nil { settings.EchoVoice = cloneVoice(*v) } if v := profile.KeyEcho.EchoSpeechServer; v != nil { settings.EchoSpeechServer = *v } } func apply_profile_reading(settings *Settings, profile writableProfile) { if value := profile.Presentation.Pronunciations; value != nil { settings.Pronunciations = append([]PronunciationEntry(nil), (*value)...) } if value := profile.Presentation.TextAttributes.Choices; value != nil { settings.TextAttributes.Choices = append([]TextAttributeChoice(nil), (*value)...) } if value := profile.Presentation.TextAttributes.Indicator; value != nil { settings.TextAttributes.Indicator = *value } if value := profile.Presentation.SpeakDescription; value != nil { settings.SpeakDescription = *value } if value := profile.Presentation.SpeechVerbosityLevel; value != nil { settings.SpeechVerbosityLevel = *value } if value := profile.Presentation.SayAllStyle; value != nil { settings.SayAllStyle = *value } if value := profile.Presentation.Reading.AutomaticallyReadClipboardChanges; value != nil { settings.Reading.AutomaticallyReadClipboardChanges = *value } if value := profile.Presentation.Reading.AutomaticallyReadTitleChanges; value != nil { settings.Reading.AutomaticallyReadTitleChanges = *value } if value := profile.Presentation.Reading.OnlySpeakDisplayedText; value != nil { settings.Reading.OnlySpeakDisplayedText = *value } if value := profile.Presentation.Reading.EnablePauseBreaks; value != nil { settings.Reading.EnablePauseBreaks = *value } if value := profile.Presentation.Reading.SpeakNumbersAsDigits; value != nil { settings.Reading.SpeakNumbersAsDigits = *value } if value := profile.Presentation.Reading.SpeakMultiCaseStringsAsWords; value != nil { settings.Reading.SpeakMultiCaseStringsAsWords = *value } if value := profile.Presentation.Reading.EnableIndentation; value != nil { settings.Reading.EnableIndentation = *value } if value := profile.Presentation.Reading.IndentationPresentationMode; value != nil { settings.Reading.IndentationPresentationMode = *value } if value := profile.Presentation.Reading.SpeakBlankLines; value != nil { settings.Reading.SpeakBlankLines = *value } if value := profile.Presentation.Reading.EnableMnemonicSpeaking; value != nil { settings.Reading.EnableMnemonicSpeaking = *value } if value := profile.Presentation.Reading.EnablePositionSpeaking; value != nil { settings.Reading.EnablePositionSpeaking = *value } if value := profile.Presentation.Reading.EnableTutorialMessages; value != nil { settings.Reading.EnableTutorialMessages = *value } if value := profile.Presentation.Reading.MessagesAreDetailed; value != nil { settings.Reading.MessagesAreDetailed = *value } if value := profile.Presentation.Reading.UseColorNames; value != nil { settings.Reading.UseColorNames = *value } if value := profile.Presentation.Reading.PresentTooltips; value != nil { settings.Reading.PresentTooltips = *value } if value := profile.Presentation.Reading.EnableMouseReview; value != nil { settings.Reading.EnableMouseReview = *value } if value := profile.Presentation.Reading.SpeakMisspelledIndicator; value != nil { settings.Reading.SpeakMisspelledIndicator = *value } if value := profile.Presentation.Reading.SpeakContextBlockquote; value != nil { settings.Reading.SpeakContextBlockquote = *value } if value := profile.Presentation.Reading.SpeakContextList; value != nil { settings.Reading.SpeakContextList = *value } if value := profile.Presentation.Reading.SpeakContextTable; value != nil { settings.Reading.SpeakContextTable = *value } if value := profile.Presentation.Reading.SpeakContextPanel; value != nil { settings.Reading.SpeakContextPanel = *value } if value := profile.Presentation.Reading.SpeakContextLandmark; value != nil { settings.Reading.SpeakContextLandmark = *value } if value := profile.Presentation.Reading.SpeakContextNonLandmarkForm; value != nil { settings.Reading.SpeakContextNonLandmarkForm = *value } if value := profile.Presentation.Reading.SayAllContextBlockquote; value != nil { settings.Reading.SayAllContextBlockquote = *value } if value := profile.Presentation.Reading.SayAllContextList; value != nil { settings.Reading.SayAllContextList = *value } if value := profile.Presentation.Reading.SayAllContextTable; value != nil { settings.Reading.SayAllContextTable = *value } if value := profile.Presentation.Reading.SayAllContextPanel; value != nil { settings.Reading.SayAllContextPanel = *value } if value := profile.Presentation.Reading.SayAllContextLandmark; value != nil { settings.Reading.SayAllContextLandmark = *value } if value := profile.Presentation.Reading.SayAllContextNonLandmarkForm; value != nil { settings.Reading.SayAllContextNonLandmarkForm = *value } if value := profile.Presentation.Reading.ReadFullRowInGUITable; value != nil { settings.Reading.ReadFullRowInGUITable = *value } if value := profile.Presentation.Reading.ReadFullRowInDocumentTable; value != nil { settings.Reading.ReadFullRowInDocumentTable = *value } if value := profile.Presentation.Reading.ReadFullRowInSpreadSheet; value != nil { settings.Reading.ReadFullRowInSpreadSheet = *value } } func apply_profile_output(settings *Settings, profile writableProfile) { if value := profile.Speech.Enabled; value != nil { settings.EnableSpeech = *value } if value := profile.Speech.HistoryLimit; value != nil { settings.SpeechHistoryLimit = *value } if value := profile.Braille.Enabled; value != nil { settings.EnableBraille = *value } if value := profile.Braille.Contracted; value != nil { settings.EnableContractedBraille = *value } if value := profile.Braille.ContractionTable; value != nil { settings.BrailleContractionTable = *value } if value := profile.Braille.AbbreviateRoles; value != nil { settings.Braille.AbbreviateRoles = *value } if value := profile.Braille.DisableEOL; value != nil { settings.Braille.DisableEOL = *value } if value := profile.Braille.WordWrap; value != nil { settings.Braille.WordWrap = *value } if value := profile.Braille.Verbosity; value != nil { settings.Braille.Verbosity = *value } if value := profile.Braille.SelectionIndicator; value != nil { settings.Braille.SelectionIndicator = *value } if value := profile.Braille.LinkIndicator; value != nil { settings.Braille.LinkIndicator = *value } if value := profile.Braille.FlashMessages; value != nil { settings.Braille.FlashMessages = *value } if value := profile.Braille.FlashTime; value != nil { settings.Braille.FlashTime = *value } if value := profile.Braille.FlashPersistent; value != nil { settings.Braille.FlashPersistent = *value } if value := profile.Braille.FlashDetailed; value != nil { settings.Braille.FlashDetailed = *value } } func apply_profile_speech(settings *Settings, profile writableProfile) { if value := profile.Speech.HardwareDevice; value != nil { settings.HardwareSpeechDevice = *value } if value := profile.Speech.HardwareBaudRate; value != nil { settings.HardwareSpeechBaudRate = *value } if value := profile.Speech.ServerFactory; value != nil { settings.SpeechServerFactory = *value } if value := profile.Speech.Punctuation; value != nil { settings.VerbalizePunctuationStyle = *value } if value := profile.Speech.Capitalization; value != nil { settings.CapitalizationStyle = *value } if value := profile.Speech.OutputModule; value != nil { settings.SpeechOutputModule = *value } if value := profile.Speech.DefaultVoice; value != nil { settings.DefaultVoice = cloneVoice(*value) } if value := profile.Speech.UppercaseVoice; value != nil { settings.UppercaseVoice = cloneVoice(*value) } if value := profile.Speech.HyperlinkVoice; value != nil { settings.HyperlinkVoice = cloneVoice(*value) } if value := profile.Speech.SystemVoice; value != nil { settings.SystemVoice = cloneVoice(*value) } } func apply_profile_sound(settings *Settings, profile writableProfile) { if value := profile.Sound.Enabled; value != nil { settings.EnableSound = *value } if value := profile.Sound.Volume; value != nil { settings.SoundVolume = *value } if value := profile.Sound.Sink; value != nil { settings.SoundSink = *value } if value := profile.Sound.Theme; value != nil { settings.SoundTheme = *value } if value := profile.Sound.PlayForRole; value != nil { settings.PlaySoundForRole = *value } if value := profile.Sound.PlayForState; value != nil { settings.PlaySoundForState = *value } if value := profile.Sound.PlayForPosition; value != nil { settings.PlaySoundForPositionInSet = *value } if value := profile.Sound.Spatialize; value != nil { settings.SpatializeObjectSounds = *value } if value := profile.Sound.RolePresentationStrategy; value != nil { settings.RoleSoundPresentation = *value } } func apply_profile_keyboard(settings *Settings, profile writableProfile) { if value := profile.Keyboard.Bindings; value != nil { settings.KeyBindings = input.CloneBindingOverrides(*value) } if value := profile.Keyboard.Layout; value != nil { settings.KeyboardLayout = *value } if value := profile.Keyboard.ScreenReaderKeys; value != nil { settings.CthulhuModifierKeys = append([]string(nil), (*value)...) } } func apply_profile_echo_keys(settings *Settings, profile writableProfile) { if value := profile.KeyEcho.Enabled; value != nil { settings.EnableKeyEcho = *value } if value := profile.KeyEcho.ByCharacter; value != nil { settings.EnableEchoByCharacter = *value } if value := profile.KeyEcho.ByWord; value != nil { settings.EnableEchoByWord = *value } if value := profile.KeyEcho.BySentence; value != nil { settings.EnableEchoBySentence = *value } if value := profile.KeyEcho.Alphabetic; value != nil { settings.EnableAlphabeticKeys = *value } if value := profile.KeyEcho.Numeric; value != nil { settings.EnableNumericKeys = *value } if value := profile.KeyEcho.Punctuation; value != nil { settings.EnablePunctuationKeys = *value } if value := profile.KeyEcho.Space; value != nil { settings.EnableSpace = *value } if value := profile.KeyEcho.Modifiers; value != nil { settings.EnableModifierKeys = *value } if value := profile.KeyEcho.FunctionKeys; value != nil { settings.EnableFunctionKeys = *value } if value := profile.KeyEcho.ActionKeys; value != nil { settings.EnableActionKeys = *value } if value := profile.KeyEcho.NavigationKeys; value != nil { settings.EnableNavigationKeys = *value } if value := profile.KeyEcho.Diacritical; value != nil { settings.EnableDiacriticalKeys = *value } } func boolPointer(value bool) *bool { return &value } func intPointer(value int) *int { return &value } func floatPointer(value float64) *float64 { return &value } func stringPointer(value string) *string { return &value } func stringSlicePointer(value []string) *[]string { copy := append([]string(nil), value...) return © } func pronunciationSlicePointer(value []PronunciationEntry) *[]PronunciationEntry { copy := append([]PronunciationEntry{}, value...) return © } func textAttributeSlicePointer(value []TextAttributeChoice) *[]TextAttributeChoice { copy := append([]TextAttributeChoice{}, value...) return © } func voicePointer(value Voice) *Voice { copy := cloneVoice(value); return © } func bindingSlicePointer(value []input.BindingOverride) *[]input.BindingOverride { copy := input.CloneBindingOverrides(value) if copy == nil { copy = []input.BindingOverride{} } return © }