diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 526b82c..35c0a4a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -22,7 +22,7 @@ You can review both before presenting, or let AutoTalk prepare and present autom - [Voices and voice samples](#voices-and-voice-samples) - [Delivery and speaking style](#delivery-and-speaking-style) - [Languages and versions](#languages-and-versions) -- [Slide settings and additional audio](#slide-settings-and-additional-audio) +- [Slide sound and additional audio](#slide-settings-and-additional-audio) - [Present, pause, continue and give a live demo](#present-pause-continue-and-give-a-live-demo) - [Record a presentation or export a video](#record-a-presentation-or-export-a-video) - [Manage the speech model and GPU](#manage-the-speech-model-and-gpu) @@ -67,7 +67,7 @@ The welcome page offers **Open PDF…** and **Open saved talk…**. After openin | Presentation setup | **Record presentation as a video** and **Presentation settings…**. | | Left: Slides | Select a slide; hover its thumbnail for inclusion and audio readiness. | | Centre: Editor | PDF preview, editable narration, separate text/audio/play controls, notes and language insertion. | -| Right: This slide | Inclusion, after-slide action, Timing (budget and pause), Slide voice & delivery, and Additional audio. | +| Right: Slide flow | Inclusion, after-slide action, Timing (budget and pause), Slide sound, and Additional audio. | | Bottom | Current recording, last saved result, text/audio counts, operation progress and cancellation. | | Status bar: Speech engine | Current model state; click for immediate and automatic engine controls. | @@ -97,72 +97,74 @@ The previous project is retained beside the talk in a folder named leaves the current talk intact. Reloading is unavailable during presentation or another operation. You can remove the backup manually once satisfied. -Settings open in three distinct windows: **Application defaults**, -**Talk settings — [title]**, and **Slide settings — [number]**. Each window has -flat navigation and only the sections relevant to its scope: +Settings have three fixed scopes, with no scope selector: **Talk defaults**, +**Talk settings — [title]**, and **Slide sound — slide [number]**. Each edits a +draft. **Preferences** is a separate window for this computer and applies changes +immediately. | Page | Settings grouped here | | --- | --- | -| **Voice & language** | Voice identity, language arrangement, writing style and spoken delivery. | -| **Talk & preparation** | Audience/conference, timing and Prepared/Quick/Realtime policies. | -| **Presentation & recording** | Pauses/after-slide action, recording/output, background track and slide clips. | -| **AI & speech engine** | Codex model/reasoning, Qwen sampling and application GPU controls. | -| **Application** | Account, display selection and system audio access. | - -The window title identifies what you are editing; there is no scope selector. -Use **Settings → Application settings…**, the main **Talk settings…** button, or -**Slide voice & delivery… / Additional audio…** in the inspector. -**Duration** in the main row is the only talk-duration editor. The toolbar's -**Mode**, main **Language** selector and main **Record presentation as a video** -checkbox are likewise the only talk-level value editors. Talk settings shows -read-only mode, language and recording values with their source and **Use app** -reset buttons. Reset participates in that dialog's Save/Cancel transaction; -Cancel also restores language versions and their selected version. Application -defaults retain their editable default mode, language and recording controls. - -Main voice and presentation shortcuts select talk scope. **Slide voice & delivery…** -and **Additional audio…** in the inspector select slide scope. Account and Speech -engine shortcuts select application scope. Manual model loading prepares the -selected slide's effective voice, or the application default when no talk is open. - -The precedence is **slide override → talk setting → application default**. Voice, -language, writing style, spoken delivery, pauses and after-slide action support all -three levels. Workflow, recording, output and technical model defaults support -application and talk levels. Account/device/GPU lifecycle settings remain local to -the application; conference content and files belong to the talk. - -**From app / From talk** identifies an inherited value. Edit a field to override it; -**Use app / Use talk** removes that override. Empty directions, zero pause and an -explicit model default are valid choices, distinct from inheritance. Voice summaries -show **Predefined**, **Own**, **Designed**, or **Reference** for ambiguous older -references, together with their scope. Saved is a library location, not a voice type. - -Changing application defaults affects existing talks that still inherit them. -Settings saved into the talk override those defaults. To keep a talk independent of -another computer's defaults, choose **Talk settings → Talk & preparation → Keep these -settings for this talk**. Required personal-voice assets are included in the talk. - -**Save** keeps changes across the pages of that window, in that scope only. -**Cancel** restores that scope’s starting configuration, including manifest changes -saved during a settings operation. Saving application defaults from a nested window -is not undone by cancelling talk settings. Slide settings stay bound to the slide -identified in the window title. -Account, manual model, external system-audio and explicitly saved library operations -are immediate. Stop a preparation or voice-recording operation started in Settings -before closing it. Closing engine settings during playback does not stop presentation. +| **Talk** (talk only) | Title, audience, objective, conference website/scope/sources, and pin inherited settings. | +| **Voice & language** | Predefined, personal, designed or saved voice; narration language and language arrangement. | +| **Writing & delivery** | Writing style, spoken delivery, directions, vocal attributes and reusable delivery presets. | +| **Timing & playback** | Mode, timing tolerance, Quick/Realtime policies, buffer, pause and after-slide action; measured timing and Save and fit for a talk. | +| **Audio & recording** | Recording source/output, background track, or slide clips. | +| **AI model** | Codex model/reasoning and Qwen model description/sampling. | + +Only applicable pages appear. The slide dialog has Voice & language, Writing & +delivery, and Audio & recording. Its flow controls remain in the **Slide flow** +inspector. **Preferences** has **Display & sound**, **Account**, and **Speech engine**. + +Use **Settings → Talk defaults…**, **Talk settings…** in the main window, or +**Slide sound… / Additional audio…** in the inspector. **Preferences… (Ctrl+,)**, +**Account…**, and **Speech engine…** open the relevant Preferences page. +Links from scoped dialogs open Preferences; they never nest another Save/Cancel +transaction. Manual model loading uses the selected slide's effective voice or +application defaults when no talk is open. + +**Duration**, **Mode**, **Language**, and **Record presentation as a video** keep +their single talk-level editors in the main window. Talk settings has read-only +mode/language/recording rows with source captions and **Reset**. Reset is a pending +draft edit, including the language-version selection it causes. + +The precedence is **slide override → talk setting → application default**. +Every inheritable row uses the same source captions: **set here**, **from talk**, +**from app**, or **built-in**, followed by **Reset**. Reset is enabled only for an +override at this scope. Selecting the same value as the parent still makes an +explicit override. Empty directions, false and a zero pause are valid values. +Voice summaries distinguish **Predefined**, **Own**, **Designed**, and older +**Reference** voices; Saved is a library location, not a voice type. + +Changing Talk defaults affects talks that inherit those values. To pin the +current values, use **Talk settings → Talk → Inherited settings → Keep these +settings for this talk**. The action is pending until Save. + +**Save** applies the draft once and saves the talk (or application defaults). +**Cancel** discards it: no manifest write, no rollback of other work, and main-window +edits made before opening the dialog stay unsaved. Imported reference/clip files +are removed on Cancel unless already present or referenced by the live talk. +Conference reading and imports update only the draft. Save is unavailable while +an import or conference operation is running; Cancel remains available. If a new +talk or preparation result replaces the project, Save refuses the stale draft and +asks you to reopen settings. Slide sound stays bound to the slide in its title. + +**Save and fit…** commits and closes talk settings, then starts the fit operation. +**Talk → Fit duration…** works directly on the current talk. Library actions save +only to the shared library immediately. Preferences uses **Close**, without +Save/Cancel; account, display/audio, manual model actions and load/release policy +changes apply immediately, with release still following its safe event boundary. Selecting a talk language restores that language's saved version or creates an empty version while preserving the current version. Changing an individual slide's language affects only that slide. **Start**, **Create talk text** or **Create slide text** generates missing narration from the slides and talk context before audio preparation. Other languages' narration is never used as translation input. -Deliberate slide-language overrides remain in effect. Readiness follows the current target, including after **Use app**, -**Use talk**, Cancel and reopening. Writing style affects later text generation; +Deliberate slide-language overrides remain in effect. Readiness follows the current target, including after **Reset**, Save, Cancel and reopening. Writing style affects later text generation; spoken delivery changes audio only. Unchanged synthesis inputs reuse current audio. Previously recorded audio remains available as **Play previous audio** when stale. **File → Recordings & export…** remains a separate task window; its settings link -opens **Presentation & recording** for the talk. +opens **Audio & recording** for the talk. Appearance follows the system palette and available native widget style; there is no independent theme picker. File dialogs retain normal directory navigation. @@ -200,8 +202,8 @@ Example: prepare a five-minute conference talk in German and listen to it before 1. Open the PDF and choose **Prepared**. 2. Set **Duration** to 5 minutes and **Language** to German in the main window. -3. Optionally configure **Talk settings → Talk & conference** and - **Voice & language → Voice / Writing & delivery**. Save the Talk settings window. +3. Optionally configure **Talk settings → Talk → Talk & conference**, + **Voice & language** and **Writing & delivery**. Save the Talk settings window. 4. Use **Create talk text** to fill missing narration for all included slides in the active language version. Existing wording is preserved. Edit it, then use **Create talk audio** to prepare missing/outdated audio without presenting. @@ -219,7 +221,9 @@ Example: prepare a five-minute conference talk in German and listen to it before There is no text-approval step. **Text ready** counts slides with usable narration; it does not claim a person reviewed the words. You can edit before presenting, but editing is optional. To revise a completed talk's duration explicitly, use -**Talk → Fit duration…** (also in Talk settings → Preparation & timing); this opens a confirmation because it can rewrite text. +**Talk → Fit duration…**. In **Talk settings → Timing & playback**, use +**Save and fit…** to commit the draft and close settings before fitting. Fitting +opens a confirmation because it can rewrite text. Fitting aims for your requested duration and allowed tolerance. It does not guarantee an exact finish time. Live pauses and demonstrations add time beyond the @@ -282,7 +286,7 @@ Prepared and Realtime, including saved slide overrides. Its simplified workspace still omits the editor and conference setup. Switch to Prepared or Realtime to edit individual narration; switching mode does not discard voice choices or audio. -**Talk settings → Preparation & timing → Quick timing policy** controls what happens before playback: +**Talk settings → Timing & playback → Quick timing policy** controls what happens before playback: | Policy | Behaviour | | --- | --- | @@ -310,8 +314,8 @@ The progress bars continue updating during preparation. If playback reaches audi that is not ready, it displays **Buffering** and waits. It continues once sufficient audio is available. Remaining time is an estimate while the talk is incomplete. -Realtime options live under **Talk settings → Preparation & timing**. The startup/refill -buffer is under its **Advanced Realtime buffering** disclosure: +Realtime options live under **Talk settings → Timing & playback → Preparation**, +including the startup/refill buffer: | Setting | Choices and effect | | --- | --- | @@ -333,7 +337,7 @@ mode still says Realtime. Selecting Realtime does not force regeneration each ti ## Conference, audience and objective -Set **Audience** under **Talk settings → Talk & conference**: for example, “Researchers and +Set **Audience** under **Talk settings → Talk → Talk & conference**: for example, “Researchers and software engineers; familiar with Linux, new to speech generation.” In the same section, set **Talk objective** and supply context in either way: @@ -410,7 +414,7 @@ Voice samples can require a model download or cold load. Follow progress and use ## Delivery and speaking style -**Voice & language → Writing & delivery** provides separate **Writing style** +**Writing & delivery** provides separate **Writing style** and **Spoken delivery** choices: Professional, Conversational, Energetic, Calm and understated, Lightly humorous, Academic, Storytelling and Inspirational. Writing style guides new narration wording; spoken delivery guides audio where supported @@ -433,16 +437,16 @@ Expand **More vocal attributes** for: | Gradual delivery across slides | Free text, such as a calm opening and more energetic conclusion | Attribute selectors also offer **Model default**. Changing style preserves explicit -attribute choices. **Save preset…** stores a named delivery configuration; select it +attribute choices. **Save delivery preset…** stores a named delivery configuration; select it under **Saved delivery presets** and choose **Use preset**. Presets do not change -advanced speech sampling. Age (young, middle-aged or older adult) is on the Design tab. +advanced speech sampling. Age (young, middle-aged or older adult) is under More vocal attributes and is enabled for designed voices. These are instructions to the model, not exact sound controls. Preview important passages. Age is restricted to voice design; accent presets to Chinese. Reference voices inherit character and delivery from their reference, so direct vocal instructions are disabled. Writing style still affects narration. Quick uses the same inherited or overridden settings. -For one deliberate exception, use the selected slide's **Delivery → Use talk delivery** setting (uncheck to override). +For one deliberate exception, use **Slide sound… → Writing & delivery**; **Reset** removes the slide override. Explicit slide directions take precedence over global custom directions, which take precedence over structured attributes/style. Speech continuity guidance is provided by default, but it cannot lock expression or speed exactly across passages. @@ -503,7 +507,7 @@ reliably from its metadata: select the intended target and use **Talk → Rewrit talk text…**. To retain the original, create another version first and run that rewrite in the new version. Review regenerated text before presenting. -## Slide settings and additional audio +## Slide sound and additional audio Select the slide before using the right-hand inspector. **Include in presentation** controls whether it is spoken/presented; at least one slide must remain included. @@ -515,8 +519,8 @@ The inspector groups these controls by their purpose: | Control | Use | | --- | --- | -| **Timing** | Leave the slide budget at **Automatic**, or set a target in seconds. **Pause after slide** inherits while **Use talk pause (X s)** is checked. Uncheck it to override; 0 means no pause and is distinct from inheritance. | -| **Slide voice & delivery…** | Opens the slide dialog for voice, language, delivery directions and vocal attributes. The caption beneath the button shows voice, source and style. | +| **Timing** | Leave the slide budget at **Automatic**, or set a target in seconds. **Pause after slide** shows its source and a Reset button. Editing creates an override; 0 means no pause. Reset restores inheritance. | +| **Slide sound…** | Opens the slide dialog for voice, language, delivery directions and vocal attributes. The caption beneath the button shows voice, source and style. | | **After this slide: Advance automatically** | Continue to the next included slide after the audio ends. | | **After this slide: Pause for live demo** | Pause and leave fullscreen for a demonstration. | | **After this slide: Wait for presenter** | Pause until you continue. | @@ -604,7 +608,7 @@ the default microphone and can pick up speaker sound, so check your setup first. ### Know when recording stops -- **Before Start:** the bottom bar says recording will start with the presentation. +- **Before Start:** the checked **Record presentation as a video** option indicates that recording will start with the presentation. - **During capture:** it shows recording status and elapsed recording time. - **Slides finished — recording continues:** screen capture is still active. - **End presentation and save video:** explicitly ends screen capture. @@ -616,7 +620,7 @@ continues through pauses, leaving fullscreen, live demos and completion of the l slide, until you explicitly End. A link to an older saved video is not evidence that the current recording has finished. -For slide-video capture, **Presentation settings → Recording → Advanced recording → Recording pauses** chooses +For slide-video capture, **Presentation settings → Audio & recording → Recording pauses** chooses whether to retain fullscreen pauses, all elapsed waits, or content only. It does not apply to screen recording, which records the ongoing session. @@ -715,9 +719,9 @@ model manifest. This button checks availability; it does not install unverified - **After each preparation or voice preview:** release after speech work finishes. A manual preload is not speech generation: it stays ready for the next operation. -Choose **Save** to apply automatic rules. **Cancel** discards draft rule changes; -it does not undo a manual Load/Unload you performed. Current-state text describes -the saved rule, even while you are editing different draft choices. +Automatic rules apply immediately in **Preferences → Speech engine**. Closing +Preferences keeps them. Current-state text describes the active rule; release +occurs at its next safe boundary and is not triggered merely by closing the window. Manual and automatic loads follow the same unloading rule. Changing a rule does not itself start loading, or treat an already stopped presentation as a new End. @@ -728,7 +732,7 @@ Practical examples: - **Avoid a cold load just before generating speech:** Load now earlier, keep until close, then prepare or start Realtime. Text generation and synthesis still take time. -- **Free the GPU after presenting:** choose presentation-end unloading and Save. +- **Free the GPU after presenting:** choose presentation-end unloading in Preferences. A manually preloaded model follows that same rule. - **Only play an already prepared talk:** use loading when speech is needed. No model load is required for playback or direct export. @@ -739,8 +743,8 @@ Technical settings stay with the task they configure. Open the indicated section | Control / location | Meaning | | --- | --- | -| **Talk settings → Preparation & timing → Allowed timing difference** | Tolerance in seconds around the target duration when assessing/fitting a talk. | -| **Application/Talk settings → Presentation & recording → Playback → Pause after slide** | Default prepared gap between slides; contributes to duration. Override for one slide in inspector → Timing. | +| **Talk settings → Timing & playback → Allowed timing difference** | Tolerance in seconds around the target duration when assessing/fitting a talk. | +| **Talk defaults/Talk settings → Timing & playback → Playback → Pause after slide** | Default prepared gap between slides; contributes to duration. Override for one slide in inspector → Timing. | | **Language → Language options… → Language arrangement** | Mixed passages, one language per slide, or single-language versions. | | **Quick timing policy** | Generation/fitting/start behaviour; visible in Quick. | | **Realtime narration / speech priority / startup-refill buffer** | Text preparation and buffering choices; visible in Realtime. | @@ -748,8 +752,8 @@ Technical settings stay with the task they configure. Open the indicated section | **Presentation settings → Recording → Output quality → Export audio sample rate** | 24 kHz, 44.1 kHz or 48 kHz. This controls output encoding, not the original voice model's quality. | | **Presentation settings → Recording → Output quality → MP4 / M4A audio encoding** | AAC 96, 128 or 192 kbit/s. Higher bitrate generally increases file size. | | **Settings → Account… → Sign in / Sign out** | Sign in is enabled when disconnected; Sign out is enabled when connected. Both are disabled during an operation. Sign-out clears the shared local Codex login and the discovered model list, while preserving saved talk selections. | -| **AI & speech engine → Narration AI — Codex → Model / Reasoning effort** | Choose from the connected account's available text models and that model's effort options. All modes respect the selected scope. | -| **AI & speech engine → Speech model — Qwen → Advanced synthesis** | Exposes Temperature, Top k, Top p, Repetition penalty and Max new tokens for speech generation. | +| **AI model → Narration AI — Codex → Model / Reasoning effort** | Choose from the connected account's available text models and that model's effort options. All modes respect the selected scope. | +| **AI model → Speech model — Qwen → Sampling** | Exposes Temperature, Top k, Top p, Repetition penalty and Max new tokens for speech generation. | AutoTalk checks an existing Codex installation for a ChatGPT sign-in at startup, even before you open a PDF or saved talk. If already signed in, model and reasoning @@ -822,14 +826,14 @@ speech GPU, unload/close that instance before attempting another model load. | **View** | Editor/Presenter, navigator/inspector visibility, restore layout, enlarge slide, Operation details. | | **Talk** | Talk settings, Voice & speech, Create talk text/audio, Rewrite all talk text, Fit duration, Language options, Selected slide and Mode submenus. | | **Presentation** | Start / Prepare and start, Continue, Pause, start selected, Restart, live demo, Previous/Next, End, Presentation settings, System audio settings. | -| **Settings** | Application settings, Account, Speech engine, immediate model Load/Unload. | +| **Settings** | Preferences, Talk defaults, Account, Speech engine, immediate model Load/Unload. | | **Help** | Getting started, Keyboard shortcuts, About AutoTalk. | | Shortcut | Action | | --- | --- | | **Ctrl+N / Ctrl+O** | Open PDF / open saved talk | | **Ctrl+S / Ctrl+Shift+S** | Save / save a copy | -| **Ctrl+T / Ctrl+,** | Talk settings / Application settings | +| **Ctrl+T / Ctrl+,** | Talk settings / Preferences | | **Ctrl+F** | Find in current talk-text editor | | **Ctrl+Z / Ctrl+Shift+Z** | Undo / redo in the focused text editor | | **Ctrl+X / Ctrl+C / Ctrl+V / Ctrl+A** | Cut / copy / paste / select all in the focused editor | @@ -899,7 +903,7 @@ repeating the title or voice. The status bar contains transient saved/operation messages and the Speech engine button, without permanent filler text. Background track Add/Remove, volume and loop share the talk's **Background audio** -section. Application defaults show volume and loop only. **Settings → Account…** +section. Talk defaults show volume and loop only. **Settings → Account…** focuses the account controls; Codex model/reasoning support application defaults and talk overrides. The Codex page displays connection state without suggesting that its readout is a sign-in control. diff --git a/docs/design/SCOPED_SETTINGS_IMPLEMENTATION.md b/docs/design/SCOPED_SETTINGS_IMPLEMENTATION.md index 5828904..82ec4c3 100644 --- a/docs/design/SCOPED_SETTINGS_IMPLEMENTATION.md +++ b/docs/design/SCOPED_SETTINGS_IMPLEMENTATION.md @@ -1,145 +1,69 @@ -# Flat settings pages and three-level inheritance +# Scoped settings: schema, drafts and Preferences -Requested 27 September 2026. This supersedes the nested navigation introduced by -the canonical Settings consolidation. The user approved up to 500 net additional production lines for this change. -Implementation and Linux verification are recorded in [VERIFICATION.md](../VERIFICATION.md). +The October 1 refinement supersedes the September 27 combined application dialog. +See [verification and accounting](../reviews/2026-10-01-settings-refactor.md). ## Invariant -For an inheritable setting, the effective value is the explicit slide override, -otherwise the explicit talk override, otherwise the application default. The same -resolved values must drive presentation, voice summaries, preview, narration, -speech synthesis and audio validity. Missing overrides are different from zero, -empty text, false and an explicitly selected model default. +Project resolves slide override → talk setting → application default → built-in. +Resolution, schema v5, SETTING_DEFAULTS, language versions and audio fingerprints +are unchanged. MainWindow owns the live project. A scoped dialog owns a draft, +never the live project. Preferences owns computer controls, not talk defaults. -Three fixed-scope Settings dialogs share flat navigation with up to five pages: +## Ownership -1. Voice & language: voice identity/source, language, writing style and delivery. -2. Talk & preparation: audience/conference and timing policies; a read-only talk mode row. -3. Presentation & recording: playback behaviour, recording, output and added audio. -4. AI & speech engine: Codex selection, Qwen options and GPU lifecycle. -5. Application: account, device preferences and general application settings. - -Pages combine related settings. Application defaults, talk and slide each have a -fixed-scope window; there is no scope selector. Navigation exposes only applicable -pages. Device and engine lifetime retain application scope. Existing shortcuts -select the concrete page and focus target; native style and palette are retained. - -## Settings ownership - -| Scope chain | Settings | Editor location | +| Scope | Settings | Editor location | | --- | --- | --- | -| Application → talk → slide | Voice, writing style, delivery attributes/directions | Corresponding scope's Voice & language dialog | -| Application → talk → slide | Narration language | Application default / main Language combo / slide Language of this slide; talk dialog readout + reset only | -| Application → talk → slide | After-slide action, pause | Application and talk Playback sections; slide inspector After this slide / Timing | -| Application → talk | Mode; recording intent | Application default dialog; main toolbar combo / main checkbox; talk dialog readouts + reset only | -| Application → talk | Language arrangement, Codex model/reasoning, synthesis sampling, Quick/Realtime policies, tolerance, recording source/microphone/policy, encoding, background gain/loop | Corresponding scope's task page | -| Application only | Account, GPU lifetime, device preferences, libraries | Application dialog and focused menu entrances | -| Talk only | Duration | Main Duration spin only | -| Talk only | Title, conference/sources, audience/objective, background track, output destination, gradual delivery | Talk dialog; background track controls share Background audio | -| Talk only | Language versions | Main Language combo | -| Slide only | Narration, notes, inclusion, budget, additional clips | Editor, inspector Timing/inclusion, slide Additional audio dialog | - - -The selected language version belongs to talk scope; it is not a fourth tier. -Slide overrides belong to that version's slide. Explicit passage language markers -remain content instructions and must satisfy the talk's language-arrangement rule. -Changing language does not translate existing text automatically. - -Voice identity is an atomic selection, including its reference recordings. Vocal -attributes inherit individually. Supported controls depend on the effective voice; -the Qwen variant follows that voice rather than an independently conflicting choice. - -## Concrete implementation boundaries - -1. **Project/settings state — `project.py`, application preferences.** Introduce - one allowlisted settings resolver and explicit override storage. Application - defaults are stored in application config; talk/slide overrides in the talk - JSON. Workers receive a resolved snapshot instead of reading live preferences. - Replace direct effective-setting calculations at consumers rather than keeping - a parallel policy. Migrate existing talk values to explicit overrides, preserving - saved behaviour and existing audio when effective synthesis inputs are unchanged. - -2. **Bindings and dialog — `ui.py`, `options.py`, `app.py`.** Replace the tree and - thirteen pages with five combined pages. Reuse the field editors with explicit - scope-aware bindings, inherited-value captions and removal of overrides. Keep - ordinary controls direct; technical parameters remain under Advanced. Preserve - pending edits while changing pages. Each fixed-scope dialog has its own - Save/Cancel transaction. Immediate account, - engine, system-audio and explicit library operations stay clearly identified. - Do not temporarily adopt a fake talk to edit application defaults. - -3. **Voice identity — `project.py`, `voices.py`, voice actions in `app.py`.** Preserve - recorded/imported/designed/predefined origin independently of the synthesis - backend and user-assigned name. Accepting a design keeps its origin after its - audio becomes a Base reference. Saved-library selection is not another voice - type. Older ambiguous references are labelled Reference voice without guessing. - Use the resolved identity in both talk and selected-slide summaries. Reuse the - existing reference-copy/hash mechanism for portable talk assets. - -4. **Generation and engine ownership — `services.py`, `runtime.py`.** Resolve - language, writing and speech settings per slide. Preserve whole-talk context - during narration generation. Replace the one-configuration-per-batch assumption - with compatible batches through the existing single speech owner, including - Realtime and explicit preload. Avoid holding the existing session guard across - a transition that needs to acquire it again. Speaker changes within a compatible - model should not trigger needless model reloads. Model-type changes may require - loading and must report that work. Do not change the existing text splitting. - -5. **Modes, cache and saved projects.** Prepared, Quick and Realtime share effective - settings; remove Quick's hidden Ryan/Professional substitutions. Quick keeps - its simplified start workflow. Audio fingerprints use effective synthesis inputs, - not override provenance, so switching from inherited to explicit identical values - preserves valid audio. Only affected slides become outdated. Keep existing words - and saved files until explicit replacement succeeds. Playback and exports use the - accepted artifacts, not newly resolved settings midway through an operation. - -## Defaults and persistence semantics - -Inheritance is live: changing an application default affects talks/slides that still -inherit that field. Show the affected scope when saving application defaults; do -not silently regenerate text or audio. An active job retains its starting snapshot. - -Provide an explicit Keep these settings for this talk action to turn inherited -values into talk overrides and include required voice assets. This makes a talk -independent of another computer's defaults without another inheritance tier. -Account credentials and machine-specific choices are never copied into a talk. - -## Verification - -- Resolver precedence, removal of overrides, explicit empty/zero/false values and - supported/unsupported vocal controls; effective-value equality preserves cache. -- Application config versus talk JSON persistence; migration and reopen with changed - application defaults; pinning settings and copying the talk to another directory. -- Predefined, own and designed voices retain their displayed origin through preview, - acceptance, library save/load and project reopen; library audition does not select. -- Three flat fixed-scope dialogs, five possible pages, focused shortcuts, scoped Save/Cancel, - application defaults without a talk and correct selected-slide/version binding. -- Whole-talk and per-slide text/audio generation, mixed-language validation and - Prepared/Quick/Realtime use the same resolved settings; no text approval is added. -- Engine reuse and compatible/incompatible voice transitions, cancellation and - progress; pre-existing valid audio avoids loading a model unnecessarily. -- Linux native Qt/Breeze Xvfb, minimum/default window sizes and 150% scaling; full - regression suite, packaged launcher and library audit. Only Linux is tested. - -## Accounting and approval - -The prior approved limit was 160 net additional production lines from baseline -117fb8b. Before this pass, the tree used 101, leaving 59. Flattening navigation can reduce UI -code, but the existing model has no persisted application speech/language defaults -or general slide overrides, and generation is bound to talk-level speech config. -Moving widgets cannot supply those missing semantics. - -Estimated additional production growth, after removing superseded paths: - -| Area | Net lines | -| --- | ---: | -| Settings resolution, serialization and migration | 110–150 | -| Scope-aware editors and transaction changes, offset by navigation consolidation | 130–180 | -| Voice origin, summaries and library/reference persistence | 30–50 | -| Generation, mode and single-engine transition changes | 80–120 | -| **Total** | **350–500** | - -Approved allowance: at most **500 net additional production lines from the current -tree** (601 total from 117fb8b). Tests and documentation are counted separately. -This is an estimate and ceiling, not a target. Final measured accounting is recorded with the verification results. +| Application → talk → slide | Voice identity and references | Voice & language in each fixed-scope dialog | +| Application → talk → slide | Language | Talk defaults; main Language combo; Slide sound → Voice & language | +| Application → talk → slide | Writing style, delivery instructions/attributes | Writing & delivery in each fixed-scope dialog | +| Application → talk → slide | After-slide action and pause | Defaults/talk Timing & playback; slide inspector only | +| Application → talk | Mode | Talk defaults Timing & playback; main Mode combo | +| Application → talk | Recording intent | Talk defaults Audio & recording; main checkbox | +| Application → talk | Language arrangement | Defaults/talk Voice & language | +| Application → talk | Quick/Realtime/timing policies | Defaults/talk Timing & playback | +| Application → talk | Codex model/effort; synthesis sampling | Defaults/talk AI model | +| Application → talk | Recording/output settings; background gain/loop | Defaults/talk Audio & recording | +| Talk | Duration | Main Duration spin only | +| Talk | Title/audience/objective/conference/sources; pin inherited settings | Talk settings → Talk | +| Talk | Background file and recording destination | Talk settings → Audio & recording | +| Slide | Inclusion/budget | Slide flow inspector | +| Slide | Audio clips, gain and placement | Slide sound → Audio & recording | +| Computer | Display, system sound, account, engine and lifecycle policy | Preferences, immediate; Close only | +| Shared library | Reusable voices and delivery presets | Relevant component's explicit library actions, immediate | + +Talk mode, language and recording rows are read-only with Reset. Every inheritable +row has a constant source caption (set here/from talk/from app/built-in) and Reset; +explicit zero, false, empty, and equal-to-parent values remain overrides. + +## Implementation + +`settings_schema.py` is the presentation metadata authority for keys, kinds, +choices, scope, page, section and order. Values/scopes originate in Project's +SETTING_DEFAULTS and SETTING_CHOICES. The explicitly listed custom owners handle +voice, sampling, delivery presets and assets. `SettingField` builds and loads the +same editor/source/Reset row in dialogs and the inspector. Widgets are placed once. + +`ScopedSettingsDialog` has thin TalkDefaults/Talk/Slide configurations. It composes +VoiceEditor, AudioAssets, CodexModelPicker, ConferencePanel and DeliveryPresets. +The narrow controller API is documented in module docstrings and checked by AST. +PreferencesDialog owns device, account and engine widgets. MainWindow owns actions, +the speech session and persistence; no compatibility widget aliases remain. + +Opening copies the project (or defaults) and records its generation. Edits use +`apply_edit`, shared by draft editing and Save replay, and append immutable operations. +Save checks the generation, replays once through Project mutators inside the +existing configuration change boundary, updates audio/UI once, compares stale +slides, and writes once. Defaults write QSettings; talk/slide write the manifest. +Cancel writes nothing, preserves pre-existing main-window modifications, cancels +owned operations and deletes only session-created unreferenced media. + +Conference/import workers receive the draft and never save. Late cancelled results +cannot edit a reopened draft. Save is unavailable during project-returning jobs; +Cancel remains available. Shared-library actions do not save the project. +Preferences account/model jobs do not save pending document settings. + +Draft edits never call MainWindow.edit_setting, configuration_changed or refresh. +MainWindow no longer reloads drafts after live edits. Local timing is measured on +the draft when its page is shown/edited. Engine status and account availability are +read-only external state; they do not replace draft model selections. diff --git a/docs/design/UX_PLACEMENT_PROPOSAL.md b/docs/design/UX_PLACEMENT_PROPOSAL.md index 24b8461..cb65caf 100644 --- a/docs/design/UX_PLACEMENT_PROPOSAL.md +++ b/docs/design/UX_PLACEMENT_PROPOSAL.md @@ -8,6 +8,44 @@ interaction contract. The [original inventory](UI_INVENTORY.md) is the historica baseline. Existing visual styling is retained. The implementation was authorized with a ceiling of 160 net additional production lines. +## Current settings and access contract — October 1 + +The [scoped settings contract](SCOPED_SETTINGS_IMPLEMENTATION.md) supersedes the +historical settings layout below. The main controls and single-owner rules remain. +Preferences is this computer (immediate, Close). Talk defaults, Talk settings and +Slide sound edit independent drafts (Save/Cancel). No scope selector exists. + +| Controls | Current entrance / location | Priority / navigation depth | +| --- | --- | --- | +| Mode, duration, language, record, Start, text/audio/play, slide navigation | Main window | P1 / 0 | +| Predefined voice and sample | Main voice button → Voice & language | P1 / 1 | +| Personal/designed/saved voice | Main voice button → source tab | P2 / 2 | +| Audience, objective, conference | Talk settings → Talk | P2 / 1 | +| Delivery style and presets | Voice button → Writing & delivery | P2 / 2 | +| Vocal attributes | Writing & delivery → More vocal attributes | P3 / 3 | +| Slide include/after | Slide flow inspector | P3 / 0 | +| Slide budget/pause, inheritance reset | Slide flow → Timing | P3 / 1 | +| Slide voice/language | Slide sound… → Voice & language | P3 / 1 | +| Slide directions/attributes | Slide sound… → Writing & delivery [→ More vocal attributes] | P3 / 2–3 | +| Slide clips | Additional audio… → Audio & recording | P3 / 1 | +| Recording source, microphone, destination, background | Presentation settings → Audio & recording | P2 / 1 plus scroll if needed | +| Pause, tolerance, Quick/Realtime policies | Talk settings → Timing & playback | P2–P3 / 2 | +| Fit duration | Talk → Fit duration…; or talk Timing → Save and fit… | P2 / 1–2 | +| Display/system sound | Settings → Preferences… → Display & sound (Ctrl+,) | P2 / 2; shortcut opens directly | +| Account | Settings → Account… | P3 / 2 | +| Speech engine | Status-bar engine button → Preferences/Speech engine | P3–P4 / 1 | +| Inheritable application defaults | Settings → Talk defaults… → relevant page | P3 / 2–3 | +| Codex model/effort and speech sampling | Talk settings → AI model | P4 / 2 plus scroll if needed | + +Count navigation separately from entering values, executing actions and Save. +Pages use the same order at each scope, omitting empty pages. P1 main controls +remain directly visible at 940×680. Fixed-scope titles, source captions and Reset +identify ownership; Cancel does not save or undo unrelated changes. See the +[October 1 evidence](../reviews/2026-10-01-settings-refactor.md) for sizes, screenshots, +keyboard/pointer tests, test counts and measured edit/Save latency. + +## Historical placement records + ## Current settings contract The subsequent [flat scoped-settings contract](SCOPED_SETTINGS_IMPLEMENTATION.md) diff --git a/docs/design/UX_PLACEMENT_VERIFICATION.md b/docs/design/UX_PLACEMENT_VERIFICATION.md index 404b753..6d326b4 100644 --- a/docs/design/UX_PLACEMENT_VERIFICATION.md +++ b/docs/design/UX_PLACEMENT_VERIFICATION.md @@ -5,6 +5,44 @@ Implemented 27 September 2026 against the accepted [inventory](UI_INVENTORY.md) remains the pre-refactor coverage baseline; the [user guide](../USER_GUIDE.md) describes the current interaction routes. +## Current settings and access contract — October 1 + +The [scoped settings contract](SCOPED_SETTINGS_IMPLEMENTATION.md) supersedes the +historical settings layout below. The main controls and single-owner rules remain. +Preferences is this computer (immediate, Close). Talk defaults, Talk settings and +Slide sound edit independent drafts (Save/Cancel). No scope selector exists. + +| Controls | Current entrance / location | Priority / navigation depth | +| --- | --- | --- | +| Mode, duration, language, record, Start, text/audio/play, slide navigation | Main window | P1 / 0 | +| Predefined voice and sample | Main voice button → Voice & language | P1 / 1 | +| Personal/designed/saved voice | Main voice button → source tab | P2 / 2 | +| Audience, objective, conference | Talk settings → Talk | P2 / 1 | +| Delivery style and presets | Voice button → Writing & delivery | P2 / 2 | +| Vocal attributes | Writing & delivery → More vocal attributes | P3 / 3 | +| Slide include/after | Slide flow inspector | P3 / 0 | +| Slide budget/pause, inheritance reset | Slide flow → Timing | P3 / 1 | +| Slide voice/language | Slide sound… → Voice & language | P3 / 1 | +| Slide directions/attributes | Slide sound… → Writing & delivery [→ More vocal attributes] | P3 / 2–3 | +| Slide clips | Additional audio… → Audio & recording | P3 / 1 | +| Recording source, microphone, destination, background | Presentation settings → Audio & recording | P2 / 1 plus scroll if needed | +| Pause, tolerance, Quick/Realtime policies | Talk settings → Timing & playback | P2–P3 / 2 | +| Fit duration | Talk → Fit duration…; or talk Timing → Save and fit… | P2 / 1–2 | +| Display/system sound | Settings → Preferences… → Display & sound (Ctrl+,) | P2 / 2; shortcut opens directly | +| Account | Settings → Account… | P3 / 2 | +| Speech engine | Status-bar engine button → Preferences/Speech engine | P3–P4 / 1 | +| Inheritable application defaults | Settings → Talk defaults… → relevant page | P3 / 2–3 | +| Codex model/effort and speech sampling | Talk settings → AI model | P4 / 2 plus scroll if needed | + +Count navigation separately from entering values, executing actions and Save. +Pages use the same order at each scope, omitting empty pages. P1 main controls +remain directly visible at 940×680. Fixed-scope titles, source captions and Reset +identify ownership; Cancel does not save or undo unrelated changes. See the +[October 1 evidence](../reviews/2026-10-01-settings-refactor.md) for sizes, screenshots, +keyboard/pointer tests, test counts and measured edit/Save latency. + +## Historical placement records + ## Subsequent settings refinement The settings-specific locations below describe the preceding canonical-dialog diff --git a/docs/reviews/2026-10-01-settings-refactor.md b/docs/reviews/2026-10-01-settings-refactor.md new file mode 100644 index 0000000..44ee1b7 --- /dev/null +++ b/docs/reviews/2026-10-01-settings-refactor.md @@ -0,0 +1,247 @@ +# Settings drafts and computer Preferences — PR B + +Base: `c109626`, PR A (`codex/edit-latency-and-navigation`). Branch: +`codex/settings-refactor`. Linux only. This is the second ordered commit/PR; +[PR A's report](2026-10-01-edit-latency-and-navigation.md) records edit latency, +slide navigation and the approved two readiness passes. + +## Architectural invariant + +MainWindow owns the live project. Scoped dialogs own independent drafts; editing +or cancelling them must not change the live project, its file, or its UI. Save +replays the ordered edits through the same Project mutators in one configuration +change boundary, refreshes audio/UI once, compares readiness before/after, and +writes once. Preferences owns this computer's immediate actions and policies. +Project still owns resolution, versions, defaults and audio fingerprints. + +Reduction alone cannot provide isolated transactions or separate immediate computer controls. +The approved additions replace the live-edit/rollback mechanism rather than adding +a second commit path. Cross-scope dialog reloads, the SettingsPanel +type switch and duplicated scope/page declarations are removed. One schema and +SettingField supply editor/source/Reset rows; thin fixed-scope dialogs compose +VoiceEditor, AudioAssets, CodexModelPicker, ConferencePanel and DeliveryPresets. +There is no scope selector, compatibility facade, widget alias or takeRow move. + +Draft imports and conference jobs do not save. Cancel removes only newly created +media not referenced by the live project. An operation is tied to its originating +draft so a late cancelled result cannot overwrite a reopened dialog. Shared voice +and delivery libraries remain immediate and explicitly labelled. Save is disabled +during project-returning jobs. A generation check rejects stale transactions. +A failed filesystem Save can be retried without falsely detecting its own replay +as an intervening project change. + +## Entrance map + +Menu mirrors are retained. Each route targets one dialog/page, with no nested +Save/Cancel document transaction. Preferences links can open its Close-only window. + +| Entrance | Before (PR A) | After | +| --- | --- | --- | +| Settings → application configuration / Ctrl+, | Application defaults, mixed computer/default settings | Preferences / Display & sound | +| Settings → Talk defaults… | Part of the application dialog | Talk defaults / Voice & language | +| Settings → Account… | Application defaults / AI & speech engine, account focus | Preferences / Account, sign-in focus | +| Settings → Speech engine…; status-bar engine button | Application defaults / AI & speech engine | Preferences / Speech engine | +| Main Talk settings…; Talk menu mirror | Talk & preparation | Talk settings / Talk | +| Main voice button; Voice & speech… menu | Voice & language | Talk settings / Voice & language | +| Main Presentation settings…; menu mirror | Presentation & recording | Talk settings / Audio & recording | +| Language combo → Language options…; Talk menu mirror | Voice & language, arrangement focus | Same destination/focus | +| Inspector Slide voice & delivery… | Slide Voice & language | Slide sound… / Voice & language | +| Inspector Additional audio…; selected-slide menu mirror | Slide Presentation & recording | Slide sound / Audio & recording | +| Scoped Display & sound settings… link | Application defaults, nested transaction | Preferences / Display & sound, display focus | +| Scoped Account settings… link | Application defaults, nested transaction | Preferences / Account, sign-in focus | +| Scoped Speech engine settings… link | Application defaults, nested transaction | Preferences / Speech engine | +| Measured-timing fit button | Fit duration… inside transaction | Save and fit… commits/closes first | +| Talk → Fit duration… | Existing confirmation/job | Unchanged | +| Presentation → System audio settings… | External system selector | Unchanged | + +Read-only talk rows retain mode/language/record values and Reset; their only value +editors remain in the main window. Slide flow owns include, after, budget and +pause. Slide sound owns voice/language, writing/delivery and clips. All inheritance +rows use a source caption and constant Reset; choosing the inherited value still +creates an explicit override. Navigation depths are remeasured in the current +[placement contract](../design/UX_PLACEMENT_PROPOSAL.md). + +## Page map + +| Window | Previous pages | Final pages, in order | +| --- | --- | --- | +| Application defaults → Talk defaults | Voice & language; Talk & preparation; Presentation & recording; AI & speech engine; Application | Voice & language; Writing & delivery; Timing & playback; Audio & recording; AI model | +| Talk settings | Voice & language; Talk & preparation; Presentation & recording; AI & speech engine | Talk; Voice & language; Writing & delivery; Timing & playback; Audio & recording; AI model | +| Slide settings → Slide sound | Voice & language; Presentation & recording | Voice & language; Writing & delivery; Audio & recording | +| Preferences (separate window) | Computer controls mixed into application defaults | Display & sound; Account; Speech engine | + +Talk metadata and pinning are together. Timing includes mode policies, tolerance, +playback and measured timing. Audio includes recording, quality and background or +clips. AI model holds Codex model/effort and Qwen sampling. Unsupported pages do not +exist for a scope. Footer text describes that scope; immediate-library wording +appears only on the relevant pages. Preferences uses Close, with immediate policies +still respecting the existing engine safe boundaries. + +## Verification + +The full suite uses private Xvfb/xfwm4/D-Bus sessions and a private null audio sink. +Neither speaker nor microphone is unmuted. The existing native Qt Multimedia +cleanup patch and driver remain unchanged. No application dependency was added. + +```sh +AUTOTALK_TEST_WM=1 .venv/bin/python \ + docs/reviews/evidence/2026-10-01-settings-refactor/full-tests.py pr-b-commit +``` + +The driver executes `python -m pytest -q` first on `.venv/bin/python` (stock Qt +6.11.2), then `artifacts/open-tasks/venv-qt610/bin/python` (native Qt 6.10.2/Breeze). +Its exact environment and subprocess commands are checked in with the evidence. + +| Acceptance | Verification | +| --- | --- | +| B-1 | Schema completeness/scopes and generic single-owner widget-property tests. | +| B-2 | All three draft scopes preserve live/default state, widget snapshots, manifest bytes/mtime and zero main refreshes per edit. | +| B-3 | Randomized ordered language/reset/voice/delivery/background/conference replay; pin/clips tests; one refresh, mix refresh and save; stale notices. | +| B-4 | Cancel preserves unsaved edits/windowModified and disk; deletes session-created unreferenced media only. | +| B-5 | Real asynchronous mocked-result conference/background/clip jobs never save mid-session; Cancel/late result/reopen guards. Voice import and library tests remain. | +| B-6 | Immediate Preferences display/account/model/policies; Close only; existing GPU lifecycle tests adapted only for removed Cancel semantics. | +| B-7 | Scope-specific page lists preserve the shared order; absent pages have no rows. | +| B-8 | Generic source/Reset fields and inspector share one builder; explicit zero and equal-to-parent overrides persist. | +| B-9 | Pending jobs disable Save, not Cancel; stale generation refuses Save; retry after failed disk write. | +| B-10 | Table-driven menu routes plus direct buttons, Language options and all scoped links; correct page/focus. | +| B-11 | Benchmark includes dialog edit/Save; zero live refresh/configuration/readiness queries on a draft edit. | +| B-12 | New tab baseline derives from actual schema layouts; same baseline passes both Qt builds. | +| B-13 | PR A navigation, shortcut, stale-audio and surface-equivalence tests remain; pause helper follows the replacement Reset row. Modal guards include Preferences. | + +The narrow controller AST test covers all six settings/component modules and the +inspector. No method in the new settings modules exceeds CC 20. This verification +uses fake network/model results; it does not claim a new real GPU synthesis, +Wayland recording or Windows/macOS test. + +## Screenshots and interaction review + +[Evidence](evidence/2026-10-01-settings-refactor/screens) contains 65 screenshots per +Qt environment: main Prepared/Quick/Realtime at 940×680 and 1100×850, every visible +page of every scope and Preferences at 760×580 and 920×760 (including scrolled +parts), and inspector/recording in light/dark at 100% and 150%. All 130 were +visually inspected using contact sheets, with full-size inspection of small-dialog +voice and recording rows. QtTest exercises the actual widget click/key paths. +The capture script asserts requested size, accessible footer buttons, and no +horizontal scrolling in dialogs. Vertical scrolling is intentional for long pages +and for the expanded inspector at the minimum main-window size. + +Intended differences: separate Preferences; the ordered schema pages; Slide flow +and Slide sound naming; source/Reset controls; no nested document transactions; +page-specific footer and audition; Save and fit; voice-design age with the other +vocal attributes; recording-policy wording shortened to “Skip pauses, buffering +and time outside fullscreen” without changing its value. Native QFormLayout wraps +long rows. Widget style and palette are unchanged. Pixel identity is not expected +for this UX refactor. + +Commands (run once with each Python/Qt environment; native adds the environment below): + +```sh +AUTOTALK_SCREEN_OUTPUT="$PWD/artifacts/settings-refactor/screens-wheel-release" \ +QT_QPA_PLATFORM=xcb xvfb-run -a dbus-run-session -- .venv/bin/python \ + docs/reviews/evidence/2026-09-30-ui-structure-perf/xvfb-session.py \ + .venv/bin/python docs/reviews/evidence/2026-10-01-settings-refactor/screens.py + +AUTOTALK_SCREEN_OUTPUT="$PWD/artifacts/settings-refactor/screens-wheel-release" \ +QT_SCALE_FACTOR=1.5 QT_QPA_PLATFORM=xcb \ +xvfb-run -a -s '-screen 0 1920x1440x24' dbus-run-session -- .venv/bin/python \ + docs/reviews/evidence/2026-09-30-ui-structure-perf/xvfb-session.py \ + .venv/bin/python docs/reviews/evidence/2026-10-01-settings-refactor/screens.py +``` + +For native runs, replace both Python paths with +`artifacts/open-tasks/venv-qt610/bin/python`, change the output suffix to `native-release`, +and set: + +```sh +PYTHONPATH="$PWD/build/system-qt:$PWD/src" +LD_LIBRARY_PATH="$PWD/build/system-qt/PySide6/Qt/lib:$PWD/artifacts/open-tasks/venv-qt610/lib/python3.12/site-packages/shiboken6" +``` + +## Failed attempts and corrections + +The [attempt logs](evidence/2026-10-01-settings-refactor/attempts) preserve earlier +full runs, including failures (trailing whitespace stripped for repository hygiene). They +are not the final release evidence. + +- Initial migration: 88 failed, 430 passed. Most exercised removed widget paths + or live-edit semantics; component paths and draft assertions replaced those + assumptions while keeping persistence, language, voice and lifecycle coverage. +- Second run: collection failed because a removed pause-checkbox helper left an + empty function body. The helper now changes the single pause spin editor. +- Third run: 21 failed, 528 passed. Remaining old dialog assumptions and component + bindings were corrected. +- Fourth runs: wheel 548 passed/1 failed; native 547 passed/2 failed. Long language + choices clipped. Native combo sizing and outer form wrapping corrected it. +- Intermediate “final” runs: wheel 550 passed; native 551 passed. Another test was + added between collection phases, so these are not the final matched result. +- Later verification: wheel 561 passed; native 560 passed/1 failed. A layout/tab + baseline changed during that run. The final production layout and reviewed + baseline were frozen before running both environments again. +- A matched pre-final run passed 561 tests on both builds (115.86/146.28 s). + Strengthened whole-project and full-widget isolation assertions then passed + 42 focused cases on each build (10.05/13.53 s), followed by the final full runs. +- Focused layout/draft check: 62 passed. A native focused check had 78 passed and + one old-tab-baseline mismatch before regeneration. +- A screenshot attempt exposed horizontal overflow on the longest recording + option. A nested per-field form removed overflow but made controls cramped; + visual inspection rejected it. The final row keeps ordinary horizontal controls, + uses the existing outer form wrapping and shortens that option's wording. +- Initial 150% capture used Xvfb's small default screen and the window manager + constrained the requested size. The final scaled run uses a 1920×1440 screen. +- Private desktop services log portal registration warnings and KDE portal crashes + at D-Bus/Xvfb shutdown, after pytest/capture completion. These separate-process + teardown messages are retained in the logs. + +## Benchmark, complexity and final accounting + +Stock Qt 6.11.2, offscreen, synthetic prepared talks, 15 repeats per action. +Before and after were run serially on this machine after test/screenshot jobs +finished. The baseline is PR A plus the adapted benchmark tool; its patch and +complete JSON query-body counts are included in the evidence. The two temporary +baseline worktrees were removed after measurement. To reproduce, create a detached +worktree at `c109626` and apply `baseline-bench.patch` there. + +```sh +QT_QPA_PLATFORM=offscreen .venv/bin/python \ + artifacts/settings-refactor/base/tools/bench_ui.py --repeats 15 +QT_QPA_PLATFORM=offscreen .venv/bin/python tools/bench_ui.py --repeats 15 +PYTHONPATH=artifacts/ui-structure-perf/analysis-tools .venv/bin/python -m radon cc \ + src/autotalk/settings.py src/autotalk/settings_schema.py \ + src/autotalk/settings_fields.py src/autotalk/settings_components.py \ + src/autotalk/options.py src/autotalk/preferences.py -s +``` + +| Slides / operation | Before ms | After ms | Speedup | Live refreshes after | Ready body evaluations after | +| --- | ---: | ---: | ---: | ---: | ---: | +| 20 / dialog edit | 6.539 | 0.329 | 19.9× | 0 | 0 | +| 60 / dialog edit | 12.998 | 0.340 | 38.2× | 0 | 0 | +| 20 / dialog Save | 8.125 | 6.097 | 1.33× | 1 | 20 | +| 60 / dialog Save | 18.242 | 12.693 | 1.44× | 1 | 60 | + +The edit benchmark changes audience in the Talk page. Other pages can perform +local draft work (for example measured timing or voice previews), but never +refresh the live window per edit. Save includes replay, readiness comparison, +audio/UI update and persistence. The metadata edit does not invalidate audio, +so its two comparisons reuse one memoized set; audio-affecting actions use the +approved maximum of two readiness passes (120 body evaluations at 60 slides). +After/pause/include/record/mode remain within that limit; budget and duration use +60. Every main/inspector edit has one full refresh and zero dialog reloads. + +Maximum method complexity is **CC 18** (`PreferencesDialog.sync_engine`); +`VoiceEditor.sync` is CC 16, dialog build/sync CC 14, all within the requested 20. +No runtime dependencies changed. + +- Full stock Qt 6.11.2 suite: **561 passed**, 115.25 s. +- Full native Qt 6.10.2/Breeze suite: **561 passed**, 145.92 s. +- Physical audio comparison: all four ALSA sinks and six ALSA sources retain + their mute, volume and active-port values. The null sink is removed afterwards. +- Production from `c109626`, including the benchmark: **+1243 / −1137 = +106**, + below the +150 ceiling. Most growth is the explicit draft job/change-log + lifecycle and the separate immediate Preferences window. They replace rollback, + cross-scope reloads and the old field/type-switch machinery; no parallel legacy + implementation remains. +- Tests from `c109626`: **+751 / −407 = +344**. No existing test functions + were removed. Draft isolation compares all main-window widget states, and + randomized replay compares the whole project after normalizing version IDs. +- All source/test/doc whitespace checks pass. Evidence logs have trailing spaces + stripped; messages and results are otherwise preserved. diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-native-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-native-full.log new file mode 100644 index 0000000..5e42165 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-native-full.log @@ -0,0 +1,47 @@ +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3446256 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3446256 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3446281]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3446256 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3446256 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3446281]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3446324 comm="artifacts/open-tasks/venv-qt610/bin/python -m pyte" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3446358 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3446358 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3446358): xdg-desktop-portal-WARNING **: 23:46:25.651: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3446358 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3446358 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3446358 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3446256 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3446253]: [session uid=1000 pid=3446253 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +........................................................................ [ 52%] +........................................................................ [ 65%] +....................................................................... +(/usr/libexec/xdg-desktop-portal:3446358): xdg-desktop-portal-WARNING **: 23:48:12.005: Realtime error: Could not map tid 3448447 of pid 3446324: Process ids could not be found: 3448447 +. [ 78%] +........................................................................ [ 91%] +............................................... [100%] +551 passed in 144.70s (0:02:24) +A connection to the bus can't be made + +(xfconfd:3446299): xfconfd-WARNING **: 23:48:50.319: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-wheel-full.log new file mode 100644 index 0000000..fcd0766 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/final-wheel-full.log @@ -0,0 +1,47 @@ +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3443589 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3443589 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3443604]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3443589 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3443589 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3443604]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3443655 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3443686 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3443686 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3443686): xdg-desktop-portal-WARNING **: 23:44:33.326: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3443686 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3443686 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3443686 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3443589 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3443587]: [session uid=1000 pid=3443587 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +........................................................................ [ 52%] +........................................................................ [ 65%] +...................................................................... +(/usr/libexec/xdg-desktop-portal:3443686): xdg-desktop-portal-WARNING **: 23:45:54.558: Realtime error: Could not map tid 3445643 of pid 3443655: Process ids could not be found: 3445643 +.. [ 78%] +........................................................................ [ 91%] +.............................................. [100%] +550 passed in 111.44s (0:01:51) +A connection to the bus can't be made + +(xfconfd:3443622): xfconfd-WARNING **: 23:46:24.749: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-native-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-native-full.log new file mode 100644 index 0000000..fffc034 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-native-full.log @@ -0,0 +1,127 @@ +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3440068 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3440068 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3440088]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3440068 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3440068 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3440088]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3440134 comm="artifacts/open-tasks/venv-qt610/bin/python -m pyte" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3440169 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3440169 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3440169): xdg-desktop-portal-WARNING **: 23:40:00.332: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3440169 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3440169 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3440169 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3440068 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3440066]: [session uid=1000 pid=3440066 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +........................................................................ [ 52%] +........................................................................ [ 65%] +..................................................................... +(/usr/libexec/xdg-desktop-portal:3440169): xdg-desktop-portal-WARNING **: 23:41:43.805: Realtime error: Could not map tid 3442460 of pid 3440134: Process ids could not be found: 3442460 +... [ 78%] +........................................................FF.............. [ 91%] +............................................. [100%] +=================================== FAILURES =================================== +_________ test_language_arrangement_choices_fit_native_field[size0-0] __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-401/test_language_arrangement_choi0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 0, size = (760, 580) + + @pytest.mark.parametrize('scope', [0, 1]) + @pytest.mark.parametrize('size', [(760, 580), (920, 760)]) + def test_language_arrangement_choices_fit_native_field(qtbot, project, scope, size): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.resize(*size) + combo = dialog.fields['language_policy'].editor + scroll = dialog.pages.currentWidget() + for index in range(combo.count()): + scroll.ensureWidgetVisible(combo) + combo.setFocus() + qtbot.keyClick(combo, Qt.Key_Home) + for _ in range(index): + qtbot.keyClick(combo, Qt.Key_Down) + qtbot.wait(20) + assert combo.currentIndex() == index + assert dialog.setting_value('language_policy') == combo.itemData(index) + option = QStyleOptionComboBox(); combo.initStyleOption(option) + field = combo.style().subControlRect(QStyle.CC_ComboBox, option, QStyle.SC_ComboBoxEditField, combo) +> assert combo.fontMetrics().horizontalAdvance(combo.currentText()) <= field.width() +E AssertionError: assert 236 <= 210 +E + where 236 = ('Separate versions and mixed passages') +E + where = .horizontalAdvance +E + where = () +E + where = .fontMetrics +E + and 'Separate versions and mixed passages' = () +E + where = .currentText +E + and 210 = () +E + where = PySide6.QtCore.QRect(6, 6, 210, 20).width + +tests/test_ux_placement.py:106: AssertionError +_________ test_language_arrangement_choices_fit_native_field[size0-1] __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-401/test_language_arrangement_choi1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 1, size = (760, 580) + + @pytest.mark.parametrize('scope', [0, 1]) + @pytest.mark.parametrize('size', [(760, 580), (920, 760)]) + def test_language_arrangement_choices_fit_native_field(qtbot, project, scope, size): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.resize(*size) + combo = dialog.fields['language_policy'].editor + scroll = dialog.pages.currentWidget() + for index in range(combo.count()): + scroll.ensureWidgetVisible(combo) + combo.setFocus() + qtbot.keyClick(combo, Qt.Key_Home) + for _ in range(index): + qtbot.keyClick(combo, Qt.Key_Down) + qtbot.wait(20) + assert combo.currentIndex() == index + assert dialog.setting_value('language_policy') == combo.itemData(index) + option = QStyleOptionComboBox(); combo.initStyleOption(option) + field = combo.style().subControlRect(QStyle.CC_ComboBox, option, QStyle.SC_ComboBoxEditField, combo) +> assert combo.fontMetrics().horizontalAdvance(combo.currentText()) <= field.width() +E AssertionError: assert 236 <= 231 +E + where 236 = ('Separate versions and mixed passages') +E + where = .horizontalAdvance +E + where = () +E + where = .fontMetrics +E + and 'Separate versions and mixed passages' = () +E + where = .currentText +E + and 231 = () +E + where = PySide6.QtCore.QRect(6, 6, 231, 20).width + +tests/test_ux_placement.py:106: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_ux_placement.py::test_language_arrangement_choices_fit_native_field[size0-0] +FAILED tests/test_ux_placement.py::test_language_arrangement_choices_fit_native_field[size0-1] +2 failed, 547 passed in 141.60s (0:02:21) + +(xfconfd:3440105): xfconfd-WARNING **: 23:42:21.952: Name org.xfce.Xfconf lost on the message dbus, exiting. +A connection to the bus can't be made +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-wheel-full.log new file mode 100644 index 0000000..33d697a --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/fourth-wheel-full.log @@ -0,0 +1,88 @@ +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3437442 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3437442 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3437457]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3437442 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3437442 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3437457]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3437504 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3437543 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3437543 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3437543): xdg-desktop-portal-WARNING **: 23:38:09.075: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3437543 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3437543 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3437543 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3437442 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3437440]: [session uid=1000 pid=3437440 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +........................................................................ [ 52%] +........................................................................ [ 65%] +..................................................................... +(/usr/libexec/xdg-desktop-portal:3437543): xdg-desktop-portal-WARNING **: 23:39:29.825: Realtime error: Could not map tid 3439390 of pid 3437504: Process ids could not be found: 3439390 +... [ 78%] +........................................................F............... [ 91%] +............................................. [100%] +=================================== FAILURES =================================== +_________ test_language_arrangement_choices_fit_native_field[size0-0] __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-400/test_language_arrangement_choi0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 0, size = (760, 580) + + @pytest.mark.parametrize('scope', [0, 1]) + @pytest.mark.parametrize('size', [(760, 580), (920, 760)]) + def test_language_arrangement_choices_fit_native_field(qtbot, project, scope, size): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.resize(*size) + combo = dialog.fields['language_policy'].editor + scroll = dialog.pages.currentWidget() + for index in range(combo.count()): + scroll.ensureWidgetVisible(combo) + combo.setFocus() + qtbot.keyClick(combo, Qt.Key_Home) + for _ in range(index): + qtbot.keyClick(combo, Qt.Key_Down) + qtbot.wait(20) + assert combo.currentIndex() == index + assert dialog.setting_value('language_policy') == combo.itemData(index) + option = QStyleOptionComboBox(); combo.initStyleOption(option) + field = combo.style().subControlRect(QStyle.CC_ComboBox, option, QStyle.SC_ComboBoxEditField, combo) +> assert combo.fontMetrics().horizontalAdvance(combo.currentText()) <= field.width() +E AssertionError: assert 235 <= 222 +E + where 235 = ('Separate versions; one language per slide') +E + where = .horizontalAdvance +E + where = () +E + where = .fontMetrics +E + and 'Separate versions; one language per slide' = () +E + where = .currentText +E + and 222 = () +E + where = PySide6.QtCore.QRect(4, 2, 222, 28).width + +tests/test_ux_placement.py:106: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_ux_placement.py::test_language_arrangement_choices_fit_native_field[size0-0] +1 failed, 548 passed in 110.75s (0:01:50) + +(xfconfd:3437475): xfconfd-WARNING **: 23:39:59.483: Name org.xfce.Xfconf lost on the message dbus, exiting. +A connection to the bus can't be made +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/initial-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/initial-wheel-full.log new file mode 100644 index 0000000..7049949 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/initial-wheel-full.log @@ -0,0 +1,2618 @@ +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3429979 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3429979 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3429994]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3429979 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3429979 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3429994]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3430040 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3430077 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3430077 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3430077): xdg-desktop-portal-WARNING **: 23:24:57.033: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3430077 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3430077 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3430077 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3429979 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3429960]: [session uid=1000 pid=3429960 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +...F..................................FFF...FF...FF..F...F.............. [ 13%] +..................F............................FFF.......FFF............ [ 27%] +.............................F.......................................... [ 41%] +............................................F........F.......FF......... [ 55%] +....FF.....F.......F........FFFFFFFFFFFFFFFF.F......F................... [ 69%] +.................FFFF.FFF.......F..... +(/usr/libexec/xdg-desktop-portal:3430077): xdg-desktop-portal-WARNING **: 23:26:09.005: Realtime error: Could not map tid 3431708 of pid 3430040: Process ids could not be found: 3431708 +......FFFFF.F....FFF.............. [ 83%] +..FFFFF.......F.........F......F.FFF.........FFFFFFF..FFFF.FF.F......FF. [ 97%] +.............. [100%] +=================================== FAILURES =================================== +___________ test_settings_cancel_restores_versions_and_source_edits ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_settings_cancel_restores_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_settings_cancel_restores_versions_and_source_edits(qtbot, project): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.narration.setPlainText('My unsaved English edit.') + project.version.language = 'English' + before = copy.deepcopy(project) + w.defaults['language'] = 'German'; w.apply_defaults(project) + dialog = w.settings[1]; dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() +> assert len(project.versions) == 2 +E AssertionError: assert 1 == 2 +E + where 1 = len({'main': TalkVersion(name='', language='English', slides=[Slide(page=1, source_text='Reliable presentations\r\nOne con...256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', audio_provenance={}, duration=0.3, clips=[])])}) +E + where {'main': TalkVersion(name='', language='English', slides=[Slide(page=1, source_text='Reliable presentations\r\nOne con...256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', audio_provenance={}, duration=0.3, clips=[])])} = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_settings_cancel_restores_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}).versions + +tests/test_automatic_languages.py:71: AssertionError +____ test_discovery_updates_ui_without_changing_saved_selections[Prepared] _____ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with0/codex') +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with0/talk'), title='slides', target_m...tion='', references={}, origin='reference'), 'mode': 'Prepared', 'codex_model': 'test-vision', 'codex_effort': 'high'}) +mode = 'Prepared' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_discovery_updates_ui_without_changing_saved_selections(fake_server, qtbot, project, mode): + from autotalk.app import MainWindow + from autotalk.project import Project + project.mode, project.codex_model, project.codex_effort = mode, 'test-vision', 'high' + project.save() + window = MainWindow(); qtbot.addWidget(window); window.adopt(project) + window.connect_chatgpt(interactive=False) + qtbot.waitUntil(lambda: window.job is None) + assert 'Connected to ChatGPT' in window.preferences.connection.text() + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.itemText(0) == 'Account default (Vision)' + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + assert window.settings[1].models.codex_model.isEnabled() + saved = Project.load(project.manifest) +> assert (saved.models.codex_model, saved.models.codex_effort) == ('test-vision', 'high') + ^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'models'. Did you mean: 'mode'? + +tests/test_codex.py:186: AttributeError +______ test_discovery_updates_ui_without_changing_saved_selections[Quick] ______ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with1/codex') +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with1/talk'), title='slides', target_m...ription='', references={}, origin='reference'), 'mode': 'Quick', 'codex_model': 'test-vision', 'codex_effort': 'high'}) +mode = 'Quick' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_discovery_updates_ui_without_changing_saved_selections(fake_server, qtbot, project, mode): + from autotalk.app import MainWindow + from autotalk.project import Project + project.mode, project.codex_model, project.codex_effort = mode, 'test-vision', 'high' + project.save() + window = MainWindow(); qtbot.addWidget(window); window.adopt(project) + window.connect_chatgpt(interactive=False) + qtbot.waitUntil(lambda: window.job is None) + assert 'Connected to ChatGPT' in window.preferences.connection.text() + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.itemText(0) == 'Account default (Vision)' + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + assert window.settings[1].models.codex_model.isEnabled() + saved = Project.load(project.manifest) +> assert (saved.models.codex_model, saved.models.codex_effort) == ('test-vision', 'high') + ^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'models'. Did you mean: 'mode'? + +tests/test_codex.py:186: AttributeError +____ test_discovery_updates_ui_without_changing_saved_selections[Realtime] _____ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with2/codex') +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_discovery_updates_ui_with2/talk'), title='slides', target_m...tion='', references={}, origin='reference'), 'mode': 'Realtime', 'codex_model': 'test-vision', 'codex_effort': 'high'}) +mode = 'Realtime' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_discovery_updates_ui_without_changing_saved_selections(fake_server, qtbot, project, mode): + from autotalk.app import MainWindow + from autotalk.project import Project + project.mode, project.codex_model, project.codex_effort = mode, 'test-vision', 'high' + project.save() + window = MainWindow(); qtbot.addWidget(window); window.adopt(project) + window.connect_chatgpt(interactive=False) + qtbot.waitUntil(lambda: window.job is None) + assert 'Connected to ChatGPT' in window.preferences.connection.text() + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.itemText(0) == 'Account default (Vision)' + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + assert window.settings[1].models.codex_model.isEnabled() + saved = Project.load(project.manifest) +> assert (saved.models.codex_model, saved.models.codex_effort) == ('test-vision', 'high') + ^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'models'. Did you mean: 'mode'? + +tests/test_codex.py:186: AttributeError +_ test_application_startup_discovers_without_pdf_and_preserves_launch_project[False] _ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_startup_disco0/codex') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f097771d5e0> +qtbot = +qapp = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_startup_disco0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +open_saved = False + + @pytest.mark.parametrize('open_saved', [False, True]) + def test_application_startup_discovers_without_pdf_and_preserves_launch_project(fake_server, monkeypatch, qtbot, qapp, project, open_saved): + from types import SimpleNamespace + from autotalk import app + windows = [] + original = app.MainWindow + def window(): + w = original(); qtbot.addWidget(w); windows.append(w); return w + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].codex_model.count() == 2 + assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + windows[0].close() + return 0 + project.save() + monkeypatch.setattr(app, 'MainWindow', window) + monkeypatch.setattr(app, 'QApplication', lambda *_: SimpleNamespace( + setApplicationName=lambda *_: None, setOrganizationName=lambda *_: None, exec=event_loop)) + app.QApplication.activeModalWidget = qapp.activeModalWidget + monkeypatch.setattr(sys, 'argv', ['autotalk'] + ([str(project.manifest)] if open_saved else [])) +> assert app.main() == 0 + ^^^^^^^^^^ + +tests/test_codex.py:238: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/app.py:1394: in main + return app.exec() + ^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') +> assert windows[0].settings[1 if open_saved else 0].codex_model.count() == 2 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'TalkDefaultsDialog' object has no attribute 'codex_model'. Did you mean: 'voice_model'? + +tests/test_codex.py:228: AttributeError +_ test_application_startup_discovers_without_pdf_and_preserves_launch_project[True] _ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_startup_disco1/codex') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0977bf2630> +qtbot = +qapp = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_startup_disco1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +open_saved = True + + @pytest.mark.parametrize('open_saved', [False, True]) + def test_application_startup_discovers_without_pdf_and_preserves_launch_project(fake_server, monkeypatch, qtbot, qapp, project, open_saved): + from types import SimpleNamespace + from autotalk import app + windows = [] + original = app.MainWindow + def window(): + w = original(); qtbot.addWidget(w); windows.append(w); return w + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].codex_model.count() == 2 + assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + windows[0].close() + return 0 + project.save() + monkeypatch.setattr(app, 'MainWindow', window) + monkeypatch.setattr(app, 'QApplication', lambda *_: SimpleNamespace( + setApplicationName=lambda *_: None, setOrganizationName=lambda *_: None, exec=event_loop)) + app.QApplication.activeModalWidget = qapp.activeModalWidget + monkeypatch.setattr(sys, 'argv', ['autotalk'] + ([str(project.manifest)] if open_saved else [])) +> assert app.main() == 0 + ^^^^^^^^^^ + +tests/test_codex.py:238: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/app.py:1394: in main + return app.exec() + ^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') +> assert windows[0].settings[1 if open_saved else 0].codex_model.count() == 2 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'TalkSettingsDialog' object has no attribute 'codex_model'. Did you mean: 'voice_model'? + +tests/test_codex.py:228: AttributeError +______ test_auth_buttons_follow_account_and_logout_preserves_talk[False] _______ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_auth_buttons_follow_accou0/codex') +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_auth_buttons_follow_accou0/talk'), title='slides', target_m...aker='Ryan', description='', references={}, origin='reference'), 'codex_model': 'test-vision', 'codex_effort': 'high'}) +signed_in = False + + @pytest.mark.parametrize('signed_in', [False, True]) + def test_auth_buttons_follow_account_and_logout_preserves_talk(fake_server, qtbot, project, signed_in): + from PySide6.QtCore import Qt + from autotalk.app import MainWindow + from autotalk.project import Project + project.codex_model, project.codex_effort = 'test-vision', 'high' + project.save() + healthy = fake_server.read_text() + if not signed_in: + fake_server.write_text(healthy.replace("{'account':{'type':'chatgpt','planType':'plus'}}", "{'account':None}")) + window = MainWindow(); qtbot.addWidget(window); window.adopt(project) + window.connect_chatgpt(interactive=False) + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + qtbot.waitUntil(lambda: window.job is None) + assert window.preferences.codex_signin.isEnabled() == (not signed_in) + assert window.preferences.codex_signout.isEnabled() == signed_in + if not signed_in: + fake_server.write_text(healthy) + qtbot.mouseClick(window.preferences.codex_signin, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: window.job is None) + assert not window.preferences.codex_signin.isEnabled() and window.preferences.codex_signout.isEnabled() + before = len(requests(fake_server)) + qtbot.mouseClick(window.preferences.codex_signout, Qt.MouseButton.LeftButton) + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + qtbot.waitUntil(lambda: window.job is None) + assert window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + assert not window.codex_settings['signed_in'] and window.codex_settings['models'] == [] + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + saved = Project.load(project.manifest) +> assert (saved.models.codex_model, saved.models.codex_effort) == ('test-vision', 'high') + ^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'models'. Did you mean: 'mode'? + +tests/test_codex.py:316: AttributeError +_______ test_auth_buttons_follow_account_and_logout_preserves_talk[True] _______ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-396/test_auth_buttons_follow_accou1/codex') +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_auth_buttons_follow_accou1/talk'), title='slides', target_m...aker='Ryan', description='', references={}, origin='reference'), 'codex_model': 'test-vision', 'codex_effort': 'high'}) +signed_in = True + + @pytest.mark.parametrize('signed_in', [False, True]) + def test_auth_buttons_follow_account_and_logout_preserves_talk(fake_server, qtbot, project, signed_in): + from PySide6.QtCore import Qt + from autotalk.app import MainWindow + from autotalk.project import Project + project.codex_model, project.codex_effort = 'test-vision', 'high' + project.save() + healthy = fake_server.read_text() + if not signed_in: + fake_server.write_text(healthy.replace("{'account':{'type':'chatgpt','planType':'plus'}}", "{'account':None}")) + window = MainWindow(); qtbot.addWidget(window); window.adopt(project) + window.connect_chatgpt(interactive=False) + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + qtbot.waitUntil(lambda: window.job is None) + assert window.preferences.codex_signin.isEnabled() == (not signed_in) + assert window.preferences.codex_signout.isEnabled() == signed_in + if not signed_in: + fake_server.write_text(healthy) + qtbot.mouseClick(window.preferences.codex_signin, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: window.job is None) + assert not window.preferences.codex_signin.isEnabled() and window.preferences.codex_signout.isEnabled() + before = len(requests(fake_server)) + qtbot.mouseClick(window.preferences.codex_signout, Qt.MouseButton.LeftButton) + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + qtbot.waitUntil(lambda: window.job is None) + assert window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() + assert not window.codex_settings['signed_in'] and window.codex_settings['models'] == [] + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + saved = Project.load(project.manifest) +> assert (saved.models.codex_model, saved.models.codex_effort) == ('test-vision', 'high') + ^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'models'. Did you mean: 'mode'? + +tests/test_codex.py:316: AttributeError +_____________ test_inspector_edits_only_owned_field[pause_seconds] _____________ + +qtbot = +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_inspector_edits_only_owne1') +field = 'pause_seconds' + + @pytest.mark.parametrize('field', ['after', 'pause_seconds', 'budget_seconds', 'included']) + def test_inspector_edits_only_owned_field(qtbot, tmp_path, field): + w = window(qtbot, tmp_path) + p = w.project; slide = p.slides[0] + slide.overrides['delivery.instructions'] = 'Keep this direction' + before = asdict(p) +> edit(w, field) + +tests/test_edit_navigation.py:52: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +w = +field = 'pause_seconds' + + def edit(w, field): + inspector = w.inspector + if field == 'after': + inspector.slide_after.setCurrentIndex(inspector.slide_after.findData('pause')) + elif field == 'pause_seconds': +> inspector.inherit_pause.setChecked(False) + ^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'InspectorPanel' object has no attribute 'inherit_pause' + +tests/test_edit_navigation.py:33: AttributeError +_ test_edit_has_one_refresh_no_hidden_loads_and_two_readiness_passes[pause_seconds] _ + +qtbot = +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_edit_has_one_refresh_no_h1') +field = 'pause_seconds' + + @pytest.mark.parametrize('field', ['after', 'pause_seconds', 'budget_seconds', 'included', 'duration', 'record', 'mode', 'quick']) + def test_edit_has_one_refresh_no_hidden_loads_and_two_readiness_passes(qtbot, tmp_path, field): + w = window(qtbot, tmp_path, 60) + codes = {inspect.unwrap(method).__code__: name for name, method in + [('refresh', MainWindow.refresh), ('loads', ScopedSettingsDialog.load_settings), + ('ready', Project.ready), ('changed', MainWindow.configuration_changed)]} + counts = Counter() + def trace(frame, event, arg): + if event == 'call' and frame.f_code in codes: + counts[codes[frame.f_code]] += 1 + sys.setprofile(trace) + try: +> edit(w, field) + +tests/test_edit_navigation.py:77: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +w = +field = 'pause_seconds' + + def edit(w, field): + inspector = w.inspector + if field == 'after': + inspector.slide_after.setCurrentIndex(inspector.slide_after.findData('pause')) + elif field == 'pause_seconds': +> inspector.inherit_pause.setChecked(False) + ^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'InspectorPanel' object has no attribute 'inherit_pause' + +tests/test_edit_navigation.py:33: AttributeError +__________ test_saved_preferences_and_cancel_do_not_undo_manual_load ___________ + +engine = PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_preferences_and_can0/worker.py') +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_preferences_and_can0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +qtbot = + + def test_saved_preferences_and_cancel_do_not_undo_manual_load(engine, project, qtbot): + w = MainWindow(); qtbot.addWidget(w, before_close_func=lambda widget: finish_job(widget, qtbot)); w.adopt(project); w.show() + w.preferences.show_section("Speech engine") + select_policy(w, 'gpu_retention', 'operation') +> assert w.speech.retention == 'session' and 'unsaved' in w.preferences.engine_policy_note.text() +E AssertionError: assert ('operation' == 'session' +E +E - session +E + operation) + +tests/test_gpu_lifecycle.py:143: AssertionError +_____________ test_language_cancel_and_reset_restore_readiness[0] ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_cancel_and_reset0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 0 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_cancel_and_reset_restore_readiness(qtbot, project, scope): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if scope == 1: + w.language.setCurrentText('German') + before = copy.deepcopy(w.project.versions) + selected = w.project.active_version + dialog = w.settings[1]; dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() + assert w.project.prepared and w.narration_progress.value() == 2 + dialog.reject() + assert w.project.versions == before and w.project.active_version == selected + assert not w.project.prepared and not w.narration.toPlainText() + dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() + dialog.accept() + assert w.project.prepared and w.narration_progress.value() == 2 + return + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') +> assert w.narration_progress.value() == (1 if scope == 2 else 0) +E assert 2 == 0 +E + where 2 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:96: AssertionError +_____________ test_language_cancel_and_reset_restore_readiness[1] ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_cancel_and_reset1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 1 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_cancel_and_reset_restore_readiness(qtbot, project, scope): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if scope == 1: + w.language.setCurrentText('German') + before = copy.deepcopy(w.project.versions) + selected = w.project.active_version + dialog = w.settings[1]; dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() +> assert w.project.prepared and w.narration_progress.value() == 2 +E AssertionError: assert (False) +E + where False = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_cancel_and_reset1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}).prepared +E + where Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_cancel_and_reset1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) = .project + +tests/test_language_changes.py:84: AssertionError +_____________ test_language_cancel_and_reset_restore_readiness[2] ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_cancel_and_reset2/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 2 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_cancel_and_reset_restore_readiness(qtbot, project, scope): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if scope == 1: + w.language.setCurrentText('German') + before = copy.deepcopy(w.project.versions) + selected = w.project.active_version + dialog = w.settings[1]; dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() + assert w.project.prepared and w.narration_progress.value() == 2 + dialog.reject() + assert w.project.versions == before and w.project.active_version == selected + assert not w.project.prepared and not w.narration.toPlainText() + dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() + dialog.accept() + assert w.project.prepared and w.narration_progress.value() == 2 + return + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') +> assert w.narration_progress.value() == (1 if scope == 2 else 0) +E assert 2 == 1 +E + where 2 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:96: AssertionError +____________ test_language_arrangement_caption_follows_its_scope[0] ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_arrangement_capt0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 0 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_arrangement_caption_follows_its_scope(qtbot, project, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope]; dialog.show_section('Voice & language') +> container, _ = dialog.options.inheritance['language_policy'] + ^^^^^^^^^^^^^^ +E AttributeError: 'TalkDefaultsDialog' object has no attribute 'options'. Did you mean: 'actions'? + +tests/test_language_changes.py:199: AttributeError +____________ test_language_arrangement_caption_follows_its_scope[1] ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_arrangement_capt1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 1 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_arrangement_caption_follows_its_scope(qtbot, project, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope]; dialog.show_section('Voice & language') +> container, _ = dialog.options.inheritance['language_policy'] + ^^^^^^^^^^^^^^ +E AttributeError: 'TalkSettingsDialog' object has no attribute 'options'. Did you mean: 'actions'? + +tests/test_language_changes.py:199: AttributeError +____________ test_language_arrangement_caption_follows_its_scope[2] ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_language_arrangement_capt2/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 2 + + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_arrangement_caption_follows_its_scope(qtbot, project, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope]; dialog.show_section('Voice & language') +> container, _ = dialog.options.inheritance['language_policy'] + ^^^^^^^^^^^^^^ +E AttributeError: 'SlideSettingsDialog' object has no attribute 'options'. Did you mean: 'actions'? + +tests/test_language_changes.py:199: AttributeError +_ test_model_progress_is_distinct_from_concurrent_narration_and_follows_dialog _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_model_progress_is_distinc0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_model_progress_is_distinct_from_concurrent_narration_and_follows_dialog(qtbot, project): + from queue import Queue, Empty + from PySide6.QtWidgets import QDialogButtonBox + commands = Queue() + w = MainWindow(); w.adopt(project); w.show() + def finish(widget): + if widget.job: + widget.cancel(); qtbot.waitUntil(lambda: widget.job is None) + qtbot.addWidget(w, before_close_func=finish) + def work(task): + while not task.cancelled.is_set(): + try: event = commands.get(timeout=.05) + except Empty: continue + if event is None: return + task.event(event) + w.start_job('Loading speech model…', work, preserve_playback=True) + assert w.progress.maximum() == 1 and w.progress.isTextVisible() + commands.put({'type':'model_progress','stage':'Checking model files','completed':4,'total':13,'unit':'files'}) + qtbot.waitUntil(lambda: w.progress.value() == 4) + assert w.progress.maximum() == 13 and '4 / 13 files' in w.progress.text() + assert '%' not in w.progress.text() + commands.put({'type':'progress','stage':'Narration','completed':1,'total':2}) + qtbot.waitUntil(lambda: w.narration_progress.value() == 1) + assert 'Checking model files' in w.progress.text() + commands.put({'type':'model_progress','stage':'Loading model into GPU and starting speech engine'}) + qtbot.waitUntil(lambda: 'Progress not reported' in w.progress.text()) + assert w.progress.minimum() == 0 and w.progress.maximum() == 1 and w.progress.value() == 0 + w.settings[0].show_section("AI model") + assert w.progress.parentWidget() is w.settings[0] and w.progress.isVisible() + w.log_message('Codex is creating narration…'); w.update_timing() + assert 'Loading model into GPU' in w.progress.text() + assert 'Codex' in w.settings[0].status.text() + assert w.settings[0].cancel_job.isVisible() + w.settings[0].buttons.button(QDialogButtonBox.StandardButton.Cancel).click() +> assert w.progress.parentWidget() is w.footer and w.progress.isVisible() +E assert ( is ) +E + where = () +E + where = .parentWidget +E + where = .progress +E + and = .footer + +tests/test_model_progress.py:120: AssertionError +________________ test_desktop_workspaces_and_remembered_policy _________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_desktop_workspaces_and_re0/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) + + def test_desktop_workspaces_and_remembered_policy(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project) + assert [a.text() for a in w.menuBar().actions()]==['File', 'Edit', 'View', 'Talk', 'Presentation', 'Settings', 'Help'] + assert w.workspace.currentWidget() is w.editor + w.mode.setCurrentText('Quick') + assert w.workspace.currentWidget() is w.quick_page + w.mode.setCurrentText('Realtime') + assert w.workspace.currentWidget() is w.editor + w.settings[0].show_section("AI model") + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value') == 'idle').click() +> assert w.speech.retention == 'session' +E AssertionError: assert 'idle' == 'session' +E +E - session +E + idle + +tests/test_refinement.py:229: AssertionError +_______________ test_accepted_designed_voice_uses_exact_preview ________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_accepted_designed_voice_u0/talk'), title='slides', target_m...ame='Ryan', source='VoiceDesign', speaker='Ryan', description='A calm warm voice', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f095445af60> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_accepted_designed_voice_u0') + + def test_accepted_designed_voice_uses_exact_preview(qtbot, project, monkeypatch, tmp_path): + from autotalk import app + from autotalk.project import Slide, file_hash + from autotalk import services + from autotalk.services import preview_text + monkeypatch.setattr(services, 'data_dir', lambda: tmp_path) + project.voice.source = 'VoiceDesign' + project.voice.description = 'A calm warm voice' + sample = Slide(0) + sample.narration = preview_text(project) + preview = tmp_path / 'previews' / (project.speech_key(sample) + '.wav') + preview.parent.mkdir() + with wave.open(str(preview), 'wb') as f: + f.setparams((1, 2, 24000, 0, 'NONE', 'not compressed')) + f.writeframes(b'\1\0' * 24000 * 4) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) +> assert w.settings[1].voice.accept_designed_voice() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests/test_refinement.py:388: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:275: in accept_designed_voice + candidate = self.voice_context() + ^^^^^^^^^^^^^^^^^^^^ +src/autotalk/settings_components.py:133: in voice_context + candidate.overrides = {key:copy.deepcopy(self.dialog.setting_value(key)) for key in SETTING_DEFAULTS if key!='language'} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'voice' + + def setting_value(self, key): + if key not in SETTING_DEFAULTS: + return getattr(self.project, key) + if self.scope == 0: + return self.project.defaults.get(key, copy.deepcopy(SETTING_DEFAULTS[key][0])) +> return self.project.setting(key, self.slide) + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:77: AttributeError +_______________ test_voice_library_preview_selection_and_cancel ________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_voice_library_preview_sel0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f095445a630> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_voice_library_preview_sel0') + + def test_voice_library_preview_selection_and_cancel(qtbot, project, monkeypatch, tmp_path): + from autotalk import voices + monkeypatch.setattr(voices, 'data_dir', lambda: tmp_path) + original = project.voice.speaker + project.voice.speaker = 'Aiden' + voices.save_voice(project, 'My conference voice') + project.voice.speaker = original + w = MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + w.settings[1].show_section("Voice & language") + w.settings[1].voice.voice_source.setCurrentIndex(3) + assert w.settings[1].voice.voice_library.rowCount() == 1 + assert w.settings[1].voice.voice_library.item(0, 0).text() == 'My conference voice' + w.settings[1].voice.library_use.click() +> assert w.project.voice.speaker == 'Aiden' +E AssertionError: assert 'Ryan' == 'Aiden' +E +E - Aiden +E + Ryan + +tests/test_refinement.py:541: AssertionError +_______ test_recording_sources_update_capabilities_and_restore_on_cancel _______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_recording_sources_update_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_recording_sources_update_capabilities_and_restore_on_cancel(qtbot, project): + w = MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + w.settings[1].show_section("Audio & recording") + w.settings[1].fields['recording_source'].editor.setCurrentIndex(1) +> assert project.recording_source == 'screen' +E AssertionError: assert 'slides' == 'screen' +E +E - screen +E + slides + +tests/test_refinement.py:552: AssertionError +_____________ test_scopes_save_cancel_and_config_separation[False] _____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_scopes_save_cancel_and_co0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +save = False + + @pytest.mark.parametrize('save', [False, True]) + def test_scopes_save_cancel_and_config_separation(qtbot, project, save): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.language.setCurrentText('French') + for scope, language in ((0, 'German'), (2, 'Spanish')): + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText(language) + if scope == 2: + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) +> assert w.project.setting('language', w.project.slides[0]) == 'Spanish' +E AssertionError: assert 'French' == 'Spanish' +E +E - Spanish +E + French + +tests/test_scoped_settings.py:50: AssertionError +_____________ test_scopes_save_cancel_and_config_separation[True] ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_scopes_save_cancel_and_co1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +save = True + + @pytest.mark.parametrize('save', [False, True]) + def test_scopes_save_cancel_and_config_separation(qtbot, project, save): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.language.setCurrentText('French') + for scope, language in ((0, 'German'), (2, 'Spanish')): + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText(language) + if scope == 2: + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) +> assert w.project.setting('language', w.project.slides[0]) == 'Spanish' +E AssertionError: assert 'French' == 'Spanish' +E +E - Spanish +E + French + +tests/test_scoped_settings.py:50: AssertionError +___________ test_designed_origin_survives_accept_library_and_reopen ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_designed_origin_survives_0/talk'), title='slides', target_m...gned narrator', source='VoiceDesign', speaker='Ryan', description='Calm and clear', references={}, origin='designed')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0945c5f0b0> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_designed_origin_survives_0') + + def test_designed_origin_survives_accept_library_and_reopen(qtbot, project, monkeypatch, tmp_path): + from autotalk import app, services, voices + monkeypatch.setenv('AUTOTALK_DATA_DIR', str(tmp_path / 'data')) + project.voice = Voice('Designed narrator', 'VoiceDesign', description='Calm and clear', origin='designed') + # A verified reference-length WAV stands in for the completed design preview. + make_audio(project, seconds=3) + audio = project.audio(project.slides[0]) +> monkeypatch.setattr(settings, 'preview_path', lambda p: audio) +E AttributeError: has no attribute 'preview_path' + +tests/test_scoped_settings.py:126: AttributeError +______________ test_editing_reference_transcript_preserves_cursor ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_editing_reference_transcr0/talk'), title='slides', target_m...a256='cb285321577f2cae96d045a03703d8dab14a24b2d1ac8a931dfcbb35f46a1f94', transcript='Original words')}, origin='own')}) + + def test_editing_reference_transcript_preserves_cursor(qtbot, project): + make_audio(project, seconds=3) + project.set_voice(project.audio(project.slides[0]), 'Original words') + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.settings[1].show_section('Voice & language') + w.settings[1].voice.transcript.moveCursor(w.settings[1].voice.transcript.textCursor().MoveOperation.End) + qtbot.keyClicks(w.settings[1].voice.transcript, ' appended') + assert w.settings[1].voice.transcript.toPlainText() == 'Original words appended' + assert w.settings[1].voice.transcript.textCursor().position() == len('Original words appended') +> assert project.voice_transcript == 'Original words appended' +E AssertionError: assert 'Original words' == 'Original words appended' +E +E - Original words appended +E ? --------- +E + Original words + +tests/test_scoped_settings.py:234: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Talk settings\u2026-Talk-1] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Talk settings…', section = 'Talk', scope = 1 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + assert dialog.scope == scope + assert dialog.windowTitle() == ('Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — 1') + assert not hasattr(dialog, 'scope_selector') + assert dialog.pages.currentWidget().isVisible() +> assert [dialog.navigation.item(i).text() for i in range(dialog.navigation.count()) if not dialog.navigation.item(i).isHidden()] == { + 0: ['Voice & language', 'Talk', 'Audio & recording', 'AI model', 'Application'], + 1: ['Voice & language', 'Talk', 'Audio & recording', 'AI model'], + 2: ['Voice & language', 'Audio & recording'], + }[scope] +E AssertionError: assert ['Talk', 'Voi...', 'AI model'] == ['Voice & lan...', 'AI model'] +E +E At index 0 diff: 'Talk' != 'Voice & language' +E Left contains 2 more items, first extra item: 'Audio & recording' +E Use -v to get more diff + +tests/test_settings_dialog.py:37: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Voice && speech\u2026-Voice & language-1] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Voice && speech…', section = 'Voice & language', scope = 1 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + assert dialog.scope == scope + assert dialog.windowTitle() == ('Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — 1') + assert not hasattr(dialog, 'scope_selector') + assert dialog.pages.currentWidget().isVisible() +> assert [dialog.navigation.item(i).text() for i in range(dialog.navigation.count()) if not dialog.navigation.item(i).isHidden()] == { + 0: ['Voice & language', 'Talk', 'Audio & recording', 'AI model', 'Application'], + 1: ['Voice & language', 'Talk', 'Audio & recording', 'AI model'], + 2: ['Voice & language', 'Audio & recording'], + }[scope] +E AssertionError: assert ['Talk', 'Voi...', 'AI model'] == ['Voice & lan...', 'AI model'] +E +E At index 0 diff: 'Talk' != 'Voice & language' +E Left contains 2 more items, first extra item: 'Audio & recording' +E Use -v to get more diff + +tests/test_settings_dialog.py:37: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Presentation settings\u2026-Audio & recording-1] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f2/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Presentation settings…', section = 'Audio & recording', scope = 1 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + assert dialog.scope == scope + assert dialog.windowTitle() == ('Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — 1') + assert not hasattr(dialog, 'scope_selector') + assert dialog.pages.currentWidget().isVisible() +> assert [dialog.navigation.item(i).text() for i in range(dialog.navigation.count()) if not dialog.navigation.item(i).isHidden()] == { + 0: ['Voice & language', 'Talk', 'Audio & recording', 'AI model', 'Application'], + 1: ['Voice & language', 'Talk', 'Audio & recording', 'AI model'], + 2: ['Voice & language', 'Audio & recording'], + }[scope] +E AssertionError: assert ['Talk', 'Voi...', 'AI model'] == ['Voice & lan...', 'AI model'] +E +E At index 0 diff: 'Talk' != 'Voice & language' +E Left contains 2 more items, first extra item: 'Audio & recording' +E Use -v to get more diff + +tests/test_settings_dialog.py:37: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Language options\u2026-Voice & language-1] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f3/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Language options…', section = 'Voice & language', scope = 1 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + assert dialog.scope == scope + assert dialog.windowTitle() == ('Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — 1') + assert not hasattr(dialog, 'scope_selector') + assert dialog.pages.currentWidget().isVisible() +> assert [dialog.navigation.item(i).text() for i in range(dialog.navigation.count()) if not dialog.navigation.item(i).isHidden()] == { + 0: ['Voice & language', 'Talk', 'Audio & recording', 'AI model', 'Application'], + 1: ['Voice & language', 'Talk', 'Audio & recording', 'AI model'], + 2: ['Voice & language', 'Audio & recording'], + }[scope] +E AssertionError: assert ['Talk', 'Voi...', 'AI model'] == ['Voice & lan...', 'AI model'] +E +E At index 0 diff: 'Talk' != 'Voice & language' +E Left contains 2 more items, first extra item: 'Audio & recording' +E Use -v to get more diff + +tests/test_settings_dialog.py:37: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Additional audio\u2026-Audio & recording-2] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f4/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Additional audio…', section = 'Audio & recording', scope = 2 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + assert dialog.scope == scope +> assert dialog.windowTitle() == ('Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — 1') +E AssertionError: assert 'Slide sound — slide 1' == 'Slide sound — 1' +E +E - Slide sound — 1 +E + Slide sound — slide 1 +E ? ++++++ + +tests/test_settings_dialog.py:33: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Application settings\u2026-Application-0] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f5/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Application settings…', section = 'Application', scope = 0 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] +> next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E StopIteration + +tests/test_settings_dialog.py:29: StopIteration +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Account\u2026-Application-0] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f6/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Account…', section = 'Application', scope = 0 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() +> assert QApplication.activeModalWidget() is dialog +E assert is +E + where = () +E + where = QApplication.activeModalWidget + +tests/test_settings_dialog.py:30: AssertionError +_ test_menu_shortcuts_open_the_fixed_scope_and_target[Speech engine\u2026-AI model-0] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_shortcuts_open_the_f7/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +caption = 'Speech engine…', section = 'AI model', scope = 0 + + @pytest.mark.parametrize('caption, section, scope', [ + ('Talk settings…', 'Talk', 1), + ('Voice && speech…', 'Voice & language', 1), + ('Presentation settings…', 'Audio & recording', 1), + ('Language options…', 'Voice & language', 1), + ('Additional audio…', 'Audio & recording', 2), + ('Application settings…', 'Application', 0), + ('Account…', 'Application', 0), + ('Speech engine…', 'AI model', 0), + ]) + def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] + next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() +> assert QApplication.activeModalWidget() is dialog +E assert is +E + where = () +E + where = QApplication.activeModalWidget + +tests/test_settings_dialog.py:30: AssertionError +___ test_buttons_and_nested_account_window_preserve_independent_transactions ___ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_buttons_and_nested_accoun0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_buttons_and_nested_account_window_preserve_independent_transactions(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + for control, section, scope in ((w.voice_button, 'Voice & language', 1), + (w.presentation_button, 'Audio & recording', 1), + (w.engine_status, 'AI model', 0)): + qtbot.mouseClick(control, Qt.LeftButton) +> assert QApplication.activeModalWidget() is w.settings[scope] +E assert is +E + where = () +E + where = QApplication.activeModalWidget + +tests/test_settings_dialog.py:51: AssertionError +________ test_talk_save_cancel_applies_across_its_sections_only[False] _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_talk_save_cancel_applies_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +save = False + + @pytest.mark.parametrize('save', [False, True]) + def test_talk_save_cancel_applies_across_its_sections_only(qtbot, project, save): + make_audio(project) + original = copy.deepcopy(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[1] + w.talk_action.trigger() + w.settings[1].fields['audience'].editor.setText('Conference speakers') + dialog.show_section('Voice & language') + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) + dialog.show_section('Audio & recording') + dialog.fields['pause_seconds'].editor.setValue(2) + dialog.fields['background_loop'].editor.setChecked(True) + dialog.fields['background_gain'].editor.setValue(35) + dialog.show_section('Voice & language') + dialog.fields['language_policy'].editor.setCurrentIndex(2) + qtbot.mouseClick(dialog.buttons.button(QDialogButtonBox.StandardButton.Save if save else QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) + reopened = Project.load(project.manifest) +> assert reopened.fields['audience'].editor == ('Conference speakers' if save else original.audience) + ^^^^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'fields' + +tests/test_settings_dialog.py:89: AttributeError +_________ test_talk_save_cancel_applies_across_its_sections_only[True] _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_talk_save_cancel_applies_1/talk'), title='slides', target_m...gin='reference'), 'pause_seconds': 2.0, 'background_loop': True, 'background_gain': 1.0, 'language_policy': 'version'}) +save = True + + @pytest.mark.parametrize('save', [False, True]) + def test_talk_save_cancel_applies_across_its_sections_only(qtbot, project, save): + make_audio(project) + original = copy.deepcopy(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[1] + w.talk_action.trigger() + w.settings[1].fields['audience'].editor.setText('Conference speakers') + dialog.show_section('Voice & language') + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) + dialog.show_section('Audio & recording') + dialog.fields['pause_seconds'].editor.setValue(2) + dialog.fields['background_loop'].editor.setChecked(True) + dialog.fields['background_gain'].editor.setValue(35) + dialog.show_section('Voice & language') + dialog.fields['language_policy'].editor.setCurrentIndex(2) + qtbot.mouseClick(dialog.buttons.button(QDialogButtonBox.StandardButton.Save if save else QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) + reopened = Project.load(project.manifest) +> assert reopened.fields['audience'].editor == ('Conference speakers' if save else original.audience) + ^^^^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'fields' + +tests/test_settings_dialog.py:89: AttributeError +_____ test_application_display_and_model_policies_are_transactional[False] _____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_display_and_m0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +save = False + + @pytest.mark.parametrize('save', [False, True]) + def test_application_display_and_model_policies_are_transactional(qtbot, project, save): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.preferences.screen.addItem('Second test display', 1) + dialog = w.settings[0] +> dialog.show_section('Application') + +tests/test_settings_dialog.py:104: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + super().show_section(section or ('Talk' if self.scope == 1 else 'Voice & language'), focus) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Application', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +_____ test_application_display_and_model_policies_are_transactional[True] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_application_display_and_m1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +save = True + + @pytest.mark.parametrize('save', [False, True]) + def test_application_display_and_model_policies_are_transactional(qtbot, project, save): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.preferences.screen.addItem('Second test display', 1) + dialog = w.settings[0] +> dialog.show_section('Application') + +tests/test_settings_dialog.py:104: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + super().show_section(section or ('Talk' if self.scope == 1 else 'Voice & language'), focus) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Application', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +________________ test_application_settings_work_without_a_talk _________________ + +qtbot = + + def test_application_settings_work_without_a_talk(qtbot): + w = MainWindow(); qtbot.addWidget(w); w.show() + dialog = w.settings[0] + dialog.show_section() +> assert dialog.navigation.currentItem().text() == 'Application' +E AssertionError: assert 'Voice & language' == 'Application' +E +E - Application +E + Voice & language + +tests/test_settings_dialog.py:118: AssertionError +___ test_slide_binding_stays_pinned_and_cancel_does_not_revert_other_scopes ____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_slide_binding_stays_pinne0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_slide_binding_stays_pinned_and_cancel_does_not_revert_other_scopes(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + slide = w.settings[2] + slide.show_section('Voice & language') + w.transport.select(1) # A transport update must not redirect an open editor. +> slide.settings_language.setCurrentText('Spanish') + ^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'SlideSettingsDialog' object has no attribute 'settings_language' + +tests/test_settings_dialog.py:133: AttributeError +_________ test_mode_edit_updates_main_window_from_resolved_setting[0] __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_mode_edit_updates_main_wi0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +scope = 0 + + @pytest.mark.parametrize('scope', [0, 1]) + def test_mode_edit_updates_main_window_from_resolved_setting(qtbot, project, scope): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[scope] +> dialog.show_section('Talk') + +tests/test_settings_dialog.py:149: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + super().show_section(section or ('Talk' if self.scope == 1 else 'Voice & language'), focus) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Talk', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +_________ test_cancel_after_background_work_preserves_completed_result _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_cancel_after_background_w0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_cancel_after_background_work_preserves_completed_result(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() +> updated = copy.deepcopy(project); updated.fields['audience'].editor = 'Newly prepared context' + ^^^^^^^^^^^^^^ +E AttributeError: 'Project' object has no attribute 'fields' + +tests/test_settings_dialog.py:159: AttributeError +_______________ test_clip_controls_stay_bound_to_the_open_slide ________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_clip_controls_stay_bound_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_clip_controls_stay_bound_to_the_open_slide(qtbot, project): + from autotalk.project import Clip + make_audio(project) + for slide in project.slides: + slide.clips = [Clip(slide.audio_file, slide.audio_sha256, slide.duration)] + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[2] + dialog.show_section('Audio & recording') + w.transport.select(1) + w.settings[2].assets.clip_gain.setFocus(); w.settings[2].assets.clip_gain.selectAll(); qtbot.keyClicks(w.settings[2].assets.clip_gain, '0.5') + qtbot.keyClick(w.settings[2].assets.clip_gain, Qt.Key.Key_Tab) +> assert w.project.slides[0].clips[0].gain == .5 +E AssertionError: assert 1.0 == 0.5 +E + where 1.0 = Clip(file='audio/main/English/0001-965b576d8c6f19aee67e5885b03c3e1623b59199dccdeb2ea9a0e447abd99fd6.wav', sha256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', seconds=0.3, placement='before', gain=1.0, loop=False).gain + +tests/test_settings_dialog.py:212: AssertionError +__________ test_dialog_labels_have_ellipses_and_direct_actions_do_not __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_dialog_labels_have_ellips0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_dialog_labels_have_ellipses_and_direct_actions_do_not(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) + labels = {a.text() for a in w.findChildren(QAction)} | {b.text() for b in w.findChildren(QPushButton)} + for text in ('Open PDF', 'Open saved talk', 'Talk settings', 'Application settings', 'Save delivery preset', + 'Rewrite slide text', 'Fit duration', 'Getting started', 'Keyboard shortcuts', 'About AutoTalk'): +> assert text + '…' in labels and text not in labels +E AssertionError: assert (('Application settings' + '…') in {'', 'About AutoTalk…', 'Account settings…', 'Account…', 'Add background track…', 'Additional audio…', ...}) + +tests/test_start_interaction.py:124: AssertionError +___ test_account_default_and_explicit_model_use_identical_name[GPT-6-Astra] ____ + +qtbot = , display = 'GPT-6-Astra' + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:136: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + @property + def slide(self): + return self.project.slides[self.slide_index] if self.scope == 2 and self.project else None + + def setting_value(self, key): +> if key not in SETTING_DEFAULTS: + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:77: AttributeError +_________ test_account_default_and_explicit_model_use_identical_name[] _________ + +qtbot = , display = '' + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:136: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + @property + def slide(self): + return self.project.slides[self.slide_index] if self.scope == 2 and self.project else None + + def setting_value(self, key): +> if key not in SETTING_DEFAULTS: + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:77: AttributeError +_______ test_account_default_and_explicit_model_use_identical_name[None] _______ + +qtbot = , display = None + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:136: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + @property + def slide(self): + return self.project.slides[self.slide_index] if self.scope == 2 and self.project else None + + def setting_value(self, key): +> if key not in SETTING_DEFAULTS: + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:77: AttributeError +___________ test_incremental_journey_matches_full_refresh[Prepared] ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_incremental_journey_match0/talk'), title='slides', target_m..., speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Prepared', 'record_presentation': True}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f09787464e0> +mode = 'Prepared' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_incremental_journey_matches_full_refresh(qtbot, project, monkeypatch, mode): + make_audio(project) + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.elapsed_timer.stop() + equivalent(w) + w.editor_next.click(); equivalent(w) + w.editor_previous.click(); equivalent(w) + w.select_slide(1); equivalent(w) + w.inspector.slide_budget.setValue(75); equivalent(w) + w.minutes.setValue(12); equivalent(w) + w.record.setChecked(True); equivalent(w) + w.mode.setCurrentText('Realtime'); equivalent(w) + w.workspace.setCurrentWidget(w.presenter) + w.previous_button.click(); equivalent(w) + w.next_button.click(); equivalent(w) + w.mode.setCurrentText(mode) + w.view_switch.setCurrentIndex(0); equivalent(w) + # Quick has no editor, but programmatic edits must still keep the surfaces equivalent. + w.narration.insertPlainText(' Revised.'); equivalent(w) + w.inspector.slide_include.setChecked(False); equivalent(w) + w.inspector.slide_include.setChecked(True); equivalent(w) + w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.findData('pause')); equivalent(w) + w.inspector.slide_pause.setValue(0); equivalent(w) + w.language.setCurrentIndex(w.language.findData('German')); equivalent(w) + w.job = SimpleNamespace(title='Fake preparation', outcome='completed', deleteLater=lambda: None) + w.job_started = __import__('time').monotonic() + w.job_finished(); equivalent(w) + # Exercise the same preview notifications without touching real output devices. + w.transport.preview_path = project.audio(project.slides[0]); w.playback_state_changed(); equivalent(w) + w.transport.preview_path = None; w.playback_state_changed(); equivalent(w) + for dialog in w.settings.values(): + dialog.show_section(); equivalent(w) +> dialog.reject(); equivalent(w) + ^^^^^^^^^^^^^ + +tests/test_surface_refresh.py:76: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +window = + + def equivalent(window): + window.flush_refresh() + before = snapshot(window) + window.refresh() +> assert snapshot(window) == before +E AssertionError: assert [{'type': 'Ma...se, ...}, ...] == [{'type': 'Ma...se, ...}, ...] +E +E At index 644 diff: {'type': 'TalkSettingsDialog', 'toolTip': '', 'isEnabled': True, 'isVisible': False} != {'type': 'QProgressBar', 'text': '', 'toolTip': '', 'isEnabled': True, 'isVisible': False, 'value': -1} +E Use -v to get more diff + +tests/test_surface_refresh.py:39: AssertionError +_____________ test_incremental_journey_matches_full_refresh[Quick] _____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_incremental_journey_match1/talk'), title='slides', target_m...ce', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick', 'record_presentation': True}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0927501a30> +mode = 'Quick' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_incremental_journey_matches_full_refresh(qtbot, project, monkeypatch, mode): + make_audio(project) + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.elapsed_timer.stop() + equivalent(w) + w.editor_next.click(); equivalent(w) + w.editor_previous.click(); equivalent(w) + w.select_slide(1); equivalent(w) + w.inspector.slide_budget.setValue(75); equivalent(w) + w.minutes.setValue(12); equivalent(w) + w.record.setChecked(True); equivalent(w) + w.mode.setCurrentText('Realtime'); equivalent(w) + w.workspace.setCurrentWidget(w.presenter) + w.previous_button.click(); equivalent(w) + w.next_button.click(); equivalent(w) + w.mode.setCurrentText(mode) + w.view_switch.setCurrentIndex(0); equivalent(w) + # Quick has no editor, but programmatic edits must still keep the surfaces equivalent. + w.narration.insertPlainText(' Revised.'); equivalent(w) + w.inspector.slide_include.setChecked(False); equivalent(w) + w.inspector.slide_include.setChecked(True); equivalent(w) + w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.findData('pause')); equivalent(w) + w.inspector.slide_pause.setValue(0); equivalent(w) + w.language.setCurrentIndex(w.language.findData('German')); equivalent(w) + w.job = SimpleNamespace(title='Fake preparation', outcome='completed', deleteLater=lambda: None) + w.job_started = __import__('time').monotonic() + w.job_finished(); equivalent(w) + # Exercise the same preview notifications without touching real output devices. + w.transport.preview_path = project.audio(project.slides[0]); w.playback_state_changed(); equivalent(w) + w.transport.preview_path = None; w.playback_state_changed(); equivalent(w) + for dialog in w.settings.values(): + dialog.show_section(); equivalent(w) +> dialog.reject(); equivalent(w) + ^^^^^^^^^^^^^ + +tests/test_surface_refresh.py:76: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +window = + + def equivalent(window): + window.flush_refresh() + before = snapshot(window) + window.refresh() +> assert snapshot(window) == before +E AssertionError: assert [{'type': 'Ma...se, ...}, ...] == [{'type': 'Ma...se, ...}, ...] +E +E At index 644 diff: {'type': 'TalkSettingsDialog', 'toolTip': '', 'isEnabled': True, 'isVisible': False} != {'type': 'QProgressBar', 'text': '', 'toolTip': '', 'isEnabled': True, 'isVisible': False, 'value': -1} +E Use -v to get more diff + +tests/test_surface_refresh.py:39: AssertionError +___________ test_incremental_journey_matches_full_refresh[Realtime] ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_incremental_journey_match2/talk'), title='slides', target_m..., speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime', 'record_presentation': True}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f094a24db20> +mode = 'Realtime' + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + def test_incremental_journey_matches_full_refresh(qtbot, project, monkeypatch, mode): + make_audio(project) + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.elapsed_timer.stop() + equivalent(w) + w.editor_next.click(); equivalent(w) + w.editor_previous.click(); equivalent(w) + w.select_slide(1); equivalent(w) + w.inspector.slide_budget.setValue(75); equivalent(w) + w.minutes.setValue(12); equivalent(w) + w.record.setChecked(True); equivalent(w) + w.mode.setCurrentText('Realtime'); equivalent(w) + w.workspace.setCurrentWidget(w.presenter) + w.previous_button.click(); equivalent(w) + w.next_button.click(); equivalent(w) + w.mode.setCurrentText(mode) + w.view_switch.setCurrentIndex(0); equivalent(w) + # Quick has no editor, but programmatic edits must still keep the surfaces equivalent. + w.narration.insertPlainText(' Revised.'); equivalent(w) + w.inspector.slide_include.setChecked(False); equivalent(w) + w.inspector.slide_include.setChecked(True); equivalent(w) + w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.findData('pause')); equivalent(w) + w.inspector.slide_pause.setValue(0); equivalent(w) + w.language.setCurrentIndex(w.language.findData('German')); equivalent(w) + w.job = SimpleNamespace(title='Fake preparation', outcome='completed', deleteLater=lambda: None) + w.job_started = __import__('time').monotonic() + w.job_finished(); equivalent(w) + # Exercise the same preview notifications without touching real output devices. + w.transport.preview_path = project.audio(project.slides[0]); w.playback_state_changed(); equivalent(w) + w.transport.preview_path = None; w.playback_state_changed(); equivalent(w) + for dialog in w.settings.values(): + dialog.show_section(); equivalent(w) +> dialog.reject(); equivalent(w) + ^^^^^^^^^^^^^ + +tests/test_surface_refresh.py:76: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +window = + + def equivalent(window): + window.flush_refresh() + before = snapshot(window) + window.refresh() +> assert snapshot(window) == before +E AssertionError: assert [{'type': 'Ma...se, ...}, ...] == [{'type': 'Ma...se, ...}, ...] +E +E At index 644 diff: {'type': 'TalkSettingsDialog', 'toolTip': '', 'isEnabled': True, 'isVisible': False} != {'type': 'QProgressBar', 'text': '', 'toolTip': '', 'isEnabled': True, 'isVisible': False, 'value': -1} +E Use -v to get more diff + +tests/test_surface_refresh.py:39: AssertionError +_________ test_recording_selection_visible_in_workspaces_and_persisted _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_recording_selection_visib0/talk'), title='slides', target_m...ource='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': False}) + + def test_recording_selection_visible_in_workspaces_and_persisted(qtbot, project): + from PySide6.QtCore import QPoint + from autotalk.project import Project + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.resize(940, 680) + window.show() + checkbox = window.record + for page in (window.editor, window.presenter, window.quick_page): + window.workspace.setCurrentWidget(page) + qtbot.wait(10) + assert checkbox.isVisible() and checkbox.isEnabled() + top = checkbox.mapTo(window, QPoint(0, 0)) + assert window.rect().contains(top) + assert top.y() + checkbox.height() <= window.height() + # The label and keyboard both operate the single project-backed checkbox. + qtbot.mouseClick(checkbox, Qt.MouseButton.LeftButton, pos=QPoint(55, checkbox.height()//2)) + assert project.record_presentation + window.save() + assert Project.load(project.manifest).record_presentation + checkbox.setFocus() + qtbot.keyClick(checkbox, Qt.Key.Key_Space) + assert not project.record_presentation + window.transport.state = 'paused' + window.refresh() + assert not checkbox.isEnabled() +> assert not window.settings[1].options.recording_widget.isEnabled() + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'TalkSettingsDialog' object has no attribute 'options'. Did you mean: 'actions'? + +tests/test_ui.py:37: AttributeError +_____________ test_settings_open_and_save_preserve_selected_slide ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_settings_open_and_save_pr0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_settings_open_and_save_preserve_selected_slide(qtbot, project): + from PySide6.QtWidgets import QDialogButtonBox + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.show() + window.transport.select(1) + original = window.project + window.talk_action.trigger() + assert window.settings[1].isVisible() + assert window.project is original and window.transport.index == 1 + window.minutes.setValue(14) + qtbot.mouseClick(window.settings[1].buttons.button(QDialogButtonBox.StandardButton.Save), Qt.MouseButton.LeftButton) +> assert not window.settings[1].isVisible() +E assert not True +E + where True = () +E + where = .isVisible + +tests/test_ui.py:246: AssertionError +_____ test_cancel_settings_restores_project_and_manifest_after_eager_save ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_cancel_settings_restores_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0978368e90> + + def test_cancel_settings_restores_project_and_manifest_after_eager_save(qtbot, project, monkeypatch): + from autotalk.project import Project + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.show() + window.transport.select(1) + window.settings[1].show_section("Talk") + window.minutes.setValue(19) + window.settings[1].fields['scope'].editor.setPlainText('A different conference') + window.language.setCurrentText('German') + window.save() # Existing language/reference/worker handlers can save eagerly. + window.settings[1].reject() + restored = Project.load(project.manifest) +> assert restored.target_minutes == 10 and restored.language == 'English' +E AssertionError: assert (19.0 == 10) +E + where 19.0 = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_cancel_settings_restores_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}).target_minutes + +tests/test_ui.py:266: AssertionError +___________ test_preferences_cancel_restores_retention_and_sampling ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_preferences_cancel_restor0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_preferences_cancel_restores_retention_and_sampling(qtbot, project): + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.show() + original = window.preferences.gpu_retention.checkedButton().property("value") + window.settings[0].show_section("AI model") + next(b for b in window.preferences.gpu_retention.buttons() if b.property('value') == 'operation').click() +> assert window.speech.retention == original # Draft policies do not act before Save. + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AssertionError: assert 'operation' == 'session' +E +E - session +E + operation + +tests/test_ui.py:282: AssertionError +_____ test_quick_keeps_basics_in_main_window_and_cancel_restores_settings ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_quick_keeps_basics_in_mai0/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) + + def test_quick_keeps_basics_in_main_window_and_cancel_restores_settings(qtbot, project): + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.resize(940, 680) + window.show() + window.mode.setCurrentText('Quick') + qtbot.wait(30) + assert window.minutes.isVisible() and window.language.isVisible() + window.minutes.setValue(7) + assert project.target_minutes == 7 + parent = window.minutes.parentWidget() + window.settings[1].show_section("Talk") + assert window.minutes.parentWidget() is parent + window.minutes.setValue(9) + window.settings[1].reject() + qtbot.wait(30) + assert window.minutes.parentWidget() is parent +> assert window.minutes.value() == window.project.target_minutes == 7 +E AssertionError: assert 9.0 == 7 +E + where 9.0 = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_quick_keeps_basics_in_mai0/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}).target_minutes +E + where Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_quick_keeps_basics_in_mai0/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) = .project + +tests/test_ui.py:311: AssertionError +_________ test_commands_and_dialogs_cannot_bypass_job_or_playback_lock _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_commands_and_dialogs_cann0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_commands_and_dialogs_cannot_bypass_job_or_playback_lock(qtbot, project): + from threading import Event + window = MainWindow() + qtbot.addWidget(window) + make_audio(project) + window.adopt(project) + window.show() + window.settings[1].show_section("AI model") + release = Event() + window.start_job('Checking command availability', lambda task: release.wait(5)) + try: + assert not window.talk_action.isEnabled() + assert window.narration.isReadOnly() + assert not window.settings[1].models.codex_model.isEnabled() and not window.settings[1].voice.override.isEnabled() + assert not window.more_presentation.isEnabled() + assert not window.welcome.isEnabled() + assert all(not a.isEnabled() for a in window.actions) + for action, control in window.control_actions: + assert action.isEnabled() == control.isEnabled() + window.settings[1].reject() +> assert window.settings[1].isVisible() +E assert False +E + where False = () +E + where = .isVisible + +tests/test_ui.py:338: AssertionError +_ test_recording_checkbox_stays_in_main_window_and_dialog_cancel_restores_settings _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_recording_checkbox_stays_0/talk'), title='slides', target_m...source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': True}) + + def test_recording_checkbox_stays_in_main_window_and_dialog_cancel_restores_settings(qtbot, project): + window = MainWindow(); qtbot.addWidget(window); window.adopt(project); window.show() + checkbox = window.record + checkbox.click() + assert window.project.record_presentation + parent = checkbox.parentWidget() + window.settings[1].show_section("Audio & recording") + assert checkbox.parentWidget() is parent and checkbox.isVisible() + window.settings[1].fields['recording_source'].editor.setCurrentIndex(1) +> assert window.project.recording_source == 'screen' +E AssertionError: assert 'slides' == 'screen' +E +E - screen +E + slides + +tests/test_ui.py:378: AssertionError +________________ test_each_component_owns_widgets_and_builders _________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_each_component_owns_widge0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_each_component_owns_widgets_and_builders(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) + for dialog in w.settings.values(): + popups = {combo.view().window() for combo in dialog.findChildren(QComboBox)} + for child in dialog.findChildren(QWidget): +> assert child.window() is dialog or child.window() in popups + ^^^^^^^^^^^^^^ +E TypeError: 'MainWindow' object is not callable + +tests/test_ui_ownership.py:45: TypeError +_________________ test_cancel_restores_clip_gain_and_placement _________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_cancel_restores_clip_gain0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_cancel_restores_clip_gain_and_placement(qtbot, project): + make_audio(project) + for slide in project.slides: + slide.clips = [Clip(slide.audio_file, slide.audio_sha256, slide.duration)] + project.save() + original = copy.deepcopy(project.slides) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + dialog = w.settings[2] + dialog.show_section('Audio & recording') + dialog.assets.clip_gain.setValue(.5) + dialog.assets.clip_placement.setCurrentIndex(dialog.assets.clip_placement.findData('after')) +> assert w.project.slides[0].clips[0].gain == .5 +E AssertionError: assert 1.0 == 0.5 +E + where 1.0 = Clip(file='audio/main/English/0001-965b576d8c6f19aee67e5885b03c3e1623b59199dccdeb2ea9a0e447abd99fd6.wav', sha256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', seconds=0.3, placement='before', gain=1.0, loop=False).gain + +tests/test_ui_ownership.py:71: AssertionError +_________________________ test_tab_order_matches_base __________________________ + +qtbot = + + def test_tab_order_matches_base(qtbot): + import json + expected = json.loads(Path('docs/reviews/evidence/2026-09-30-ui-structure-perf/base-tab-order.json').read_text()) + w = MainWindow(); qtbot.addWidget(w) + for scope, dialog in w.settings.items(): + names = {v: k.replace('conference_scope', 'scope') for k, v in dialog.__dict__.items() + if isinstance(v, QWidget) and QWidget.window(v) is dialog} +> names.update({v: 'field:' + k for k, v in dialog.options.fields.items()}) + ^^^^^^^^^^^^^^ +E AttributeError: 'TalkDefaultsDialog' object has no attribute 'options'. Did you mean: 'actions'? + +tests/test_ui_ownership.py:85: AttributeError +__________ test_previous_video_does_not_override_new_recording_intent __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_previous_video_does_not_o0/talk'), title='slides', target_m...source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': True}) +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_previous_video_does_not_o0') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0924e4a6f0> + + def test_previous_video_does_not_override_new_recording_intent(qtbot, project, tmp_path, monkeypatch): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) + video = tmp_path / 'previous.mp4'; video.write_bytes(b'video') + w.export_saved(video) + w.record.click() + assert not w.record_status.isVisible() and w.record.isChecked() + assert 'Saved ' in w.saved_output.text() and 'Open video' in w.saved_output.text() + w.settings[1].fields['recording_source'].editor.setCurrentIndex(w.settings[1].fields['recording_source'].editor.findData('screen')) +> assert 'Screen + system audio' in w.record.toolTip() +E AssertionError: assert 'Screen + system audio' in 'Recording source: Slide video + narration. Configure it in Presentation settings → Recording.' +E + where 'Recording source: Slide video + narration. Configure it in Presentation settings → Recording.' = () +E + where = .toolTip +E + where = .record + +tests/test_usability.py:224: AssertionError +____________ test_voice_actions_follow_reference_and_current_design ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_voice_actions_follow_refe0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_voice_actions_follow_refe0') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f094414f8f0> + + def test_voice_actions_follow_reference_and_current_design(qtbot, project, tmp_path, monkeypatch): + import wave + monkeypatch.setattr(services, 'data_dir', lambda: tmp_path) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.settings[1].show_section("Voice & language") + assert 'Energetic male' in w.settings[1].voice.speaker.currentText() + w.settings[1].voice.speaker.setCurrentIndex(w.settings[1].voice.speaker.findData('Aiden')) +> assert project.voice.speaker == 'Aiden' +E AssertionError: assert 'Ryan' == 'Aiden' +E +E - Aiden +E + Ryan + +tests/test_usability.py:239: AssertionError +_______________ test_operation_feedback_is_in_the_active_dialog ________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_operation_feedback_is_in_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_operation_feedback_is_in_the_active_dialog(qtbot, project): + import threading + release = threading.Event() + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.settings[1].show_section("Voice & language") + try: + w.start_job('Generating voice preview…', lambda task: release.wait(3)) + qtbot.wait(300) + assert w.progress.isVisible() and w.progress.parentWidget() is w.settings[1] + assert 'Waiting ' in w.settings[1].status.text() + assert w.settings[1].cancel_job.isVisible() and w.settings[1].cancel_job.isEnabled() + finally: + release.set() + qtbot.waitUntil(lambda: w.job is None) + assert not w.progress.isVisible() + w.settings[1].accept() +> assert w.progress.parentWidget() is w.footer +E assert is +E + where = () +E + where = .parentWidget +E + where = .progress +E + and = .footer + +tests/test_usability.py:277: AssertionError +__________ test_inapplicable_options_and_recording_browser_priorities __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_inapplicable_options_and_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f097731c6b0> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_inapplicable_options_and_0') + + def test_inapplicable_options_and_recording_browser_priorities(qtbot, project, monkeypatch, tmp_path): + from autotalk import options + monkeypatch.setattr(options, 'data_dir', lambda: tmp_path) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + assert not w.settings[1].presets.use_preset_button.isEnabled() + w.settings[1].show_section("Audio & recording") + assert not w.settings[1].assets.remove_background_button.isEnabled() and w.settings[1].fields['background_gain'].editor.isEnabled() +> assert w.settings[1].fields['background_gain'].editor.value() == 15 and w.settings[1].fields['background_gain'].editor.suffix() == ' %' +E assert (0.15 == 15) +E + where 0.15 = () +E + where = .value +E + where = .editor + +tests/test_usability.py:288: AssertionError +______________ test_fullscreen_mouse_controls_and_finished_return ______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_fullscreen_mouse_controls0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_fullscreen_mouse_controls_and_finished_return(qtbot, project): + from PySide6.QtCore import QPoint + make_audio(project, seconds=10) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.start_button.click() + fullscreen = w.presentation + assert fullscreen is not None + qtbot.mouseMove(fullscreen, QPoint(20, fullscreen.height()-25)) + qtbot.waitUntil(lambda: fullscreen.controls.isVisible()) + qtbot.mouseClick(fullscreen.controls.widgetForAction(fullscreen.toggle_action), Qt.MouseButton.LeftButton) + assert w.transport.state == 'paused' + assert fullscreen.toggle_action.text() == 'Continue' + qtbot.mouseClick(fullscreen.controls.widgetForAction(fullscreen.toggle_action), Qt.MouseButton.LeftButton) + assert w.transport.playing + assert not w.previous_action.isEnabled() and w.next_action.isEnabled() + qtbot.mouseClick(fullscreen.controls.widgetForAction(w.next_action), Qt.MouseButton.LeftButton) +> assert w.transport.index == 1 and w.previous_action.isEnabled() +E assert (0 == 1) +E + where 0 = .index +E + where = .transport + +tests/test_usability.py:316: AssertionError +________ test_saved_voice_preview_feedback_and_settings_have_one_owner _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_voice_preview_feedb0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f097831c200> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_voice_preview_feedb0') + + def test_saved_voice_preview_feedback_and_settings_have_one_owner(qtbot, project, monkeypatch, tmp_path): + import threading + from PySide6.QtWidgets import QApplication, QDialogButtonBox + from autotalk import voices + monkeypatch.setattr(voices, 'data_dir', lambda: tmp_path) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.settings[1].show_section("Voice & language") + w.settings[1].voice.voice_source.setCurrentIndex(3) + assert w.settings[1].voice.voice_library.rowCount() == 0 + assert not w.settings[1].voice.voice_preview_button.isEnabled() and not w.settings[1].voice.library_use.isEnabled() + voices.save_voice(project, 'Conference voice'); w.settings[1].voice.refresh_library() + assert w.settings[1].voice.voice_preview_button.isEnabled() and w.settings[1].voice.library_use.isEnabled() + release = threading.Event() +> monkeypatch.setattr(settings, 'synthesize', lambda *a, **kw: (release.wait(3), None)[1]) +E AttributeError: has no attribute 'synthesize' + +tests/test_usability.py:428: AttributeError +______________ test_dialog_destinations_and_direct_voice_sources _______________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_dialog_destinations_and_d0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_dialog_destinations_and_direct_voice_sources(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + qtbot.mouseClick(w.voice_button, Qt.LeftButton) # depth 1 + assert w.settings[1].isVisible() and w.settings[1].voice.speaker.isVisible() + for index, control in ((1, w.settings[1].voice.record_button), (2, w.settings[1].voice.voice_description), (3, w.settings[1].voice.voice_library)): + qtbot.mouseClick(w.settings[1].voice.voice_source, Qt.LeftButton, pos=w.settings[1].voice.voice_source.tabRect(index).center()) # depth 2 + visible_inside(control, w.settings[1]) + visible_inside(w.settings[1].voice.voice_preview_button, w.settings[1]) + w.settings[1].reject() + qtbot.mouseClick(w.presentation_button, Qt.LeftButton) +> w.settings[0].show_section("Application") + +tests/test_ux_placement.py:70: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + self.voice.refresh_library() +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Application', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +_________ test_saved_voice_audition_does_not_select_or_invalidate_talk _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_voice_audition_does0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0927501700> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_saved_voice_audition_does0') + + def test_saved_voice_audition_does_not_select_or_invalidate_talk(qtbot, project, monkeypatch, tmp_path): + monkeypatch.setattr(voices, 'data_dir', lambda: tmp_path) + candidate = copy.deepcopy(project); candidate.voice.speaker = 'Aiden' + voices.save_voice(candidate, 'Candidate') + make_audio(project) + original = copy.deepcopy(project.voice) + heard = [] +> monkeypatch.setattr(settings, 'synthesize', lambda p, task, preview: heard.append(p.voice.speaker) or p.audio(p.slides[0])) +E AttributeError: has no attribute 'synthesize' + +tests/test_ux_placement.py:149: AttributeError +___________ test_delivery_preset_and_style_never_overwrite_sampling ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_delivery_preset_and_style0/talk'), title='slides', target_m...Voice', speaker='Ryan', description='', references={}, origin='reference'), 'delivery.sampling': {'temperature': 0.3}}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f096c11a240> +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-396/test_delivery_preset_and_style0') + + def test_delivery_preset_and_style_never_overwrite_sampling(qtbot, project, monkeypatch, tmp_path): + monkeypatch.setattr(options, 'data_dir', lambda: tmp_path) + directory = tmp_path/'delivery'; directory.mkdir() + # A legacy preset containing sampling must not cross the new delivery boundary. + (directory/'preset.json').write_text(json.dumps({'name': 'Warm', 'delivery': { + 'style': 'Storytelling', 'attributes': {'texture': 'Warm'}, 'sampling': {'temperature': 1.8}}})) + project.set_setting("delivery.sampling", {'temperature': .3}) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) + w.settings[1].show_section("Voice & language") + w.settings[1].presets.use_preset_button.click() +> assert project.delivery.style == 'Storytelling' and project.delivery.sampling == {'temperature': .3} +E AssertionError: assert ('Professional' == 'Storytelling' +E +E - Storytelling +E + Professional) + +tests/test_ux_placement.py:191: AssertionError +___________ test_cancel_restores_clips_display_and_project_settings ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_cancel_restores_clips_dis0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_cancel_restores_clips_display_and_project_settings(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.preferences.screen.addItem('Second test display', 1) +> w.settings[0].show_section('Application'); w.preferences.screen.setCurrentIndex(1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests/test_ux_placement.py:201: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + self.voice.refresh_library() +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Application', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +______ test_engine_account_and_f5_use_their_single_authoritative_commands ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_engine_account_and_f5_use0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0977740230> + + def test_engine_account_and_f5_use_their_single_authoritative_commands(qtbot, project, monkeypatch): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.engine_status.click() + assert w.preferences.engine_state.isVisible() and w.preferences.load_gpu_button.isVisible() + w.settings[0].reject() +> w.settings[0].show_section("Application") + +tests/test_ux_placement.py:219: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings.py:157: in show_section + self.voice.refresh_library() +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +section = 'Application', focus = None + + def show_section(self, section="Recordings", focus=None): +> self.navigation.setCurrentRow(self.navigation.row(self.navigation.findItems(section, Qt.MatchFlag.MatchExactly)[0])) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E IndexError: list index out of range + +src/autotalk/ui.py:217: IndexError +_________________________ test_single_editor_ownership _________________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_single_editor_ownership0/talk'), title='slides', target_min...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_single_editor_ownership(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + assert not hasattr(w, 'settings_minutes') + assert not hasattr(w, 'slide_directions') and not hasattr(w, 'inherit_delivery') + for name, main in [('mode', w.mode), ('language', w.language), ('record_presentation', w.record)]: + field = w.settings[1].fields[name].editor + assert isinstance(field, QLabel) and not isinstance(main, QLabel) + assert not isinstance(w.settings[0].fields[name].editor, QLabel) + assert isinstance(w.settings[2].fields['language'].editor, QComboBox) + slide = w.settings[2]; slide.show_section('Audio & recording'); qtbot.wait(10) +> assert not slide.pause.isVisible() and not slide.settings_after.isVisible() + ^^^^^^^^^^^ +E AttributeError: 'SlideSettingsDialog' object has no attribute 'pause' + +tests/test_ux_redundancy.py:35: AttributeError +_______ test_readonly_reset_save_cancel[False-mode-Prepared-Quick-Talk] ________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance0/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) +name = 'mode', default = 'Prepared', override = 'Quick', page = 'Talk' +save = False + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'Quick' +E + where 'Quick' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +_ test_readonly_reset_save_cancel[False-language-English-German-Voice & language] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +name = 'language', default = 'English', override = 'German' +page = 'Voice & language', save = False + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'German' +E + where 'German' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +_ test_readonly_reset_save_cancel[False-record_presentation-False-True-Audio & recording] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance2/talk'), title='slides', target_m...source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': True}) +name = 'record_presentation', default = False, override = True +page = 'Audio & recording', save = False + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'On' +E + where 'On' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +________ test_readonly_reset_save_cancel[True-mode-Prepared-Quick-Talk] ________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance3/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) +name = 'mode', default = 'Prepared', override = 'Quick', page = 'Talk' +save = True + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'Quick' +E + where 'Quick' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +_ test_readonly_reset_save_cancel[True-language-English-German-Voice & language] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance4/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +name = 'language', default = 'English', override = 'German' +page = 'Voice & language', save = True + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'German' +E + where 'German' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +_ test_readonly_reset_save_cancel[True-record_presentation-False-True-Audio & recording] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readonly_reset_save_cance5/talk'), title='slides', target_m...source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': True}) +name = 'record_presentation', default = False, override = True +page = 'Audio & recording', save = True + + @pytest.mark.parametrize('name,default,override,page', [ + ('mode', 'Prepared', 'Quick', 'Talk'), + ('language', 'English', 'German', 'Voice & language'), + ('record_presentation', False, True, 'Audio & recording')]) + @pytest.mark.parametrize('save', [False, True]) + def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + if name == 'language': + w.language.setCurrentText(override) + elif name == 'mode': + w.mode.setCurrentText(override) + else: + w.record.setChecked(override) + before = copy.deepcopy(w.project) + dialog = w.settings[1]; dialog.show_section(page) + field = dialog.fields[name].editor +> assert 'Talk setting' in field.text() +E AssertionError: assert 'Talk setting' in 'On' +E + where 'On' = () +E + where = .text + +tests/test_ux_redundancy.py:59: AssertionError +_________ test_inspector_source_pause_and_delivery_independence[False] _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_inspector_source_pause_an0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +talk_override = False + + @pytest.mark.parametrize('talk_override', [False, True]) + def test_inspector_source_pause_and_delivery_independence(qtbot, project, talk_override): + project.defaults['after'] = 'demo' + if talk_override: + project.set_setting('after', 'pause') + project.set_setting('delivery.instructions', 'Speak softly', project.slides[0]) + w = MainWindow(); qtbot.addWidget(w); w.defaults['after'] = 'demo'; w.adopt(project); w.show(); reveal_timing(w) + source = 'talk' if talk_override else 'app' +> assert w.inspector.slide_after.itemText(0) == f'Use {source} setting' +E AssertionError: assert 'Advance automatically' == 'Use app setting' +E +E - Use app setting +E + Advance automatically + +tests/test_ux_redundancy.py:96: AssertionError +_________ test_inspector_source_pause_and_delivery_independence[True] __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_inspector_source_pause_an1/talk'), title='slides', target_m...me='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'after': 'pause'}) +talk_override = True + + @pytest.mark.parametrize('talk_override', [False, True]) + def test_inspector_source_pause_and_delivery_independence(qtbot, project, talk_override): + project.defaults['after'] = 'demo' + if talk_override: + project.set_setting('after', 'pause') + project.set_setting('delivery.instructions', 'Speak softly', project.slides[0]) + w = MainWindow(); qtbot.addWidget(w); w.defaults['after'] = 'demo'; w.adopt(project); w.show(); reveal_timing(w) + source = 'talk' if talk_override else 'app' +> assert w.inspector.slide_after.itemText(0) == f'Use {source} setting' +E AssertionError: assert 'Advance automatically' == 'Use talk setting' +E +E - Use talk setting +E + Advance automatically + +tests/test_ux_redundancy.py:96: AssertionError +________________ test_readouts_recording_and_progress_position _________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_readouts_recording_and_pr0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_readouts_recording_and_progress_position(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + assert w.summary.text() == 'Audio not ready' + target = clock(project.target_minutes*60) +> assert w.settings[1].timing_summary.text().count(target) == 1 +E AssertionError: assert 0 == 1 +E + where 0 = ('10:00') +E + where = ''.count +E + where '' = () +E + where = .text +E + where = .timing_summary + +tests/test_ux_redundancy.py:116: AssertionError +____________________ test_menu_focus_status_and_background _____________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_menu_focus_status_and_bac0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_menu_focus_status_and_background(qtbot, project): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + assert not w.statusBar().currentMessage() + action(w, 'Settings', 'Account…').trigger(); qtbot.wait(30) + assert w.preferences.codex_signin.hasFocus() + w.settings[0].reject() + menus = w.menuBar().actions() + help_menu = next(a for a in menus if a.text() == 'Help').menu() + assert 'Operation details' not in [a.text() for a in help_menu.actions()] + assert action(w, 'View', 'Operation details') + parent = w.settings[1].assets.background_button.parentWidget() + assert w.settings[1].assets.remove_background_button.parentWidget() is parent + assert w.settings[1].fields['background_gain'].editor.isAncestorOf(parent) is False +> assert parent.isAncestorOf(w.settings[1].fields['background_gain'].editor) +E assert False +E + where False = () +E + where = .isAncestorOf +E + and = .editor + +tests/test_ux_redundancy.py:151: AssertionError +_ test_conference_completion_restores_activity_without_erasing_capabilities[CustomVoice] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_conference_completion_res0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f0927517590> +source = 'CustomVoice' + + @pytest.mark.parametrize('source', ['CustomVoice', 'Base']) + def test_conference_completion_restores_activity_without_erasing_capabilities(qtbot, project, monkeypatch, source): + from autotalk import settings + release = threading.Event() + project.voice = Voice(source=source) + project.conference_url = 'https://conference.example' +> monkeypatch.setattr(settings, 'extract_scope', lambda *args: ( + release.wait(5), {'scope': 'Verified conference topics', 'sources': [project.conference_url]})[1]) +E AttributeError: has no attribute 'extract_scope' + +tests/test_verdict_acceptance.py:28: AttributeError +_ test_conference_completion_restores_activity_without_erasing_capabilities[Base] _ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_conference_completion_res1/talk'), title='slides', target_m...rrides={'voice': Voice(name='Ryan', source='Base', speaker='Ryan', description='', references={}, origin='reference')}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f096c1059a0> +source = 'Base' + + @pytest.mark.parametrize('source', ['CustomVoice', 'Base']) + def test_conference_completion_restores_activity_without_erasing_capabilities(qtbot, project, monkeypatch, source): + from autotalk import settings + release = threading.Event() + project.voice = Voice(source=source) + project.conference_url = 'https://conference.example' +> monkeypatch.setattr(settings, 'extract_scope', lambda *args: ( + release.wait(5), {'scope': 'Verified conference topics', 'sources': [project.conference_url]})[1]) +E AttributeError: has no attribute 'extract_scope' + +tests/test_verdict_acceptance.py:28: AttributeError +___________ test_disclosure_arrows_follow_actual_pointer_interaction ___________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_disclosure_arrows_follow_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_disclosure_arrows_follow_actual_pointer_interaction(qtbot, project): + project.slides[0].notes = 'Check this statement against the conference programme.' + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + assert isinstance(w.notes_toggle, QToolButton) + for expanded in (True, False): + qtbot.mouseClick(w.notes_toggle, Qt.MouseButton.LeftButton) + assert w.notes.isVisible() == expanded + assert w.notes_toggle.arrowType() == (Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow) + qtbot.mouseClick(w.voice_button, Qt.MouseButton.LeftButton) + dialog = w.settings[1] +> toggle = next(b for b in dialog.findChildren(QToolButton) if b.text() == 'More vocal attributes') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E StopIteration + +tests/test_verdict_acceptance.py:79: StopIteration +____ test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size0] ____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_pointer_navigation_all_fi0/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +size = (940, 680) + + @pytest.mark.parametrize('size', [(940, 680), (1100, 850)]) + def test_pointer_navigation_all_fixed_scopes_and_native_disclosures(qtbot, project, size): + from PySide6.QtWidgets import QApplication, QDialogButtonBox + from test_ux_placement import visible_inside + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.resize(*size); w.show() + directory = Path(os.environ['AUTOTALK_EVIDENCE_DIR']) if os.environ.get('AUTOTALK_EVIDENCE_DIR') else None + def snapshot(widget, name): + qtbot.wait(30) + if directory: + directory.mkdir(parents=True, exist_ok=True) + widget.grab().save(str(directory / f'{size[0]}-{name}.png')) + for mode in ('Prepared', 'Quick', 'Realtime'): + w.mode.setFocus() + qtbot.keyClick(w.mode, Qt.Key.Key_Home) + for _ in range(w.mode.findText(mode)): + qtbot.keyClick(w.mode, Qt.Key.Key_Down) + assert w.project.mode == mode + assert w.start_button.text() == ('Prepare and start' if mode == 'Prepared' else 'Start') + for control in (w.start_button, w.minutes, w.language, w.voice_button, w.record): + visible_inside(control, w) + snapshot(w, mode) + # Open actual native menus with the pointer, including a real submenu arrow. + # Keep Qt action wrappers alive during inspection: PySide ties menu wrappers + # to their action wrapper even though the native menubar owns the menu. + held_actions = w.menuBar().actions() + for action in held_actions: + qtbot.mouseClick(w.menuBar(), Qt.LeftButton, pos=w.menuBar().actionGeometry(action).center()) + menu = action.menu() + qtbot.waitUntil(menu.isVisible) + snapshot(menu, 'menu-' + action.text()) + assert all(a.menu() is None or not a.text().endswith('…') for a in menu.actions()) + qtbot.keyClick(menu, Qt.Key.Key_Escape) + routes = [(0, ('Settings', 'Application settings…')), + (1, ('Talk', 'Talk settings…')), + (2, ('Talk', 'Selected slide', 'Additional audio…'))] + for scope, path in routes: + container = w.menuBar() + for caption in path: + actions = container.actions() + held_actions.extend(actions) +> action = next(a for a in actions if a.text() == caption) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E StopIteration + +tests/test_verdict_acceptance.py:333: StopIteration +____ test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size1] ____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-396/test_pointer_navigation_all_fi1/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +size = (1100, 850) + + @pytest.mark.parametrize('size', [(940, 680), (1100, 850)]) + def test_pointer_navigation_all_fixed_scopes_and_native_disclosures(qtbot, project, size): + from PySide6.QtWidgets import QApplication, QDialogButtonBox + from test_ux_placement import visible_inside + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.resize(*size); w.show() + directory = Path(os.environ['AUTOTALK_EVIDENCE_DIR']) if os.environ.get('AUTOTALK_EVIDENCE_DIR') else None + def snapshot(widget, name): + qtbot.wait(30) + if directory: + directory.mkdir(parents=True, exist_ok=True) + widget.grab().save(str(directory / f'{size[0]}-{name}.png')) + for mode in ('Prepared', 'Quick', 'Realtime'): + w.mode.setFocus() + qtbot.keyClick(w.mode, Qt.Key.Key_Home) + for _ in range(w.mode.findText(mode)): + qtbot.keyClick(w.mode, Qt.Key.Key_Down) + assert w.project.mode == mode + assert w.start_button.text() == ('Prepare and start' if mode == 'Prepared' else 'Start') + for control in (w.start_button, w.minutes, w.language, w.voice_button, w.record): + visible_inside(control, w) + snapshot(w, mode) + # Open actual native menus with the pointer, including a real submenu arrow. + # Keep Qt action wrappers alive during inspection: PySide ties menu wrappers + # to their action wrapper even though the native menubar owns the menu. + held_actions = w.menuBar().actions() + for action in held_actions: + qtbot.mouseClick(w.menuBar(), Qt.LeftButton, pos=w.menuBar().actionGeometry(action).center()) + menu = action.menu() + qtbot.waitUntil(menu.isVisible) + snapshot(menu, 'menu-' + action.text()) + assert all(a.menu() is None or not a.text().endswith('…') for a in menu.actions()) + qtbot.keyClick(menu, Qt.Key.Key_Escape) + routes = [(0, ('Settings', 'Application settings…')), + (1, ('Talk', 'Talk settings…')), + (2, ('Talk', 'Selected slide', 'Additional audio…'))] + for scope, path in routes: + container = w.menuBar() + for caption in path: + actions = container.actions() + held_actions.extend(actions) +> action = next(a for a in actions if a.text() == caption) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E StopIteration + +tests/test_verdict_acceptance.py:333: StopIteration +=========================== short test summary info ============================ +FAILED tests/test_automatic_languages.py::test_settings_cancel_restores_versions_and_source_edits +FAILED tests/test_codex.py::test_discovery_updates_ui_without_changing_saved_selections[Prepared] +FAILED tests/test_codex.py::test_discovery_updates_ui_without_changing_saved_selections[Quick] +FAILED tests/test_codex.py::test_discovery_updates_ui_without_changing_saved_selections[Realtime] +FAILED tests/test_codex.py::test_application_startup_discovers_without_pdf_and_preserves_launch_project[False] +FAILED tests/test_codex.py::test_application_startup_discovers_without_pdf_and_preserves_launch_project[True] +FAILED tests/test_codex.py::test_auth_buttons_follow_account_and_logout_preserves_talk[False] +FAILED tests/test_codex.py::test_auth_buttons_follow_account_and_logout_preserves_talk[True] +FAILED tests/test_edit_navigation.py::test_inspector_edits_only_owned_field[pause_seconds] +FAILED tests/test_edit_navigation.py::test_edit_has_one_refresh_no_hidden_loads_and_two_readiness_passes[pause_seconds] +FAILED tests/test_gpu_lifecycle.py::test_saved_preferences_and_cancel_do_not_undo_manual_load +FAILED tests/test_language_changes.py::test_language_cancel_and_reset_restore_readiness[0] +FAILED tests/test_language_changes.py::test_language_cancel_and_reset_restore_readiness[1] +FAILED tests/test_language_changes.py::test_language_cancel_and_reset_restore_readiness[2] +FAILED tests/test_language_changes.py::test_language_arrangement_caption_follows_its_scope[0] +FAILED tests/test_language_changes.py::test_language_arrangement_caption_follows_its_scope[1] +FAILED tests/test_language_changes.py::test_language_arrangement_caption_follows_its_scope[2] +FAILED tests/test_model_progress.py::test_model_progress_is_distinct_from_concurrent_narration_and_follows_dialog +FAILED tests/test_refinement.py::test_desktop_workspaces_and_remembered_policy +FAILED tests/test_refinement.py::test_accepted_designed_voice_uses_exact_preview +FAILED tests/test_refinement.py::test_voice_library_preview_selection_and_cancel +FAILED tests/test_refinement.py::test_recording_sources_update_capabilities_and_restore_on_cancel +FAILED tests/test_scoped_settings.py::test_scopes_save_cancel_and_config_separation[False] +FAILED tests/test_scoped_settings.py::test_scopes_save_cancel_and_config_separation[True] +FAILED tests/test_scoped_settings.py::test_designed_origin_survives_accept_library_and_reopen +FAILED tests/test_scoped_settings.py::test_editing_reference_transcript_preserves_cursor +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Talk settings\u2026-Talk-1] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Voice && speech\u2026-Voice & language-1] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Presentation settings\u2026-Audio & recording-1] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Language options\u2026-Voice & language-1] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Additional audio\u2026-Audio & recording-2] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Application settings\u2026-Application-0] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Account\u2026-Application-0] +FAILED tests/test_settings_dialog.py::test_menu_shortcuts_open_the_fixed_scope_and_target[Speech engine\u2026-AI model-0] +FAILED tests/test_settings_dialog.py::test_buttons_and_nested_account_window_preserve_independent_transactions +FAILED tests/test_settings_dialog.py::test_talk_save_cancel_applies_across_its_sections_only[False] +FAILED tests/test_settings_dialog.py::test_talk_save_cancel_applies_across_its_sections_only[True] +FAILED tests/test_settings_dialog.py::test_application_display_and_model_policies_are_transactional[False] +FAILED tests/test_settings_dialog.py::test_application_display_and_model_policies_are_transactional[True] +FAILED tests/test_settings_dialog.py::test_application_settings_work_without_a_talk +FAILED tests/test_settings_dialog.py::test_slide_binding_stays_pinned_and_cancel_does_not_revert_other_scopes +FAILED tests/test_settings_dialog.py::test_mode_edit_updates_main_window_from_resolved_setting[0] +FAILED tests/test_settings_dialog.py::test_cancel_after_background_work_preserves_completed_result +FAILED tests/test_settings_dialog.py::test_clip_controls_stay_bound_to_the_open_slide +FAILED tests/test_start_interaction.py::test_dialog_labels_have_ellipses_and_direct_actions_do_not +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[GPT-6-Astra] +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[] +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[None] +FAILED tests/test_surface_refresh.py::test_incremental_journey_matches_full_refresh[Prepared] +FAILED tests/test_surface_refresh.py::test_incremental_journey_matches_full_refresh[Quick] +FAILED tests/test_surface_refresh.py::test_incremental_journey_matches_full_refresh[Realtime] +FAILED tests/test_ui.py::test_recording_selection_visible_in_workspaces_and_persisted +FAILED tests/test_ui.py::test_settings_open_and_save_preserve_selected_slide +FAILED tests/test_ui.py::test_cancel_settings_restores_project_and_manifest_after_eager_save +FAILED tests/test_ui.py::test_preferences_cancel_restores_retention_and_sampling +FAILED tests/test_ui.py::test_quick_keeps_basics_in_main_window_and_cancel_restores_settings +FAILED tests/test_ui.py::test_commands_and_dialogs_cannot_bypass_job_or_playback_lock +FAILED tests/test_ui.py::test_recording_checkbox_stays_in_main_window_and_dialog_cancel_restores_settings +FAILED tests/test_ui_ownership.py::test_each_component_owns_widgets_and_builders +FAILED tests/test_ui_ownership.py::test_cancel_restores_clip_gain_and_placement +FAILED tests/test_ui_ownership.py::test_tab_order_matches_base - AttributeErr... +FAILED tests/test_usability.py::test_previous_video_does_not_override_new_recording_intent +FAILED tests/test_usability.py::test_voice_actions_follow_reference_and_current_design +FAILED tests/test_usability.py::test_operation_feedback_is_in_the_active_dialog +FAILED tests/test_usability.py::test_inapplicable_options_and_recording_browser_priorities +FAILED tests/test_usability.py::test_fullscreen_mouse_controls_and_finished_return +FAILED tests/test_usability.py::test_saved_voice_preview_feedback_and_settings_have_one_owner +FAILED tests/test_ux_placement.py::test_dialog_destinations_and_direct_voice_sources +FAILED tests/test_ux_placement.py::test_saved_voice_audition_does_not_select_or_invalidate_talk +FAILED tests/test_ux_placement.py::test_delivery_preset_and_style_never_overwrite_sampling +FAILED tests/test_ux_placement.py::test_cancel_restores_clips_display_and_project_settings +FAILED tests/test_ux_placement.py::test_engine_account_and_f5_use_their_single_authoritative_commands +FAILED tests/test_ux_redundancy.py::test_single_editor_ownership - AttributeE... +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[False-mode-Prepared-Quick-Talk] +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[False-language-English-German-Voice & language] +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[False-record_presentation-False-True-Audio & recording] +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[True-mode-Prepared-Quick-Talk] +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[True-language-English-German-Voice & language] +FAILED tests/test_ux_redundancy.py::test_readonly_reset_save_cancel[True-record_presentation-False-True-Audio & recording] +FAILED tests/test_ux_redundancy.py::test_inspector_source_pause_and_delivery_independence[False] +FAILED tests/test_ux_redundancy.py::test_inspector_source_pause_and_delivery_independence[True] +FAILED tests/test_ux_redundancy.py::test_readouts_recording_and_progress_position +FAILED tests/test_ux_redundancy.py::test_menu_focus_status_and_background - a... +FAILED tests/test_verdict_acceptance.py::test_conference_completion_restores_activity_without_erasing_capabilities[CustomVoice] +FAILED tests/test_verdict_acceptance.py::test_conference_completion_restores_activity_without_erasing_capabilities[Base] +FAILED tests/test_verdict_acceptance.py::test_disclosure_arrows_follow_actual_pointer_interaction +FAILED tests/test_verdict_acceptance.py::test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size0] +FAILED tests/test_verdict_acceptance.py::test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size1] +88 failed, 430 passed in 100.30s (0:01:40) + +(xfconfd:3430011): xfconfd-WARNING **: 23:26:36.825: Name org.xfce.Xfconf lost on the message dbus, exiting. +A connection to the bus can't be made +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/second-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/second-wheel-full.log new file mode 100644 index 0000000..7140a2e --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/second-wheel-full.log @@ -0,0 +1,46 @@ +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3434069 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3434069 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3434084]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3434069 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3434069 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434084]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3434067]: [session uid=1000 pid=3434067 pidfd=5] Successfully activated service 'org.xfce.Xfconf' + +==================================== ERRORS ==================================== +________________ ERROR collecting tests/test_edit_navigation.py ________________ +.venv/lib/python3.12/site-packages/_pytest/python.py:508: in importtestmodule + mod = import_path( +.venv/lib/python3.12/site-packages/_pytest/pathlib.py:596: in import_path + importlib.import_module(module_name) +/home/voc/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/importlib/__init__.py:90: in import_module + return _bootstrap._gcd_import(name[level:], package, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:1387: in _gcd_import + ??? +:1360: in _find_and_load + ??? +:1331: in _find_and_load_unlocked + ??? +:935: in _load_unlocked + ??? +.venv/lib/python3.12/site-packages/_pytest/assertion/rewrite.py:179: in exec_module + source_stat, co = _rewrite_test(fn, self.config) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv/lib/python3.12/site-packages/_pytest/assertion/rewrite.py:348: in _rewrite_test + tree = ast.parse(source, filename=strfn) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/home/voc/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/ast.py:52: in parse + return compile(source, filename, mode, flags, +E File "/home/voc/projects/drafts/autotalk/AutoTalk/tests/test_edit_navigation.py", line 33 +E elif field == 'budget_seconds': +E ^^^^ +E IndentationError: expected an indented block after 'elif' statement on line 32 +=========================== short test summary info ============================ +ERROR tests/test_edit_navigation.py +!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! +1 error in 0.86s +A connection to the bus can't be made + +(xfconfd:3434102): xfconfd-WARNING **: 23:33:07.207: Name org.xfce.Xfconf lost on the message dbus, exiting. diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/third-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/third-wheel-full.log new file mode 100644 index 0000000..f5f1b7b --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/third-wheel-full.log @@ -0,0 +1,833 @@ +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3434417 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3434417 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3434432]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3434417 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3434417 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434432]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3434478 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3434514 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3434514 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3434514): xdg-desktop-portal-WARNING **: 23:35:04.199: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3434514 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3434514 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3434514 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3434417 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3434415]: [session uid=1000 pid=3434415 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +...F........................................FF.......................... [ 13%] +......................................FFFFFFFFF......................... [ 26%] +........................................................................ [ 39%] +............................................F.................F......... [ 52%] +........................................................................ [ 65%] +.................................................FFF...........F..... +(/usr/libexec/xdg-desktop-portal:3434514): xdg-desktop-portal-WARNING **: 23:36:24.424: Realtime error: Could not map tid 3436497 of pid 3434478: Process ids could not be found: 3436497 +... [ 78%] +.................................F...................................... [ 91%] +............................FF............... [100%] +=================================== FAILURES =================================== +___________ test_settings_cancel_restores_versions_and_source_edits ____________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_settings_cancel_restores_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_settings_cancel_restores_versions_and_source_edits(qtbot, project): + make_audio(project) + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.narration.setPlainText('My unsaved English edit.') + project.version.language = 'English' + before = copy.deepcopy(project) + w.defaults['language'] = 'German'; w.apply_defaults(project) + dialog = w.settings[1]; dialog.show_section('Voice & language') + dialog.fields['language'].reset.click() + assert len(w.settings[1].project.versions) == 2 + assert project.versions['main'].slides[0].narration == 'My unsaved English edit.' + dialog.reject() + assert w.project.versions == before.versions and w.project.active_version == before.active_version + assert w.narration.toPlainText() == 'My unsaved English edit.' +> assert Project.load(project.manifest).versions == before.versions +E AssertionError: assert {'main': Talk..., clips=[])])} == {'main': Talk..., clips=[])])} +E +E Differing items: +E {'main': TalkVersion(name='', language='', slides=[Slide(page=1, source_text='Reliable presentations\r\nOne controller...256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', audio_provenance={}, duration=0.3, clips=[])])} != {'main': TalkVersion(name='', language='English', slides=[Slide(page=1, source_text='Reliable presentations\r\nOne con...256='651d0bac16d2010f6d3ff53b1b4493814736616c0e89528e7a723bee13a8152d', audio_provenance={}, duration=0.3, clips=[])])} +E Use -v to get more diff + +tests/test_automatic_languages.py:76: AssertionError +_ test_application_startup_discovers_without_pdf_and_preserves_launch_project[False] _ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-399/test_application_startup_disco0/codex') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e981556d0> +qtbot = +qapp = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_application_startup_disco0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +open_saved = False + + @pytest.mark.parametrize('open_saved', [False, True]) + def test_application_startup_discovers_without_pdf_and_preserves_launch_project(fake_server, monkeypatch, qtbot, qapp, project, open_saved): + from types import SimpleNamespace + from autotalk import app + windows = [] + original = app.MainWindow + def window(): + w = original(); qtbot.addWidget(w); windows.append(w); return w + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].models.codex_model.count() == 2 + assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + windows[0].close() + return 0 + project.save() + monkeypatch.setattr(app, 'MainWindow', window) + monkeypatch.setattr(app, 'QApplication', lambda *_: SimpleNamespace( + setApplicationName=lambda *_: None, setOrganizationName=lambda *_: None, exec=event_loop)) + app.QApplication.activeModalWidget = qapp.activeModalWidget + monkeypatch.setattr(sys, 'argv', ['autotalk'] + ([str(project.manifest)] if open_saved else [])) +> assert app.main() == 0 + ^^^^^^^^^^ + +tests/test_codex.py:240: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/app.py:1394: in main + return app.exec() + ^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].models.codex_model.count() == 2 +> assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'TalkDefaultsDialog' object has no attribute 'codex_effort' + +tests/test_codex.py:231: AttributeError +_ test_application_startup_discovers_without_pdf_and_preserves_launch_project[True] _ + +fake_server = PosixPath('/tmp/pytest-of-voc/pytest-399/test_application_startup_disco1/codex') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e98156990> +qtbot = +qapp = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_application_startup_disco1/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) +open_saved = True + + @pytest.mark.parametrize('open_saved', [False, True]) + def test_application_startup_discovers_without_pdf_and_preserves_launch_project(fake_server, monkeypatch, qtbot, qapp, project, open_saved): + from types import SimpleNamespace + from autotalk import app + windows = [] + original = app.MainWindow + def window(): + w = original(); qtbot.addWidget(w); windows.append(w); return w + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].models.codex_model.count() == 2 + assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + windows[0].close() + return 0 + project.save() + monkeypatch.setattr(app, 'MainWindow', window) + monkeypatch.setattr(app, 'QApplication', lambda *_: SimpleNamespace( + setApplicationName=lambda *_: None, setOrganizationName=lambda *_: None, exec=event_loop)) + app.QApplication.activeModalWidget = qapp.activeModalWidget + monkeypatch.setattr(sys, 'argv', ['autotalk'] + ([str(project.manifest)] if open_saved else [])) +> assert app.main() == 0 + ^^^^^^^^^^ + +tests/test_codex.py:240: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/app.py:1394: in main + return app.exec() + ^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def event_loop(): + qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) + assert bool(windows[0].project) == open_saved + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].models.codex_model.count() == 2 +> assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'TalkSettingsDialog' object has no attribute 'codex_effort' + +tests/test_codex.py:231: AttributeError +______ test_language_change_generates_fresh_text_before_start[0-Prepared] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates0/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Prepared'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e52c48d10> +local_speech = None, mode = 'Prepared', scope = 0 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +_______ test_language_change_generates_fresh_text_before_start[0-Quick] ________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates1/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e703cebd0> +local_speech = None, mode = 'Quick', scope = 0 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +______ test_language_change_generates_fresh_text_before_start[0-Realtime] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates2/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e7088b8c0> +local_speech = None, mode = 'Realtime', scope = 0 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +______ test_language_change_generates_fresh_text_before_start[1-Prepared] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates3/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Prepared'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e7088aab0> +local_speech = None, mode = 'Prepared', scope = 1 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +_______ test_language_change_generates_fresh_text_before_start[1-Quick] ________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates4/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e76955bb0> +local_speech = None, mode = 'Quick', scope = 1 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +______ test_language_change_generates_fresh_text_before_start[1-Realtime] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates5/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e7a50a450> +local_speech = None, mode = 'Realtime', scope = 1 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 0 == 2 +E + where 0 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +______ test_language_change_generates_fresh_text_before_start[2-Prepared] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates6/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Prepared'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e7a545460> +local_speech = None, mode = 'Prepared', scope = 2 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 1 == 2 +E + where 1 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +_______ test_language_change_generates_fresh_text_before_start[2-Quick] ________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates7/talk'), title='slides', target_m...ame='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Quick'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e7af43500> +local_speech = None, mode = 'Quick', scope = 2 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 1 == 2 +E + where 1 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +______ test_language_change_generates_fresh_text_before_start[2-Realtime] ______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_language_change_generates8/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e983a20c0> +local_speech = None, mode = 'Realtime', scope = 2 + + @pytest.mark.parametrize('mode', ['Prepared', 'Quick', 'Realtime']) + @pytest.mark.parametrize('scope', [0, 1, 2]) + def test_language_change_generates_fresh_text_before_start(qtbot, project, monkeypatch, local_speech, mode, scope): + project.mode = mode + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + w.edit_setting('language', 'Chinese', scope=0 if scope == 0 else 1) + for index in range(2): + w.transport.select(index) + w.narration.setPlainText('这是中文演讲。') + w.transport.select(0) + make_audio(project) + untouched = copy.deepcopy(project.slides[1]) + if scope == 1: + w.language.setCurrentText('German') + else: + dialog = w.settings[scope] + dialog.show_section('Voice & language') + dialog.fields['language'].editor.setCurrentText('German') + dialog.accept() +> assert w.narration_progress.value() == 2 +E assert 1 == 2 +E + where 1 = () +E + where = .value +E + where = .narration_progress + +tests/test_language_changes.py:35: AssertionError +________________ test_desktop_workspaces_and_remembered_policy _________________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_desktop_workspaces_and_re0/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) + + def test_desktop_workspaces_and_remembered_policy(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project) + assert [a.text() for a in w.menuBar().actions()]==['File', 'Edit', 'View', 'Talk', 'Presentation', 'Settings', 'Help'] + assert w.workspace.currentWidget() is w.editor + w.mode.setCurrentText('Quick') + assert w.workspace.currentWidget() is w.quick_page + w.mode.setCurrentText('Realtime') + assert w.workspace.currentWidget() is w.editor + w.settings[0].show_section("AI model") + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value') == 'idle').click() +> assert w.speech.retention == 'session' +E AssertionError: assert 'idle' == 'session' +E +E - session +E + idle + +tests/test_refinement.py:229: AssertionError +_______ test_recording_sources_update_capabilities_and_restore_on_cancel _______ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_recording_sources_update_0/talk'), title='slides', target_m...{'voice': Voice(name='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference')}) + + def test_recording_sources_update_capabilities_and_restore_on_cancel(qtbot, project): + w = MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + w.settings[1].show_section("Audio & recording") + w.settings[1].fields['recording_source'].editor.setCurrentIndex(1) + assert w.settings[1].project.recording_source == 'screen' + assert project.recording_source == 'slides' +> assert w.settings[1].fields['capture_microphone'].editor.isEnabled() +E assert False +E + where False = () +E + where = .isEnabled +E + where = .editor + +tests/test_refinement.py:557: AssertionError +___ test_account_default_and_explicit_model_use_identical_name[GPT-6-Astra] ____ + +qtbot = , display = 'GPT-6-Astra' + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} + w.settings[1].show_section('AI model') +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:137: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + def setting_value(self, key): + if key not in SETTING_DEFAULTS: + return getattr(self.project, key) + if self.scope == 0: + return self.project.defaults.get(key, copy.deepcopy(SETTING_DEFAULTS[key][0])) +> return self.project.setting(key, self.slide) + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:81: AttributeError +_________ test_account_default_and_explicit_model_use_identical_name[] _________ + +qtbot = , display = '' + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} + w.settings[1].show_section('AI model') +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:137: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + def setting_value(self, key): + if key not in SETTING_DEFAULTS: + return getattr(self.project, key) + if self.scope == 0: + return self.project.defaults.get(key, copy.deepcopy(SETTING_DEFAULTS[key][0])) +> return self.project.setting(key, self.slide) + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:81: AttributeError +_______ test_account_default_and_explicit_model_use_identical_name[None] _______ + +qtbot = , display = None + + @pytest.mark.parametrize('display', ['GPT-6-Astra', '', None]) + def test_account_default_and_explicit_model_use_identical_name(qtbot, display): + w = MainWindow(); qtbot.addWidget(w) + w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} + w.settings[1].show_section('AI model') +> w.settings[1].models.refresh_models() + +tests/test_start_interaction.py:137: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/autotalk/settings_components.py:313: in refresh_models + wanted = self.dialog.setting_value("codex_model") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +key = 'codex_model' + + def setting_value(self, key): + if key not in SETTING_DEFAULTS: + return getattr(self.project, key) + if self.scope == 0: + return self.project.defaults.get(key, copy.deepcopy(SETTING_DEFAULTS[key][0])) +> return self.project.setting(key, self.slide) + ^^^^^^^^^^^^^^^^^^^^ +E AttributeError: 'NoneType' object has no attribute 'setting' + +src/autotalk/settings.py:81: AttributeError +_________ test_recording_selection_visible_in_workspaces_and_persisted _________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_recording_selection_visib0/talk'), title='slides', target_m...ource='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': False}) + + def test_recording_selection_visible_in_workspaces_and_persisted(qtbot, project): + from PySide6.QtCore import QPoint + from autotalk.project import Project + window = MainWindow() + qtbot.addWidget(window) + window.adopt(project) + window.resize(940, 680) + window.show() + checkbox = window.record + for page in (window.editor, window.presenter, window.quick_page): + window.workspace.setCurrentWidget(page) + qtbot.wait(10) + assert checkbox.isVisible() and checkbox.isEnabled() + top = checkbox.mapTo(window, QPoint(0, 0)) + assert window.rect().contains(top) + assert top.y() + checkbox.height() <= window.height() + # The label and keyboard both operate the single project-backed checkbox. + qtbot.mouseClick(checkbox, Qt.MouseButton.LeftButton, pos=QPoint(55, checkbox.height()//2)) + assert project.record_presentation + window.save() + assert Project.load(project.manifest).record_presentation + checkbox.setFocus() + qtbot.keyClick(checkbox, Qt.Key.Key_Space) + assert not project.record_presentation + window.transport.state = 'paused' + window.refresh() + assert not checkbox.isEnabled() +> assert not window.settings[1].fields['recording_source'].editor.isEnabled() +E assert not True +E + where True = () +E + where = .isEnabled +E + where = .editor + +tests/test_ui.py:37: AssertionError +__________ test_previous_video_does_not_override_new_recording_intent __________ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_previous_video_does_not_o0/talk'), title='slides', target_m...source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'record_presentation': True}) +tmp_path = PosixPath('/tmp/pytest-of-voc/pytest-399/test_previous_video_does_not_o0') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f8e52bd12e0> + + def test_previous_video_does_not_override_new_recording_intent(qtbot, project, tmp_path, monkeypatch): + w = MainWindow(); qtbot.addWidget(w); w.adopt(project) + video = tmp_path / 'previous.mp4'; video.write_bytes(b'video') + w.export_saved(video) + w.record.click() + assert not w.record_status.isVisible() and w.record.isChecked() + assert 'Saved ' in w.saved_output.text() and 'Open video' in w.saved_output.text() + w.settings[1].fields['recording_source'].editor.setCurrentIndex(w.settings[1].fields['recording_source'].editor.findData('screen')) + w.settings[1].accept() +> assert 'Screen + system audio' in w.record.toolTip() +E AssertionError: assert 'Screen + system audio' in 'Recording source: Slide video + narration. Configure it in Presentation settings → Recording.' +E + where 'Recording source: Slide video + narration. Configure it in Presentation settings → Recording.' = () +E + where = .toolTip +E + where = .record + +tests/test_usability.py:225: AssertionError +____ test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size0] ____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_pointer_navigation_all_fi0/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +size = (940, 680) + + @pytest.mark.parametrize('size', [(940, 680), (1100, 850)]) + def test_pointer_navigation_all_fixed_scopes_and_native_disclosures(qtbot, project, size): + from PySide6.QtWidgets import QApplication, QDialogButtonBox + from test_ux_placement import visible_inside + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.resize(*size); w.show() + directory = Path(os.environ['AUTOTALK_EVIDENCE_DIR']) if os.environ.get('AUTOTALK_EVIDENCE_DIR') else None + def snapshot(widget, name): + qtbot.wait(30) + if directory: + directory.mkdir(parents=True, exist_ok=True) + widget.grab().save(str(directory / f'{size[0]}-{name}.png')) + for mode in ('Prepared', 'Quick', 'Realtime'): + w.mode.setFocus() + qtbot.keyClick(w.mode, Qt.Key.Key_Home) + for _ in range(w.mode.findText(mode)): + qtbot.keyClick(w.mode, Qt.Key.Key_Down) + assert w.project.mode == mode + assert w.start_button.text() == ('Prepare and start' if mode == 'Prepared' else 'Start') + for control in (w.start_button, w.minutes, w.language, w.voice_button, w.record): + visible_inside(control, w) + snapshot(w, mode) + # Open actual native menus with the pointer, including a real submenu arrow. + # Keep Qt action wrappers alive during inspection: PySide ties menu wrappers + # to their action wrapper even though the native menubar owns the menu. + held_actions = w.menuBar().actions() + for action in held_actions: + qtbot.mouseClick(w.menuBar(), Qt.LeftButton, pos=w.menuBar().actionGeometry(action).center()) + menu = action.menu() + qtbot.waitUntil(menu.isVisible) + snapshot(menu, 'menu-' + action.text()) + assert all(a.menu() is None or not a.text().endswith('…') for a in menu.actions()) + qtbot.keyClick(menu, Qt.Key.Key_Escape) + routes = [(0, ('Settings', 'Talk defaults…')), + (1, ('Talk', 'Talk settings…')), + (2, ('Talk', 'Selected slide', 'Additional audio…'))] + for scope, path in routes: + container = w.menuBar() + for caption in path: + actions = container.actions() + held_actions.extend(actions) + action = next(a for a in actions if a.text() == caption) + qtbot.mouseClick(container, Qt.LeftButton, pos=container.actionGeometry(action).center()) + if action.menu(): + container = action.menu() + qtbot.waitUntil(container.isVisible) + dialog = w.settings[scope] + qtbot.waitUntil(dialog.isVisible) + assert QApplication.activeModalWidget() is dialog + dialog.resize(760 if size[0] == 940 else 920, 580 if size[0] == 940 else 760) + assert not hasattr(dialog, 'scope_selector') + for index in range(dialog.navigation.count()): + item = dialog.navigation.item(index) + if item.isHidden(): + continue + qtbot.mouseClick(dialog.navigation.viewport(), Qt.LeftButton, + pos=dialog.navigation.visualItemRect(item).center()) + assert dialog.pages.currentIndex() == index +> assert dialog.voice.voice_audition.isVisible() == (index == 0) +E assert False == (0 == 0) +E + where False = () +E + where = .isVisible +E + where = .voice_audition +E + where = .voice + +tests/test_verdict_acceptance.py:353: AssertionError +____ test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size1] ____ + +qtbot = +project = Project(root=PosixPath('/tmp/pytest-of-voc/pytest-399/test_pointer_navigation_all_fi1/talk'), title='slides', target_m...='Ryan', source='CustomVoice', speaker='Ryan', description='', references={}, origin='reference'), 'mode': 'Realtime'}) +size = (1100, 850) + + @pytest.mark.parametrize('size', [(940, 680), (1100, 850)]) + def test_pointer_navigation_all_fixed_scopes_and_native_disclosures(qtbot, project, size): + from PySide6.QtWidgets import QApplication, QDialogButtonBox + from test_ux_placement import visible_inside + w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.resize(*size); w.show() + directory = Path(os.environ['AUTOTALK_EVIDENCE_DIR']) if os.environ.get('AUTOTALK_EVIDENCE_DIR') else None + def snapshot(widget, name): + qtbot.wait(30) + if directory: + directory.mkdir(parents=True, exist_ok=True) + widget.grab().save(str(directory / f'{size[0]}-{name}.png')) + for mode in ('Prepared', 'Quick', 'Realtime'): + w.mode.setFocus() + qtbot.keyClick(w.mode, Qt.Key.Key_Home) + for _ in range(w.mode.findText(mode)): + qtbot.keyClick(w.mode, Qt.Key.Key_Down) + assert w.project.mode == mode + assert w.start_button.text() == ('Prepare and start' if mode == 'Prepared' else 'Start') + for control in (w.start_button, w.minutes, w.language, w.voice_button, w.record): + visible_inside(control, w) + snapshot(w, mode) + # Open actual native menus with the pointer, including a real submenu arrow. + # Keep Qt action wrappers alive during inspection: PySide ties menu wrappers + # to their action wrapper even though the native menubar owns the menu. + held_actions = w.menuBar().actions() + for action in held_actions: + qtbot.mouseClick(w.menuBar(), Qt.LeftButton, pos=w.menuBar().actionGeometry(action).center()) + menu = action.menu() + qtbot.waitUntil(menu.isVisible) + snapshot(menu, 'menu-' + action.text()) + assert all(a.menu() is None or not a.text().endswith('…') for a in menu.actions()) + qtbot.keyClick(menu, Qt.Key.Key_Escape) + routes = [(0, ('Settings', 'Talk defaults…')), + (1, ('Talk', 'Talk settings…')), + (2, ('Talk', 'Selected slide', 'Additional audio…'))] + for scope, path in routes: + container = w.menuBar() + for caption in path: + actions = container.actions() + held_actions.extend(actions) + action = next(a for a in actions if a.text() == caption) + qtbot.mouseClick(container, Qt.LeftButton, pos=container.actionGeometry(action).center()) + if action.menu(): + container = action.menu() + qtbot.waitUntil(container.isVisible) + dialog = w.settings[scope] + qtbot.waitUntil(dialog.isVisible) + assert QApplication.activeModalWidget() is dialog + dialog.resize(760 if size[0] == 940 else 920, 580 if size[0] == 940 else 760) + assert not hasattr(dialog, 'scope_selector') + for index in range(dialog.navigation.count()): + item = dialog.navigation.item(index) + if item.isHidden(): + continue + qtbot.mouseClick(dialog.navigation.viewport(), Qt.LeftButton, + pos=dialog.navigation.visualItemRect(item).center()) + assert dialog.pages.currentIndex() == index +> assert dialog.voice.voice_audition.isVisible() == (index == 0) +E assert False == (0 == 0) +E + where False = () +E + where = .isVisible +E + where = .voice_audition +E + where = .voice + +tests/test_verdict_acceptance.py:353: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_automatic_languages.py::test_settings_cancel_restores_versions_and_source_edits +FAILED tests/test_codex.py::test_application_startup_discovers_without_pdf_and_preserves_launch_project[False] +FAILED tests/test_codex.py::test_application_startup_discovers_without_pdf_and_preserves_launch_project[True] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[0-Prepared] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[0-Quick] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[0-Realtime] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[1-Prepared] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[1-Quick] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[1-Realtime] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[2-Prepared] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[2-Quick] +FAILED tests/test_language_changes.py::test_language_change_generates_fresh_text_before_start[2-Realtime] +FAILED tests/test_refinement.py::test_desktop_workspaces_and_remembered_policy +FAILED tests/test_refinement.py::test_recording_sources_update_capabilities_and_restore_on_cancel +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[GPT-6-Astra] +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[] +FAILED tests/test_start_interaction.py::test_account_default_and_explicit_model_use_identical_name[None] +FAILED tests/test_ui.py::test_recording_selection_visible_in_workspaces_and_persisted +FAILED tests/test_usability.py::test_previous_video_does_not_override_new_recording_intent +FAILED tests/test_verdict_acceptance.py::test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size0] +FAILED tests/test_verdict_acceptance.py::test_pointer_navigation_all_fixed_scopes_and_native_disclosures[size1] +21 failed, 528 passed in 109.20s (0:01:49) + +(xfconfd:3434450): xfconfd-WARNING **: 23:36:53.288: Name org.xfce.Xfconf lost on the message dbus, exiting. +A connection to the bus can't be made +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-native-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-native-full.log new file mode 100644 index 0000000..07e7b3d --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-native-full.log @@ -0,0 +1,66 @@ +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3453632 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3453632 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3453652]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3453632 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3453632 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3453652]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3453699 comm="artifacts/open-tasks/venv-qt610/bin/python -m pyte" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3453732 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3453732 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3453732): xdg-desktop-portal-WARNING **: 23:53:24.825: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3453732 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3453732 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3453732 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3453632 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3453630]: [session uid=1000 pid=3453630 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 12%] +........................................................................ [ 25%] +........................................................................ [ 38%] +........................................................................ [ 51%] +........................................................................ [ 64%] +........................................................................ [ 77%] +......... +(/usr/libexec/xdg-desktop-portal:3453732): xdg-desktop-portal-WARNING **: 23:55:15.867: Realtime error: Could not map tid 3456285 of pid 3453699: Process ids could not be found: 3456285 +...................F........................................... [ 89%] +......................................................... [100%] +=================================== FAILURES =================================== +_________________________ test_tab_order_matches_base __________________________ + +qtbot = + + def test_tab_order_matches_base(qtbot): + import json + expected = json.loads(Path('docs/reviews/evidence/2026-10-01-settings-refactor/settings-tab-order.json').read_text()) + w = MainWindow(); qtbot.addWidget(w) + for scope, dialog in w.settings.items(): +> assert tab_order(dialog) == expected[str(scope)] +E AssertionError: assert ['navigation'..._button', ...] == ['navigation'...jective', ...] +E +E At index 2 diff: 'field:audience' != 'reset:title' +E Use -v to get more diff + +tests/test_ui_ownership.py:85: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_ui_ownership.py::test_tab_order_matches_base - AssertionErr... +1 failed, 560 passed in 149.85s (0:02:29) +A connection to the bus can't be made + +(xfconfd:3453670): xfconfd-WARNING **: 23:55:54.653: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-wheel-full.log new file mode 100644 index 0000000..562e04d --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/attempts/verified-wheel-full.log @@ -0,0 +1,47 @@ +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3450477 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3450477 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3450493]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3450477 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3450477 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3450493]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3450536 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3450572 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3450572 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3450572): xdg-desktop-portal-WARNING **: 23:51:26.630: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3450572 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3450572 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3450572 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3450477 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3450475]: [session uid=1000 pid=3450475 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 12%] +........................................................................ [ 25%] +........................................................................ [ 38%] +........................................................................ [ 51%] +........................................................................ [ 64%] +........................................................................ [ 77%] +......... +(/usr/libexec/xdg-desktop-portal:3450572): xdg-desktop-portal-WARNING **: 23:52:52.902: Realtime error: Could not map tid 3452778 of pid 3450536: Process ids could not be found: 3452778 +............................................................... [ 89%] +......................................................... [100%] +561 passed in 117.41s (0:01:57) +A connection to the bus can't be made + +(xfconfd:3450510): xfconfd-WARNING **: 23:53:23.948: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/baseline-bench.patch b/docs/reviews/evidence/2026-10-01-settings-refactor/baseline-bench.patch new file mode 100644 index 0000000..4ea24e5 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/baseline-bench.patch @@ -0,0 +1,53 @@ +diff --git a/tools/bench_ui.py b/tools/bench_ui.py +index 85a14bc..370d0d1 100644 +--- a/tools/bench_ui.py ++++ b/tools/bench_ui.py +@@ -61,6 +61,8 @@ def benchmark(count, repeats): + original = copy.deepcopy(project) + def restore(): + nonlocal project ++ for dialog in window.settings.values(): ++ if dialog.isVisible(): dialog.reject() + project = copy.deepcopy(original) + window.adopt(project) + window.select_slide(count // 2) +@@ -75,8 +77,10 @@ def benchmark(count, repeats): + 'keystroke': lambda: window.narration.insertPlainText('x'), + 'selection': lambda: window.slide_list.setCurrentRow((window.transport.index + 1) % count)} + actions.update({ ++ 'dialog_edit': lambda: window.settings[1].audience.setText('Changed audience'), ++ 'dialog_save': window.settings[1].accept, + 'after': lambda: window.inspector.slide_after.setCurrentIndex(window.inspector.slide_after.findData('pause')), +- 'pause': lambda: window.inspector.inherit_pause.setChecked(False), ++ 'pause': lambda: window.inspector.slide_pause.setValue(1.2), + 'budget': lambda: window.inspector.slide_budget.setValue(75), + 'include': lambda: window.inspector.slide_include.setChecked(False), + 'duration': lambda: window.minutes.setValue(12), +@@ -87,17 +91,21 @@ def benchmark(count, repeats): + }) + if hasattr(window, 'flush_refresh'): + actions['aggregates'] = window.flush_refresh ++ def prepare_action(name): ++ restore() ++ if name.startswith('dialog_'): ++ window.settings[1].show_section('Talk & preparation') ++ if name == 'dialog_save': ++ window.settings[1].audience.setText('Changed audience') ++ if name == 'aggregates': ++ window.narration.insertPlainText('x') + for name, action in actions.items(): + samples = [] + for _ in range(repeats): +- restore() +- if name == 'aggregates': +- window.narration.insertPlainText('x') ++ prepare_action(name) + start = time.perf_counter(); action() + samples.append((time.perf_counter() - start) * 1000) +- restore() +- if name == 'aggregates': +- window.narration.insertPlainText('x') ++ prepare_action(name) + counts = Counter() + def trace(frame, event, arg): + if event == 'call' and frame.f_code in codes: diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/bench-after.json b/docs/reviews/evidence/2026-10-01-settings-refactor/bench-after.json new file mode 100644 index 0000000..cae0427 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/bench-after.json @@ -0,0 +1,368 @@ +[ + { + "slides": 20, + "refresh": { + "ms": 2.585, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 20, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "keystroke": { + "ms": 0.354, + "calls": { + "ready": 1, + "speech_key": 1, + "text_ready": 1, + "asset": 1, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "selection": { + "ms": 4.672, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "dialog_edit": { + "ms": 0.329, + "calls": { + "ready": 0, + "speech_key": 0, + "text_ready": 0, + "asset": 0, + "refresh": 0, + "dialog_loads": 1, + "configuration_changed": 0 + } + }, + "dialog_save": { + "ms": 6.097, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "after": { + "ms": 7.205, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "pause": { + "ms": 8.145, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "budget": { + "ms": 4.881, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "include": { + "ms": 8.418, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "duration": { + "ms": 5.126, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "record": { + "ms": 7.084, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "mode": { + "ms": 7.375, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "editor_next": { + "ms": 4.857, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "editor_previous": { + "ms": 4.657, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "aggregates": { + "ms": 3.329, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 19, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + } + }, + { + "slides": 60, + "refresh": { + "ms": 7.016, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 60, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "keystroke": { + "ms": 0.39, + "calls": { + "ready": 1, + "speech_key": 1, + "text_ready": 1, + "asset": 1, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "selection": { + "ms": 8.893, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "dialog_edit": { + "ms": 0.34, + "calls": { + "ready": 0, + "speech_key": 0, + "text_ready": 0, + "asset": 0, + "refresh": 0, + "dialog_loads": 1, + "configuration_changed": 0 + } + }, + "dialog_save": { + "ms": 12.693, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "after": { + "ms": 17.286, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "pause": { + "ms": 18.962, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "budget": { + "ms": 11.03, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "include": { + "ms": 19.279, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "duration": { + "ms": 11.414, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "record": { + "ms": 18.031, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "mode": { + "ms": 17.894, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "editor_next": { + "ms": 9.06, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "editor_previous": { + "ms": 9.147, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "aggregates": { + "ms": 7.711, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 59, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + } + } +] diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/bench-before.json b/docs/reviews/evidence/2026-10-01-settings-refactor/bench-before.json new file mode 100644 index 0000000..c76627a --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/bench-before.json @@ -0,0 +1,368 @@ +[ + { + "slides": 20, + "refresh": { + "ms": 2.995, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 20, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "keystroke": { + "ms": 0.38, + "calls": { + "ready": 1, + "speech_key": 1, + "text_ready": 1, + "asset": 1, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "selection": { + "ms": 5.024, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "dialog_edit": { + "ms": 6.539, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 1, + "configuration_changed": 1 + } + }, + "dialog_save": { + "ms": 8.125, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 41, + "asset": 43, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "after": { + "ms": 7.665, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "pause": { + "ms": 7.698, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "budget": { + "ms": 5.718, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "include": { + "ms": 8.881, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "duration": { + "ms": 5.737, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 22, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "record": { + "ms": 7.675, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "mode": { + "ms": 7.456, + "calls": { + "ready": 40, + "speech_key": 40, + "text_ready": 40, + "asset": 42, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "editor_next": { + "ms": 4.821, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "editor_previous": { + "ms": 5.145, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 21, + "asset": 23, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "aggregates": { + "ms": 3.581, + "calls": { + "ready": 20, + "speech_key": 20, + "text_ready": 20, + "asset": 19, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + } + }, + { + "slides": 60, + "refresh": { + "ms": 7.644, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 60, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "keystroke": { + "ms": 0.397, + "calls": { + "ready": 1, + "speech_key": 1, + "text_ready": 1, + "asset": 1, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "selection": { + "ms": 9.53, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "dialog_edit": { + "ms": 12.998, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 1, + "configuration_changed": 1 + } + }, + "dialog_save": { + "ms": 18.242, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 121, + "asset": 123, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "after": { + "ms": 17.672, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "pause": { + "ms": 17.664, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "budget": { + "ms": 11.269, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "include": { + "ms": 18.711, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "duration": { + "ms": 11.288, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 62, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "record": { + "ms": 17.665, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "mode": { + "ms": 17.497, + "calls": { + "ready": 120, + "speech_key": 120, + "text_ready": 120, + "asset": 122, + "refresh": 1, + "dialog_loads": 0, + "configuration_changed": 1 + } + }, + "editor_next": { + "ms": 8.966, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "editor_previous": { + "ms": 9.361, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 61, + "asset": 63, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + }, + "aggregates": { + "ms": 7.795, + "calls": { + "ready": 60, + "speech_key": 60, + "text_ready": 60, + "asset": 59, + "refresh": 0, + "dialog_loads": 0, + "configuration_changed": 0 + } + } + } +] diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/full-tests.py b/docs/reviews/evidence/2026-10-01-settings-refactor/full-tests.py new file mode 100644 index 0000000..de8c1da --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/full-tests.py @@ -0,0 +1,28 @@ +import json, os, shlex, subprocess, sys +from pathlib import Path +root = Path.cwd() +out = root/'artifacts/ui-structure-perf'/sys.argv[1] +out.mkdir(parents=True, exist_ok=True) +def audio_state(): + return {kind: subprocess.check_output(['pactl','--format=json','list',kind],text=True) for kind in ('sinks','sources')} +(out/'audio-before.json').write_text(json.dumps(audio_state())) +name='autotalk_settings_verification_'+str(os.getpid()) +module=subprocess.check_output(['pactl','load-module','module-null-sink','sink_name='+name,'channels=2','channel_map=front-left,front-right'],text=True).strip() +try: + for tag,python in [('wheel','.venv/bin/python'),('native','artifacts/open-tasks/venv-qt610/bin/python')]: + if len(sys.argv)>2 and tag not in sys.argv[2:]: + continue + env=os.environ.copy() + env.update(QT_QPA_PLATFORM='xcb',PULSE_SINK=name,PULSE_SOURCE=name+'.monitor',PIPEWIRE_PROPS=json.dumps({'target.object':name,'node.dont-reconnect':False})) + if tag=='native': + env['PYTHONPATH']=str(root/'build/system-qt')+':'+str(root/'src') + env['LD_LIBRARY_PATH']=str(root/'build/system-qt/PySide6/Qt/lib')+':'+str(root/'artifacts/open-tasks/venv-qt610/lib/python3.12/site-packages/shiboken6') + with (out/(tag+'-full.log')).open('w') as log: + command=['xvfb-run','-a'] + if os.environ.get('AUTOTALK_TEST_WM'): + command += ['dbus-run-session','--',python,str(root/'docs/reviews/evidence/2026-09-30-ui-structure-perf/xvfb-session.py')] + result=subprocess.run(command+[python,'-m','pytest','-q']+shlex.split(os.environ.get('AUTOTALK_PYTEST_ARGS','')),env=env,stdout=log,stderr=subprocess.STDOUT) + print(tag, result.returncode, flush=True) +finally: + subprocess.run(['pactl','unload-module',module],check=True) + (out/'audio-after.json').write_text(json.dumps(audio_state())) diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/native-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/native-full.log new file mode 100644 index 0000000..ad87638 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/native-full.log @@ -0,0 +1,45 @@ +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3467698 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3467698 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3467718]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3467698 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3467698 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3467718]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3467763 comm="artifacts/open-tasks/venv-qt610/bin/python -m pyte" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3467797 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3467797 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3467797): xdg-desktop-portal-WARNING **: 00:09:28.380: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3467797 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3467797 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3467797 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3467698 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3467696]: [session uid=1000 pid=3467696 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 12%] +........................................................................ [ 25%] +........................................................................ [ 38%] +........................................................................ [ 51%] +........................................................................ [ 64%] +........................................................................ [ 77%] +........................................................................ [ 89%] +......................................................... [100%] +561 passed in 145.92s (0:02:25) +A connection to the bus can't be made + +(xfconfd:3467736): xfconfd-WARNING **: 00:11:54.285: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/physical-audio-unchanged.json b/docs/reviews/evidence/2026-10-01-settings-refactor/physical-audio-unchanged.json new file mode 100644 index 0000000..4fe7d09 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/physical-audio-unchanged.json @@ -0,0 +1,355 @@ +{ + "unchanged": true, + "before": { + "sinks": { + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI3__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI3" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI2__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI2" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI1__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI1" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Speaker__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 27525, + "value_percent": "42%", + "db": "-22.61 dB" + }, + "front-right": { + "value": 27525, + "value_percent": "42%", + "db": "-22.61 dB" + } + }, + "active_port": "[Out] Speaker" + } + }, + "sources": { + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI3__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI3" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI2__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI2" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI1__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI1" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Speaker__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] Speaker" + }, + "alsa_input.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Mic2__source": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[In] Mic2" + }, + "alsa_input.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Mic1__source": { + "mute": true, + "volume": { + "front-left": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "front-right": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "rear-left": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "rear-right": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + } + }, + "active_port": "[In] Mic1" + } + } + }, + "after": { + "sinks": { + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI3__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI3" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI2__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI2" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI1__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI1" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Speaker__sink": { + "mute": false, + "volume": { + "front-left": { + "value": 27525, + "value_percent": "42%", + "db": "-22.61 dB" + }, + "front-right": { + "value": 27525, + "value_percent": "42%", + "db": "-22.61 dB" + } + }, + "active_port": "[Out] Speaker" + } + }, + "sources": { + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI3__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI3" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI2__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI2" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__HDMI1__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] HDMI1" + }, + "alsa_output.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Speaker__sink.monitor": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[Out] Speaker" + }, + "alsa_input.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Mic2__source": { + "mute": false, + "volume": { + "front-left": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + }, + "front-right": { + "value": 65536, + "value_percent": "100%", + "db": "0.00 dB" + } + }, + "active_port": "[In] Mic2" + }, + "alsa_input.pci-0000_00_1f.3-platform-skl_hda_dsp_generic.HiFi__Mic1__source": { + "mute": true, + "volume": { + "front-left": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "front-right": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "rear-left": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + }, + "rear-right": { + "value": 39578, + "value_percent": "60%", + "db": "-13.14 dB" + } + }, + "active_port": "[In] Mic1" + } + } + } +} diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/radon.txt b/docs/reviews/evidence/2026-10-01-settings-refactor/radon.txt new file mode 100644 index 0000000..96a7fd0 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/radon.txt @@ -0,0 +1,95 @@ +src/autotalk/settings.py + M 123:4 ScopedSettingsDialog.build_page - C (14) + M 238:4 ScopedSettingsDialog.sync - C (14) + M 156:4 ScopedSettingsDialog.build_extras - C (12) + F 38:0 inheritance - B (10) + M 180:4 ScopedSettingsDialog.show_section - B (9) + M 263:4 ScopedSettingsDialog.sync_fields - B (9) + M 324:4 ScopedSettingsDialog.done - B (7) + F 23:0 apply_edit - B (6) + M 196:4 ScopedSettingsDialog.load_settings - B (6) + C 48:0 ScopedSettingsDialog - A (5) + M 311:4 ScopedSettingsDialog.clean_media - A (5) + M 113:4 ScopedSettingsDialog.field_layout - A (4) + M 214:4 ScopedSettingsDialog.page_changed - A (4) + M 222:4 ScopedSettingsDialog.edit_many - A (4) + M 279:4 ScopedSettingsDialog.sync_timing - A (4) + M 89:4 ScopedSettingsDialog.pending - A (3) + M 93:4 ScopedSettingsDialog.slide - A (3) + M 96:4 ScopedSettingsDialog.setting_value - A (3) + M 117:4 ScopedSettingsDialog.build_pages - A (3) + M 235:4 ScopedSettingsDialog.pin - A (3) + M 69:4 ScopedSettingsDialog.order_focus - A (2) + M 103:4 ScopedSettingsDialog.add_field - A (2) + M 230:4 ScopedSettingsDialog.choose_destination - A (2) + M 287:4 ScopedSettingsDialog.start_job - A (2) + M 307:4 ScopedSettingsDialog.media_files - A (2) + M 339:4 ScopedSettingsDialog.save_and_fit - A (2) + C 345:0 TalkDefaultsDialog - A (2) + C 350:0 TalkSettingsDialog - A (2) + C 355:0 SlideSettingsDialog - A (2) + M 53:4 ScopedSettingsDialog.__init__ - A (1) + M 219:4 ScopedSettingsDialog.edit_setting - A (1) + M 346:4 TalkDefaultsDialog.__init__ - A (1) + M 351:4 TalkSettingsDialog.__init__ - A (1) + M 356:4 SlideSettingsDialog.__init__ - A (1) +src/autotalk/settings_schema.py + F 51:0 setting - A (1) + C 34:0 Setting - A (1) +src/autotalk/settings_fields.py + C 7:0 SettingField - B (7) + M 39:4 SettingField.make_editor - B (7) + M 72:4 SettingField.load - B (6) + M 8:4 SettingField.__init__ - A (5) +src/autotalk/settings_components.py + M 142:4 VoiceEditor.sync - C (16) + M 228:4 VoiceEditor.refresh_library - C (13) + M 107:4 VoiceEditor.load - C (11) + M 315:4 CodexModelPicker.refresh_models - B (10) + M 332:4 CodexModelPicker.refresh_efforts - B (9) + M 32:4 VoiceEditor.__init__ - B (8) + C 31:0 VoiceEditor - B (7) + M 210:4 VoiceEditor.voice_settings_changed - B (7) + M 187:4 VoiceEditor.preview_voice - B (6) + M 263:4 VoiceEditor.save_personal_voice - B (6) + C 297:0 CodexModelPicker - B (6) + M 399:4 AudioAssets.show_clip - B (6) + M 129:4 VoiceEditor.sampling_changed - A (4) + M 276:4 VoiceEditor.accept_designed_voice - A (4) + C 361:0 AudioAssets - A (4) + M 383:4 AudioAssets.load - A (4) + M 416:4 AudioAssets.clip_changed - A (4) + C 451:0 ConferencePanel - A (4) + M 452:4 ConferencePanel.__init__ - A (4) + M 467:4 ConferencePanel.load - A (4) + M 133:4 VoiceEditor.voice_context - A (3) + M 159:4 VoiceEditor.voice_changed - A (3) + M 174:4 VoiceEditor.set_voice - A (3) + M 252:4 VoiceEditor.use_saved_voice - A (3) + M 351:4 CodexModelPicker.model_changed - A (3) + M 355:4 CodexModelPicker.model_settings_changed - A (3) + M 168:4 VoiceEditor.import_voice - A (2) + M 298:4 CodexModelPicker.__init__ - A (2) + M 362:4 AudioAssets.__init__ - A (2) + M 426:4 AudioAssets.remove_clip - A (2) + M 433:4 AudioAssets.add_clip - A (2) + M 439:4 AudioAssets.add_background - A (2) + M 474:4 ConferencePanel.read_conference - A (2) + M 139:4 VoiceEditor.toggle_recording - A (1) + M 445:4 AudioAssets.install_background - A (1) +src/autotalk/options.py + M 48:4 DeliveryPresets.use_preset - B (9) + C 11:0 DeliveryPresets - B (6) + M 35:4 DeliveryPresets.save_preset - B (6) + M 24:4 DeliveryPresets.refresh_presets - A (3) + M 12:4 DeliveryPresets.__init__ - A (1) +src/autotalk/preferences.py + M 43:4 PreferencesDialog.sync_engine - C (18) + C 12:0 PreferencesDialog - B (6) + F 91:0 build_engine - A (3) + M 36:4 PreferencesDialog.sync - A (3) + F 66:0 build_display - A (2) + M 33:4 PreferencesDialog.show_section - A (2) + F 79:0 build_account - A (1) + M 22:4 PreferencesDialog.__init__ - A (1) + M 30:4 PreferencesDialog.done - A (1) diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native-150.log b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native-150.log new file mode 100644 index 0000000..720d375 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native-150.log @@ -0,0 +1,39 @@ +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3457885 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3457885 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3457900]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3457885 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3457885 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457900]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3457943 comm="artifacts/open-tasks/venv-qt610/bin/python docs/re" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3457977 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3457977 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3457977): xdg-desktop-portal-WARNING **: 23:59:16.544: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3457977 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3457977 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3457977 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +qt.multimedia.ffmpeg: Using Qt multimedia with FFmpeg version 8.1.2-2+b3 GPL version 2 or later +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3457885 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457883]: [session uid=1000 pid=3457883 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: App info not found for ''") +Captured 6 screenshots at scale 1.5 +A connection to the bus can't be made + +(xfconfd:3457917): xfconfd-WARNING **: 23:59:21.931: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native.log b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native.log new file mode 100644 index 0000000..bcab0d9 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-native.log @@ -0,0 +1,39 @@ +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3455691 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3455691 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3455706]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3455691 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3455691 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3455706]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3455749 comm="artifacts/open-tasks/venv-qt610/bin/python docs/re" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3455781 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3455781 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3455781): xdg-desktop-portal-WARNING **: 23:54:46.980: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3455781 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3455781 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3455781 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +qt.multimedia.ffmpeg: Using Qt multimedia with FFmpeg version 8.1.2-2+b3 GPL version 2 or later +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3455691 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3455689]: [session uid=1000 pid=3455689 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: App info not found for ''") +Captured 59 screenshots at scale 1 +A connection to the bus can't be made + +(xfconfd:3455723): xfconfd-WARNING **: 23:55:37.411: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel-150.log b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel-150.log new file mode 100644 index 0000000..53b330d --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel-150.log @@ -0,0 +1,39 @@ +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3460438 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3460438 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3460453]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3460438 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3460438 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3460453]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3460498 comm=".venv/bin/python docs/reviews/evidence/2026-10-01-" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3460529 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3460529 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3460529): xdg-desktop-portal-WARNING **: 00:00:52.866: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3460529 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3460529 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3460529 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +qt.multimedia.ffmpeg: Using Qt multimedia with FFmpeg version 7.1.5 LGPL version 2.1 or later +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3460438 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3460420]: [session uid=1000 pid=3460420 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: App info not found for ''") +Captured 6 screenshots at scale 1.5 +A connection to the bus can't be made + +(xfconfd:3460471): xfconfd-WARNING **: 00:00:57.924: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel.log b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel.log new file mode 100644 index 0000000..d220325 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens-wheel.log @@ -0,0 +1,39 @@ +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3457714 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3457714 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3457745]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3457714 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3457714 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3457745]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3457788 comm=".venv/bin/python docs/reviews/evidence/2026-10-01-" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3457818 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3457818 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3457818): xdg-desktop-portal-WARNING **: 23:59:15.373: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3457818 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3457818 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3457818 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +qt.multimedia.ffmpeg: Using Qt multimedia with FFmpeg version 7.1.5 LGPL version 2.1 or later +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3457714 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3457712]: [session uid=1000 pid=3457712 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: App info not found for ''") +Captured 59 screenshots at scale 1 +A connection to the bus can't be made + +(xfconfd:3457762): xfconfd-WARNING **: 00:00:05.371: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens.py b/docs/reviews/evidence/2026-10-01-settings-refactor/screens.py new file mode 100644 index 0000000..692aee1 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens.py @@ -0,0 +1,76 @@ +import json, os, tempfile +from pathlib import Path +from PySide6.QtCore import Qt, QPoint, QSettings +from PySide6.QtTest import QTest +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication, QToolButton, QLabel +from autotalk.app import MainWindow +from autotalk.services import import_pdf +from autotalk.runtime import Task + +app=QApplication([]) +scale=os.environ.get('QT_SCALE_FACTOR','1') +output=Path(os.environ.get('AUTOTALK_SCREEN_OUTPUT', 'artifacts/ui-structure-perf/screens'));output.mkdir(parents=True,exist_ok=True) +records=[] +base=QPalette(app.palette()) +def capture(widget,name,size): + widget.resize(*size);app.processEvents();QTest.qWait(500) + assert widget.size().toTuple()==size,(name,widget.size().toTuple(),size) + if hasattr(widget, 'buttons'): + assert widget.rect().contains(widget.buttons.mapTo(widget, widget.buttons.rect().bottomRight())), name + assert widget.pages.currentWidget().horizontalScrollBar().maximum()==0, name + name=f'{name}-scale{scale}.png' + widget.grab().save(str(output/name)) + records.append({'file':name,'logical_size':size,'style':app.style().objectName()}) +with tempfile.TemporaryDirectory() as tmp: + QSettings.setDefaultFormat(QSettings.Format.IniFormat) + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(Path(tmp)/'config')) + pdf=next(Path('/home/voc/projects/Hagenberg/DACHS/2026/DACHS').glob('*.pdf')) + p=import_pdf(pdf,Path(tmp)/'talk',Task(lambda _:None)) + p.title='Kollaborative KI-gestützte Softwareentwicklung in der Lehre' + p.select_language('German') + p.set_narration(p.slides[0],'Herzlich willkommen. Heute geht es um kollaborative KI-gestützte Softwareentwicklung in der Lehre und unsere Erfahrungen mit Codex.') + w=MainWindow();w.adopt(p);w.show();app.processEvents() + if scale=='1': + for mode in ('Prepared','Quick','Realtime'): + w.mode.setCurrentText(mode) + for size in ((940,680),(1100,850)): + capture(w,f'main-{mode}-{size[0]}',size) + w.mode.setCurrentText('Realtime') + for scope,dialog in list(w.settings.items())+[("preferences",w.preferences)]: + dialog.show_section() + for index in range(dialog.navigation.count()): + if dialog.navigation.item(index).isHidden():continue + title=dialog.navigation.item(index).text() + for size in ((760,580),(920,760)): + dialog.show_section(title);dialog.resize(*size);app.processEvents();QTest.qWait(500) + + for toggle in dialog.pages.currentWidget().findChildren(QToolButton): + if toggle.isCheckable(): toggle.setChecked(True) + app.processEvents() + bar=dialog.pages.currentWidget().verticalScrollBar() + positions=list(range(0,bar.maximum()+1,max(1,bar.pageStep()-40))) + if positions[-1]!=bar.maximum(): positions.append(bar.maximum()) + for n,y in enumerate(positions): + bar.setValue(y);app.processEvents() + capture(dialog,f'dialog-{scope}-{index}-{size[0]}-part{n}',size) + dialog.reject() + for dark in (False,True): + palette=QPalette(base) + if dark: + for role,value in ((QPalette.Window,'#31363b'),(QPalette.Base,'#232629'),(QPalette.AlternateBase,'#31363b'),(QPalette.Button,'#31363b'),(QPalette.WindowText,'#eff0f1'),(QPalette.Text,'#eff0f1'),(QPalette.ButtonText,'#eff0f1'),(QPalette.Mid,'#62686e')): + palette.setColor(role,QColor(value)) + app.setPalette(palette);app.processEvents() + w.mode.setCurrentText('Prepared') + next(b for b in w.editor.findChildren(QToolButton) if b.text()=='Timing').setChecked(True) + capture(w,f'inspector-{ "dark" if dark else "light"}',(940,680)) + dialog=w.settings[1];dialog.show_section('Audio & recording') + dialog.resize(760,580);app.processEvents() + bar=dialog.pages.currentWidget().verticalScrollBar() + for part,y in [('top',0),('bottom',bar.maximum())]: + bar.setValue(y);app.processEvents() + capture(dialog,f'recording-{ "dark" if dark else "light"}-{part}',(760,580)) + dialog.reject() + w.close() +(output/f'manifest-{scale}.json').write_text(json.dumps(records,indent=2)) +print('Captured',len(records),'screenshots at scale',scale,flush=True) diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-760-part0-scale1.png new file mode 100644 index 0000000..b6f0727 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-920-part0-scale1.png new file mode 100644 index 0000000..ac158d7 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part0-scale1.png new file mode 100644 index 0000000..e5dd313 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part1-scale1.png new file mode 100644 index 0000000..5f1af2a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part0-scale1.png new file mode 100644 index 0000000..f26d80c Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part1-scale1.png new file mode 100644 index 0000000..94baa48 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-1-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part0-scale1.png new file mode 100644 index 0000000..db35846 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part1-scale1.png new file mode 100644 index 0000000..f3b069e Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-920-part0-scale1.png new file mode 100644 index 0000000..ebe7b74 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-760-part0-scale1.png new file mode 100644 index 0000000..b2c66db Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-920-part0-scale1.png new file mode 100644 index 0000000..ffe8048 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-3-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part0-scale1.png new file mode 100644 index 0000000..6779eb6 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part1-scale1.png new file mode 100644 index 0000000..1985945 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-920-part0-scale1.png new file mode 100644 index 0000000..c31e7bc Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-0-4-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part0-scale1.png new file mode 100644 index 0000000..587d5c3 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part1-scale1.png new file mode 100644 index 0000000..2cac286 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-920-part0-scale1.png new file mode 100644 index 0000000..bf20724 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-760-part0-scale1.png new file mode 100644 index 0000000..29d11ca Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-920-part0-scale1.png new file mode 100644 index 0000000..85c1742 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part0-scale1.png new file mode 100644 index 0000000..2305ac3 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part1-scale1.png new file mode 100644 index 0000000..43a1393 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part0-scale1.png new file mode 100644 index 0000000..76c35cc Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part1-scale1.png new file mode 100644 index 0000000..48c9ea7 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-2-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part0-scale1.png new file mode 100644 index 0000000..a260106 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part1-scale1.png new file mode 100644 index 0000000..c7eae2a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-920-part0-scale1.png new file mode 100644 index 0000000..bc3971c Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-3-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part0-scale1.png new file mode 100644 index 0000000..e1731cd Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part1-scale1.png new file mode 100644 index 0000000..67fdf36 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-920-part0-scale1.png new file mode 100644 index 0000000..65a051a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-4-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part0-scale1.png new file mode 100644 index 0000000..56394c2 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part1-scale1.png new file mode 100644 index 0000000..17502fc Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-920-part0-scale1.png new file mode 100644 index 0000000..0b8c56a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-1-5-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-760-part0-scale1.png new file mode 100644 index 0000000..1cff38f Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-920-part0-scale1.png new file mode 100644 index 0000000..e900f30 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part0-scale1.png new file mode 100644 index 0000000..c4e5eab Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part1-scale1.png new file mode 100644 index 0000000..a5f99f4 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part0-scale1.png new file mode 100644 index 0000000..60803d8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part1-scale1.png new file mode 100644 index 0000000..294a2c8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-1-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-760-part0-scale1.png new file mode 100644 index 0000000..7498539 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-920-part0-scale1.png new file mode 100644 index 0000000..46d1fb5 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-2-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-760-part0-scale1.png new file mode 100644 index 0000000..c937aa6 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-920-part0-scale1.png new file mode 100644 index 0000000..6c623a8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-760-part0-scale1.png new file mode 100644 index 0000000..7e43238 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-920-part0-scale1.png new file mode 100644 index 0000000..56aa461 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part0-scale1.png new file mode 100644 index 0000000..8216454 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part1-scale1.png new file mode 100644 index 0000000..2ef6c79 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-920-part0-scale1.png new file mode 100644 index 0000000..9466ab5 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/dialog-preferences-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.5.png new file mode 100644 index 0000000..ee437a5 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.png new file mode 100644 index 0000000..29bcdf4 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-dark-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.5.png new file mode 100644 index 0000000..e7a2f53 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.png new file mode 100644 index 0000000..6597e85 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/inspector-light-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-1100-scale1.png new file mode 100644 index 0000000..cc2adb5 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-940-scale1.png new file mode 100644 index 0000000..ebc435e Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Prepared-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-1100-scale1.png new file mode 100644 index 0000000..2145195 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-940-scale1.png new file mode 100644 index 0000000..f2643d0 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Quick-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-1100-scale1.png new file mode 100644 index 0000000..af28fa4 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-940-scale1.png new file mode 100644 index 0000000..a62d9a9 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/main-Realtime-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.5.json b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.5.json new file mode 100644 index 0000000..ef8968e --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.5.json @@ -0,0 +1,50 @@ +[ + { + "file": "inspector-light-scale1.5.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "recording-light-top-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "recording-light-bottom-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "inspector-dark-scale1.5.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "recording-dark-top-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "recording-dark-bottom-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + } +] \ No newline at end of file diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.json b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.json new file mode 100644 index 0000000..80637b0 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/manifest-1.json @@ -0,0 +1,474 @@ +[ + { + "file": "main-Prepared-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "main-Prepared-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "breeze" + }, + { + "file": "main-Quick-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "main-Quick-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "breeze" + }, + { + "file": "main-Realtime-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "main-Realtime-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "breeze" + }, + { + "file": "dialog-0-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-0-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-1-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-0-1-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-0-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-0-3-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-3-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-0-4-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-4-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-0-4-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-0-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-2-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-3-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-3-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-3-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-4-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-4-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-4-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-1-5-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-5-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-1-5-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-2-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-2-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-2-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-2-1-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-2-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-2-1-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-2-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-2-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "dialog-preferences-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "breeze" + }, + { + "file": "inspector-light-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "recording-light-top-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "recording-light-bottom-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "inspector-dark-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "breeze" + }, + { + "file": "recording-dark-top-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + }, + { + "file": "recording-dark-bottom-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "breeze" + } +] \ No newline at end of file diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.5.png new file mode 100644 index 0000000..523f982 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.png new file mode 100644 index 0000000..fa313fb Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-bottom-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.5.png new file mode 100644 index 0000000..758e9fc Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.png new file mode 100644 index 0000000..a64ce64 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-dark-top-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.5.png new file mode 100644 index 0000000..2b05275 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.png new file mode 100644 index 0000000..67fdf36 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-bottom-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.5.png new file mode 100644 index 0000000..f0e01f8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.png new file mode 100644 index 0000000..e1731cd Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/native/recording-light-top-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-760-part0-scale1.png new file mode 100644 index 0000000..d7e9888 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-920-part0-scale1.png new file mode 100644 index 0000000..3354717 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part0-scale1.png new file mode 100644 index 0000000..63ebab0 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part1-scale1.png new file mode 100644 index 0000000..0e1379b Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part0-scale1.png new file mode 100644 index 0000000..63aecb7 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part1-scale1.png new file mode 100644 index 0000000..60d9647 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-1-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part0-scale1.png new file mode 100644 index 0000000..e19c257 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part1-scale1.png new file mode 100644 index 0000000..cf1b344 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-920-part0-scale1.png new file mode 100644 index 0000000..4e73e48 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-760-part0-scale1.png new file mode 100644 index 0000000..98793f9 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-920-part0-scale1.png new file mode 100644 index 0000000..94ec7bb Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-3-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part0-scale1.png new file mode 100644 index 0000000..6619d3d Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part1-scale1.png new file mode 100644 index 0000000..3290112 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-920-part0-scale1.png new file mode 100644 index 0000000..37fcd25 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-0-4-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part0-scale1.png new file mode 100644 index 0000000..7ad0286 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part1-scale1.png new file mode 100644 index 0000000..4bc5328 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-920-part0-scale1.png new file mode 100644 index 0000000..1b5eddc Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-760-part0-scale1.png new file mode 100644 index 0000000..477ebd3 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-920-part0-scale1.png new file mode 100644 index 0000000..1d36b55 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part0-scale1.png new file mode 100644 index 0000000..b1ce422 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part1-scale1.png new file mode 100644 index 0000000..24bb6f8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part0-scale1.png new file mode 100644 index 0000000..93531a7 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part1-scale1.png new file mode 100644 index 0000000..3c90171 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-2-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part0-scale1.png new file mode 100644 index 0000000..27afc24 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part1-scale1.png new file mode 100644 index 0000000..96a094f Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-920-part0-scale1.png new file mode 100644 index 0000000..56faf47 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-3-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part0-scale1.png new file mode 100644 index 0000000..cca3be3 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part1-scale1.png new file mode 100644 index 0000000..478d2b0 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-920-part0-scale1.png new file mode 100644 index 0000000..d9f9cd5 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-4-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part0-scale1.png new file mode 100644 index 0000000..aa89132 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part1-scale1.png new file mode 100644 index 0000000..ec235e4 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-920-part0-scale1.png new file mode 100644 index 0000000..70bb5c6 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-1-5-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-760-part0-scale1.png new file mode 100644 index 0000000..9a59895 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-920-part0-scale1.png new file mode 100644 index 0000000..353d30c Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part0-scale1.png new file mode 100644 index 0000000..f83ec36 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part1-scale1.png new file mode 100644 index 0000000..b96ab2f Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part0-scale1.png new file mode 100644 index 0000000..d57423c Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part1-scale1.png new file mode 100644 index 0000000..5cbfa4b Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-1-920-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-760-part0-scale1.png new file mode 100644 index 0000000..e44c961 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-920-part0-scale1.png new file mode 100644 index 0000000..ac95b53 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-2-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-760-part0-scale1.png new file mode 100644 index 0000000..eb92c6b Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-920-part0-scale1.png new file mode 100644 index 0000000..1640261 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-0-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-760-part0-scale1.png new file mode 100644 index 0000000..5851c03 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-920-part0-scale1.png new file mode 100644 index 0000000..fd04b9b Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-1-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part0-scale1.png new file mode 100644 index 0000000..9e7943a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part1-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part1-scale1.png new file mode 100644 index 0000000..9b1df9d Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-760-part1-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-920-part0-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-920-part0-scale1.png new file mode 100644 index 0000000..624b80f Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/dialog-preferences-2-920-part0-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.5.png new file mode 100644 index 0000000..25f7918 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.png new file mode 100644 index 0000000..29b20e8 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-dark-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.5.png new file mode 100644 index 0000000..81c2292 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.png new file mode 100644 index 0000000..ab201ba Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/inspector-light-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-1100-scale1.png new file mode 100644 index 0000000..245866b Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-940-scale1.png new file mode 100644 index 0000000..8abadf6 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Prepared-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-1100-scale1.png new file mode 100644 index 0000000..185695e Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-940-scale1.png new file mode 100644 index 0000000..30f3194 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Quick-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-1100-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-1100-scale1.png new file mode 100644 index 0000000..0890a2a Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-1100-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-940-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-940-scale1.png new file mode 100644 index 0000000..69c1885 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/main-Realtime-940-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.5.json b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.5.json new file mode 100644 index 0000000..f974d3f --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.5.json @@ -0,0 +1,50 @@ +[ + { + "file": "inspector-light-scale1.5.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "recording-light-top-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "recording-light-bottom-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "inspector-dark-scale1.5.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "recording-dark-top-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "recording-dark-bottom-scale1.5.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + } +] \ No newline at end of file diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.json b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.json new file mode 100644 index 0000000..219fbad --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/manifest-1.json @@ -0,0 +1,474 @@ +[ + { + "file": "main-Prepared-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "main-Prepared-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "fusion" + }, + { + "file": "main-Quick-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "main-Quick-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "fusion" + }, + { + "file": "main-Realtime-940-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "main-Realtime-1100-scale1.png", + "logical_size": [ + 1100, + 850 + ], + "style": "fusion" + }, + { + "file": "dialog-0-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-0-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-1-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-0-1-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-0-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-0-3-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-3-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-0-4-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-4-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-0-4-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-0-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-2-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-3-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-3-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-3-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-4-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-4-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-4-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-1-5-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-5-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-1-5-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-2-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-2-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-2-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-2-1-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-2-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-2-1-920-part1-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-2-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-2-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-0-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-0-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-1-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-1-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-2-760-part0-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-2-760-part1-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "dialog-preferences-2-920-part0-scale1.png", + "logical_size": [ + 920, + 760 + ], + "style": "fusion" + }, + { + "file": "inspector-light-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "recording-light-top-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "recording-light-bottom-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "inspector-dark-scale1.png", + "logical_size": [ + 940, + 680 + ], + "style": "fusion" + }, + { + "file": "recording-dark-top-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + }, + { + "file": "recording-dark-bottom-scale1.png", + "logical_size": [ + 760, + 580 + ], + "style": "fusion" + } +] \ No newline at end of file diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.5.png new file mode 100644 index 0000000..9165fea Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.png new file mode 100644 index 0000000..6a93317 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-bottom-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.5.png new file mode 100644 index 0000000..f123b11 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.png new file mode 100644 index 0000000..1c5f237 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-dark-top-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.5.png new file mode 100644 index 0000000..ab7ae12 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.png new file mode 100644 index 0000000..478d2b0 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-bottom-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.5.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.5.png new file mode 100644 index 0000000..6d6b15c Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.5.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.png b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.png new file mode 100644 index 0000000..cca3be3 Binary files /dev/null and b/docs/reviews/evidence/2026-10-01-settings-refactor/screens/wheel/recording-light-top-scale1.png differ diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/settings-tab-order.json b/docs/reviews/evidence/2026-10-01-settings-refactor/settings-tab-order.json new file mode 100644 index 0000000..a470e75 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/settings-tab-order.json @@ -0,0 +1,239 @@ +{ + "0": [ + "navigation", + "field:voice", + "reset:voice", + "voice.speaker", + "voice.record_button", + "voice.transcript", + "voice.voice_description", + "voice.accept_voice_button", + "voice.library_language", + "voice.library_use", + "voice.voice_library", + "field:language", + "reset:language", + "field:language_policy", + "reset:language_policy", + "field:writing_style", + "reset:writing_style", + "field:delivery.style", + "reset:delivery.style", + "field:delivery.instructions", + "reset:delivery.instructions", + "field:delivery.attributes.pitch", + "reset:delivery.attributes.pitch", + "field:delivery.attributes.texture", + "reset:delivery.attributes.texture", + "field:delivery.attributes.energy", + "reset:delivery.attributes.energy", + "field:delivery.attributes.pace", + "reset:delivery.attributes.pace", + "field:delivery.attributes.age", + "reset:delivery.attributes.age", + "field:delivery.attributes.articulation", + "reset:delivery.attributes.articulation", + "field:delivery.attributes.projection", + "reset:delivery.attributes.projection", + "field:delivery.attributes.accent", + "reset:delivery.attributes.accent", + "field:delivery.attributes.expression", + "reset:delivery.attributes.expression", + "field:delivery.attributes.persona", + "reset:delivery.attributes.persona", + "presets.presets", + "presets.use_preset_button", + "field:mode", + "reset:mode", + "field:tolerance_seconds", + "reset:tolerance_seconds", + "field:quick_timing", + "reset:quick_timing", + "field:realtime_script", + "reset:realtime_script", + "field:speech_priority", + "reset:speech_priority", + "field:buffer_seconds", + "reset:buffer_seconds", + "field:pause_seconds", + "reset:pause_seconds", + "field:after", + "reset:after", + "field:record_presentation", + "reset:record_presentation", + "field:recording_source", + "reset:recording_source", + "field:capture_microphone", + "reset:capture_microphone", + "field:recording_policy", + "reset:recording_policy", + "field:export_rate", + "reset:export_rate", + "field:export_bitrate", + "reset:export_bitrate", + "field:background_gain", + "reset:background_gain", + "field:background_loop", + "reset:background_loop", + "field:codex_model", + "reset:codex_model", + "field:codex_effort", + "reset:codex_effort", + "field:delivery.sampling", + "reset:delivery.sampling", + "voice.voice_preview_button", + "voice.save_voice_button", + "cancel_job" + ], + "1": [ + "navigation", + "field:title", + "reset:title", + "field:audience", + "reset:audience", + "field:objective", + "reset:objective", + "field:conference_url", + "reset:conference_url", + "conference.read_button", + "field:scope", + "reset:scope", + "field:voice", + "reset:voice", + "voice.speaker", + "voice.record_button", + "voice.transcript", + "voice.voice_description", + "voice.accept_voice_button", + "voice.library_language", + "voice.library_use", + "voice.voice_library", + "reset:language", + "field:language_policy", + "reset:language_policy", + "field:writing_style", + "reset:writing_style", + "field:delivery.style", + "reset:delivery.style", + "field:delivery.instructions", + "reset:delivery.instructions", + "field:delivery.attributes.pitch", + "reset:delivery.attributes.pitch", + "field:delivery.attributes.texture", + "reset:delivery.attributes.texture", + "field:delivery.attributes.energy", + "reset:delivery.attributes.energy", + "field:delivery.attributes.pace", + "reset:delivery.attributes.pace", + "field:delivery.attributes.age", + "reset:delivery.attributes.age", + "field:delivery.attributes.articulation", + "reset:delivery.attributes.articulation", + "field:delivery.attributes.projection", + "reset:delivery.attributes.projection", + "field:delivery.attributes.accent", + "reset:delivery.attributes.accent", + "field:delivery.attributes.expression", + "reset:delivery.attributes.expression", + "field:delivery.attributes.persona", + "reset:delivery.attributes.persona", + "field:delivery.progression", + "reset:delivery.progression", + "presets.presets", + "presets.use_preset_button", + "reset:mode", + "field:tolerance_seconds", + "reset:tolerance_seconds", + "field:quick_timing", + "reset:quick_timing", + "field:realtime_script", + "reset:realtime_script", + "field:speech_priority", + "reset:speech_priority", + "field:buffer_seconds", + "reset:buffer_seconds", + "field:pause_seconds", + "reset:pause_seconds", + "field:after", + "reset:after", + "fit_button", + "reset:record_presentation", + "field:recording_source", + "reset:recording_source", + "field:capture_microphone", + "reset:capture_microphone", + "field:recording_policy", + "reset:recording_policy", + "field:recording_destination", + "reset:recording_destination", + "field:export_rate", + "reset:export_rate", + "field:export_bitrate", + "reset:export_bitrate", + "assets.background_button", + "assets.remove_background_button", + "field:background_gain", + "reset:background_gain", + "field:background_loop", + "reset:background_loop", + "field:codex_model", + "reset:codex_model", + "field:codex_effort", + "reset:codex_effort", + "field:delivery.sampling", + "reset:delivery.sampling", + "voice.voice_preview_button", + "voice.save_voice_button", + "cancel_job" + ], + "2": [ + "navigation", + "field:voice", + "reset:voice", + "voice.speaker", + "voice.record_button", + "voice.transcript", + "voice.voice_description", + "voice.accept_voice_button", + "voice.library_language", + "voice.library_use", + "voice.voice_library", + "field:language", + "reset:language", + "field:writing_style", + "reset:writing_style", + "field:delivery.style", + "reset:delivery.style", + "field:delivery.instructions", + "reset:delivery.instructions", + "field:delivery.attributes.pitch", + "reset:delivery.attributes.pitch", + "field:delivery.attributes.texture", + "reset:delivery.attributes.texture", + "field:delivery.attributes.energy", + "reset:delivery.attributes.energy", + "field:delivery.attributes.pace", + "reset:delivery.attributes.pace", + "field:delivery.attributes.age", + "reset:delivery.attributes.age", + "field:delivery.attributes.articulation", + "reset:delivery.attributes.articulation", + "field:delivery.attributes.projection", + "reset:delivery.attributes.projection", + "field:delivery.attributes.accent", + "reset:delivery.attributes.accent", + "field:delivery.attributes.expression", + "reset:delivery.attributes.expression", + "field:delivery.attributes.persona", + "reset:delivery.attributes.persona", + "presets.presets", + "presets.use_preset_button", + "assets.clip_select", + "assets.clip_gain", + "assets.clip_placement", + "assets.remove_clip_button", + "voice.voice_preview_button", + "voice.save_voice_button", + "cancel_job" + ] +} diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-native.log b/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-native.log new file mode 100644 index 0000000..ff4f219 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-native.log @@ -0,0 +1,38 @@ +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3464542 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3464542 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3464562]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3464542 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3464542 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464562]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +.dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3464612 comm="artifacts/open-tasks/venv-qt610/bin/python -m pyte" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3464643 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3464643 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3464643): xdg-desktop-portal-WARNING **: 00:06:51.387: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3464643 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3464643 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3464643 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3464542 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464540]: [session uid=1000 pid=3464540 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +......................................... [100%] +42 passed in 13.53s +A connection to the bus can't be made + +(xfconfd:3464579): xfconfd-WARNING **: 00:07:05.229: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-wheel.log b/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-wheel.log new file mode 100644 index 0000000..ef160ec --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/strengthened-wheel.log @@ -0,0 +1,38 @@ +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3464215 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3464215 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3464231]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3464215 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3464215 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464231]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +.dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3464278 comm=".venv/bin/python -m pytest -q tests/test_settings_" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3464314 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3464314 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3464314): xdg-desktop-portal-WARNING **: 00:06:40.525: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3464314 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3464314 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3464314 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3464215 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464213]: [session uid=1000 pid=3464213 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +......................................... [100%] +42 passed in 10.05s +A connection to the bus can't be made + +(xfconfd:3464249): xfconfd-WARNING **: 00:06:50.643: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/docs/reviews/evidence/2026-10-01-settings-refactor/wheel-full.log b/docs/reviews/evidence/2026-10-01-settings-refactor/wheel-full.log new file mode 100644 index 0000000..b551e94 --- /dev/null +++ b/docs/reviews/evidence/2026-10-01-settings-refactor/wheel-full.log @@ -0,0 +1,47 @@ +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.a11y.Bus' requested by ':1.0' (uid=1000 pid=3465000 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.a11y.Bus' +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.gtk.vfs.Daemon' requested by ':1.2' (uid=1000 pid=3465000 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.gtk.vfs.Daemon' +dbus-daemon[3465015]: Activating service name='org.a11y.atspi.Registry' requested by ':1.1' (uid=1000 pid=3465000 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.xfce.Xfconf' requested by ':1.2' (uid=1000 pid=3465000 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.xfce.Xfconf' +dbus-daemon[3465015]: Successfully activated service 'org.a11y.atspi.Registry' +SpiRegistry daemon is running with well-known name - org.a11y.atspi.Registry +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.portal.Desktop' requested by ':1.7' (uid=1000 pid=3465061 comm=".venv/bin/python -m pytest -q" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.impl.portal.PermissionStore' requested by ':1.8' (uid=1000 pid=3465097 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.PermissionStore' +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.portal.Documents' requested by ':1.8' (uid=1000 pid=3465097 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.portal.Documents' + +(/usr/libexec/xdg-desktop-portal:3465097): xdg-desktop-portal-WARNING **: 00:07:32.319: Choosing gtk.portal for org.freedesktop.impl.portal.Lockdown as a last-resort fallback +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.gtk' requested by ':1.8' (uid=1000 pid=3465097 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.gtk' +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kde' requested by ':1.8' (uid=1000 pid=3465097 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kde' +xdp-kde-clipboard: Couldn't resolve QWaylandApplication - clipboard will not work +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.freedesktop.impl.portal.desktop.kwallet' requested by ':1.8' (uid=1000 pid=3465097 comm="/usr/libexec/xdg-desktop-portal" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.portal.Desktop' +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +qt.qpa.services: Failed to register with host portal QDBusError("org.freedesktop.portal.Error.Failed", "Could not register app ID: Connection already associated with an application ID") +kf.wallet.ksecretd: Lacking a socket, pipe: 0 env: 0 +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.freedesktop.impl.portal.desktop.kwallet' +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Activating service name='org.gtk.vfs.Metadata' requested by ':1.2' (uid=1000 pid=3465000 comm="xfwm4 --compositor=off" label="unconfined") +dbus-daemon[3464998]: [session uid=1000 pid=3464998 pidfd=5] Successfully activated service 'org.gtk.vfs.Metadata' +........................................................................ [ 12%] +........................................................................ [ 25%] +........................................................................ [ 38%] +........................................................................ [ 51%] +........................................................................ [ 64%] +........................................................................ [ 77%] +........ +(/usr/libexec/xdg-desktop-portal:3465097): xdg-desktop-portal-WARNING **: 00:08:57.027: Realtime error: Could not map tid 3466984 of pid 3465061: Process ids could not be found: 3466984 +................................................................ [ 89%] +......................................................... [100%] +561 passed in 115.25s (0:01:55) +A connection to the bus can't be made + +(xfconfd:3465033): xfconfd-WARNING **: 00:09:27.516: Name org.xfce.Xfconf lost on the message dbus, exiting. +The X11 connection broke (error 1). Did the X11 server die? +The X11 connection broke (error 1). Did the X11 server die? +KCrash: Application 'xdg-desktop-portal-kde' crashing... crashRecursionCounter = 2 diff --git a/src/autotalk/app.py b/src/autotalk/app.py index a60ec82..b4bfa17 100644 --- a/src/autotalk/app.py +++ b/src/autotalk/app.py @@ -104,10 +104,11 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.project = None + self.project_generation = 0 self.source_notice = None self.source_signature = None - self.preferences = QSettings() - self.defaults = read_settings(json.loads(self.preferences.value("setting_defaults", "{}"))) + self.config = QSettings() + self.defaults = read_settings(json.loads(self.config.value("setting_defaults", "{}"))) self.speech = SpeechSession(Task(), {}) self.job = None self.job_live = False @@ -145,7 +146,6 @@ def __init__(self): self.aggregate_timer.timeout.connect(self.refresh_aggregates) self._build() self.narration.installEventFilter(self) - self.load_settings() self.speech_preferences() self.refresh_recordings() self.elapsed_timer.start() @@ -165,46 +165,36 @@ def apply(): self.apply_defaults(self.project) elif self.project: self.project.set_setting(name, value, self.project.slides[slide] if scope == 2 else None, inherit) - self.configuration_changed(apply, edited=slide + 1 if scope == 2 else None, preserve_candidate=name != "voice") + self.configuration_changed(apply, edited=slide + 1 if scope == 2 else None) def begin_settings(self, dialog): - dialog.defaults_before = copy.deepcopy(self.defaults) - dialog.screen_before = self.settings[0].screen.currentIndex() - dialog.before = copy.deepcopy(self.project) if dialog.scope and not self.job else None + self.flush_refresh() + dialog.generation = self.project_generation + dialog.project = copy.deepcopy(self.project) if dialog.scope else Project(data_dir(), defaults=copy.deepcopy(self.defaults)) def finish_settings(self, dialog, result): - if self.recorder.source or self.job and dialog.before is not None and not getattr(self.job, "preserve_playback", False): - dialog.status.setText("Stop the current operation or voice recording before closing.") + if result != QDialog.DialogCode.Accepted: + if self.recorder.source and self.recorder.editor is dialog.voice: + self.record_voice(dialog.voice) + if self.transport.preview_path: + self.transport.stop() + return True + if dialog.generation != self.project_generation: + dialog.status.setText("The talk changed while this dialog was open. Cancel and reopen settings before saving.") return False - if self.transport.preview_path: - self.transport.stop() - accepted = result == QDialog.DialogCode.Accepted - if dialog.scope == 0: - if accepted: - self.preferences.setValue("setting_defaults", json.dumps(self.defaults, default=lambda v: asdict(v))) - self.preferences.setValue("presentation_display", self.settings[0].screen.currentText()) - else: - self.defaults = dialog.defaults_before - self.settings[0].screen.setCurrentIndex(dialog.screen_before) - self.speech_preferences(accepted) - if self.project: - self.apply_defaults(self.project) - elif dialog.before is not None: - if not accepted: - if dialog.scope == 2: - self.project.slides[dialog.slide_index] = dialog.before.slides[dialog.slide_index] - else: - self.project = dialog.before - self.adopt(self.project) - if not self.save(): - return False - return True - - def settings_closed(self): - self.load_settings() - if self.project: - self.show_slide(self.transport.index) - self.refresh() + from .settings import apply_edit + def apply(): + target = Project(data_dir(), defaults=copy.deepcopy(self.defaults)) if dialog.scope == 0 else self.project + for operation in dialog.operations: + apply_edit(target, dialog.scope, dialog.slide_index, operation) + if dialog.scope == 0: + self.defaults = target.defaults + self.config.setValue("setting_defaults", json.dumps(self.defaults, default=lambda v: asdict(v))) + if self.project: + self.apply_defaults(self.project) + self.configuration_changed(apply, edited=dialog.slide_index+1 if dialog.scope == 2 else None) + dialog.generation = self.project_generation + return True if dialog.scope == 0 else self.save() def apply_defaults(self, project): project.set_defaults(self.defaults) @@ -218,12 +208,6 @@ def apply_defaults(self, project): if not target.exists(): shutil.copy2(source, target) - def load_settings(self, *_, preserve_candidate=False): - for dialog in self.settings.values(): - dialog.defaults = self.defaults - if dialog.isVisible(): - dialog.load_settings(preserve_candidate=preserve_candidate) - def event(self, event): if event.type() == QEvent.Type.WindowActivate and hasattr(self, "source_notice"): QTimer.singleShot(0, self.check_source_pdf) @@ -257,14 +241,14 @@ def log_message(self, message): self.log.appendPlainText(message) self.status.setText(message.splitlines()[-1][:220] if message else "") - def start_job(self, title, function, callback=None, live=False, preserve_playback=False): + def start_job(self, title, function, callback=None, live=False, preserve_playback=False, *, save_before=True): self.flush_refresh() if self.job: return if self.recorder.source: self.error("Stop the reference recording before starting another operation.") return - if not self.save(): + if save_before and not self.save(): return if not preserve_playback and (self.transport.active or self.transport.preview_path): self.transport.stop() @@ -296,6 +280,7 @@ def start_job(self, title, function, callback=None, live=False, preserve_playbac self.job.finished.connect(self.job_finished) self.refresh() self.job.start() + return self.job def job_finished(self): if self.project: @@ -356,17 +341,13 @@ def save(self): return True def adopt(self, project): + self.project_generation += 1 if not self.project or self.project.root != project.root: self.source_signature = None selected = self.transport.index if self.project and self.project.root == project.root else 0 self.project = project self.apply_defaults(project) self.loading = True - self.settings[1].talk_title.setText(project.title) - self.settings[1].conference_scope.setPlainText(project.scope) - self.settings[1].url.setText(project.conference_url) - self.settings[1].audience.setText(project.audience) - self.settings[1].objective.setText(project.objective) self.slide_list.clear() self.slide_list.addItems([f"{s.page:02d} Slide {s.page}" for s in project.slides]) for i, slide in enumerate(project.slides): @@ -378,11 +359,10 @@ def adopt(self, project): self.refresh() self.status.setText("Start reuses current audio and prepares any missing text or speech.") self.log.appendPlainText(f"Talk loaded: {project.title}") - recent = self.preferences.value("recent_projects", []) + recent = self.config.value("recent_projects", []) recent = recent if isinstance(recent, list) else [recent] - self.preferences.setValue("recent_projects", [str(project.root)] + [v for v in recent if v != str(project.root)][:9]) + self.config.setValue("recent_projects", [str(project.root)] + [v for v in recent if v != str(project.root)][:9]) self.refresh_recordings() - self.load_settings() QTimer.singleShot(0, self.check_source_pdf) def check_source_pdf(self, *, force=False): @@ -458,7 +438,7 @@ def imported(project): self.apply_defaults(project) self.adopt(project) if project.mode != "Quick": - self.after_job = lambda: self.settings[1].show_section("Talk & preparation") + self.after_job = lambda: self.settings[1].show_section("Talk") self.start_job("Importing PDF…", lambda task: import_pdf(Path(pdf), destination, task), imported) def open_project(self, path=None): @@ -472,19 +452,17 @@ def open_project(self, path=None): def details_changed(self, *_): if self.loading or not self.project: return - p, dialog = self.project, self.settings[1] - values = {"title": dialog.talk_title.text(), "scope": dialog.conference_scope.toPlainText(), - "target_minutes": self.minutes.value(), "audience": dialog.audience.text(), - "objective": dialog.objective.text(), "conference_url": dialog.url.text().strip()} - names = {"title": "Title", "scope": "Audience topics", "target_minutes": "Talk length", "audience": "Audience", "objective": "Talk objective", "conference_url": "Conference website"} - changed = [names[key] for key, value in values.items() if getattr(p, key) != value] - self.configuration_changed(lambda: [setattr(p, key, value) for key, value in values.items()]) + p = self.project + value = self.minutes.value() + changed = p.target_minutes != value + self.configuration_changed(lambda: setattr(p, "target_minutes", value)) if changed: - self.log_message(", ".join(changed) + " changed. Existing narration is kept; use Rewrite slide text to replace it.") + self.log_message("Talk length changed. Existing narration is kept; use Rewrite slide text to replace it.") def narration_changed(self): if not self.loading and self.project: self.setWindowModified(True) + self.project_generation += 1 self.project.set_narration(self.project.slides[self.transport.index], self.narration.toPlainText()) self.aggregate_timer.stop() self.transport.stop() @@ -599,7 +577,7 @@ def refresh_frame(self): for action, control in self.control_actions: action.setEnabled(False) for control in (self.play_button, self.continue_button, self.end_button, self.demo_button, - self.previous_button, self.next_button, self.editor_previous, self.editor_next, self.preview_button, self.settings[1].fit_button, self.export_button): + self.previous_button, self.next_button, self.editor_previous, self.editor_next, self.preview_button, self.export_button): control.setEnabled(False) return if self.workspace.currentWidget() in (self.editor, self.quick_page): @@ -655,9 +633,10 @@ def refresh_header(self): def refresh_dialogs(self): p = self.project for dialog in self.dialogs: - dialog.sync(self.editable, self.job, engine=self.settings[0].engine_state.text()) - self.signin_button.setVisible(p is not None and self.settings[0].codex_signin.isEnabled()) + dialog.sync(self.editable, self.job) + self.signin_button.setVisible(p is not None and self.preferences.codex_signin.isEnabled()) self.update_speech_controls() + self.fit_action.setEnabled(bool(self.editable and p and all(p.text_ready(s) for s in p.included_slides))) @ui_pass def refresh_slide_list(self): @@ -811,7 +790,7 @@ def connect(task): if interactive: raise task.event({"type": "codex_settings", "account": f"Codex check unavailable: {error} Restart AutoTalk to retry."}) - self.start_job("Signing out of ChatGPT…" if sign_out else "Connecting to ChatGPT…" if interactive else "Checking existing ChatGPT sign-in…", connect) + self.start_job("Signing out of ChatGPT…" if sign_out else "Connecting to ChatGPT…" if interactive else "Checking existing ChatGPT sign-in…", connect, save_before=False) def create_narration(self, rewrite=False): if self.check_source_pdf(force=True): @@ -858,7 +837,7 @@ def present(self, resume=False, selected=False): self.live_autostart = False self.presentation = Presentation(self.transport, self.previous_action, self.next_action, self.end_action) screens = QApplication.screens() - screen = screens[min(self.settings[0].screen.currentIndex(), len(screens)-1)] + screen = screens[min(self.preferences.screen.currentIndex(), len(screens)-1)] self.presentation.setScreen(screen) self.presentation.setGeometry(screen.geometry()) self.presentation.closed.connect(self.presentation_closed) @@ -866,7 +845,7 @@ def present(self, resume=False, selected=False): self.transport.fullscreen = True self.presentation.showFullScreen() self.transport.play() - if not resume and self.preferences.value("gpu_loading", "needed") == "start": + if not resume and self.config.value("gpu_loading", "needed") == "start": self.load_gpu() self.refresh() @@ -894,7 +873,7 @@ def stop_presentation(self): self.workspace.setCurrentWidget(self.quick_page if self.project and self.project.mode == "Quick" else self.editor) self.refresh() - def configuration_changed(self, mutation=lambda: None, *, edited=None, inclusion=False, preserve_candidate=True): + def configuration_changed(self, mutation=lambda: None, *, edited=None, inclusion=False): if self.loading: return self.flush_refresh() @@ -904,9 +883,9 @@ def configuration_changed(self, mutation=lambda: None, *, edited=None, inclusion with p.pass_cache() if p else nullcontext(): before = {s.page: p.pause_after(s) for s in p.included_slides if p.ready(s)} if p else {} mutation() + self.project_generation += 1 self.transport.stop() self.transport.refresh_audio() - self.load_settings(preserve_candidate=preserve_candidate) if p: self.setWindowModified(True) self.show_slide(self.transport.index, surfaces=False) @@ -981,10 +960,10 @@ def start_when_buffered(self): self.present(resume=True) def accept_result(self, project): + self.project_generation += 1 if self.transport.active or self.presentation: self.project = self.transport.project = project self.transport.refresh_audio() - self.load_settings() self.show_slide(self.transport.index) else: self.adopt(project) @@ -1003,7 +982,6 @@ def job_event(self, value): kind = value.get("type") if kind == "codex_settings": self.codex_settings.update(value) - self.load_settings() elif kind in ("progress", "model_progress"): model = kind == "model_progress" progress = self.progress if model else self.narration_progress if value["stage"] == "Narration" else self.slide_progress @@ -1193,7 +1171,7 @@ def refresh_recordings(self): selected = self.recordings.item(self.recordings.currentRow(), 0) selected = selected.data(Qt.ItemDataRole.UserRole) if selected else None self.recordings.setRowCount(0) - roots = [str(self.project.root)] if self.project else self.preferences.value("recent_projects", []) + roots = [str(self.project.root)] if self.project else self.config.value("recent_projects", []) for root in roots if isinstance(roots, list) else [roots]: for path in sorted(Path(root).glob("recordings/*/session.json"), key=lambda p: p.stat().st_mtime, reverse=True): try: @@ -1246,7 +1224,7 @@ def language_selected(self, *_): value = self.language.currentData() if value == "options": self.refresh() - self.settings[1].show_section("Voice & language", focus=self.settings[1].options.fields["language_policy"]) + self.settings[1].show_section("Voice & language", focus=self.settings[1].fields["language_policy"].editor) else: if value in self.project.versions: self.configuration_changed(lambda: self.project.activate_version(value)) @@ -1256,27 +1234,27 @@ def language_selected(self, *_): def speech_preferences(self, save=False): for name, default in (("gpu_loading", "needed"), ("gpu_retention", "session")): - group = getattr(self.settings[0], name) + group = getattr(self.preferences, name) if save: - self.preferences.setValue(name, group.checkedButton().property("value")) + self.config.setValue(name, group.checkedButton().property("value")) else: - value = self.preferences.value(name, default) + value = self.config.value(name, default) next((b for b in group.buttons() if b.property("value") == value), group.buttons()[0]).setChecked(True) - policy = self.preferences.value("gpu_retention", "session") + policy = self.config.value("gpu_retention", "session") if self.speech.retention != policy: self.speech.set_retention(policy) self.update_speech_controls() def selected_speech_config(self): - return speech_config(self.project, self.project.slides[self.transport.index]) if self.project else speech_config(self.settings[0].voice_context()) + return speech_config(self.project, self.project.slides[self.transport.index]) if self.project else speech_config(Project(data_dir(), defaults=copy.deepcopy(self.defaults))) def update_speech_controls(self): try: config = self.selected_speech_config() except ValueError: config = None - dialog = self.settings[0] - description = dialog.sync_engine(self.speech, config, self.job, self.preferences) + dialog = self.preferences + description = dialog.sync_engine(self.speech, config, self.job) self.engine_status.setText("Speech engine: " + description.removesuffix("…") + "…") self.load_gpu_action.setEnabled(dialog.load_gpu_button.isEnabled()) self.unload_gpu_action.setEnabled(dialog.unload_gpu_button.isEnabled()) @@ -1294,17 +1272,17 @@ def load_gpu(self): def load(task): with speech_session(task, config, preload=True): pass - self.start_job("Loading speech model…", load, preserve_playback=True) + self.start_job("Loading speech model…", load, preserve_playback=True, save_before=False) def check_model_updates(self): - if not self.settings[0].check_model_button.isEnabled(): + if not self.preferences.check_model_button.isEnabled(): return spec = self.selected_speech_config()["model"] def checked(latest): message = ("This AutoTalk model matches the latest upstream revision." if latest == spec["revision"] else "A different upstream revision is available. To use a newer model, install an AutoTalk release that includes its verified revision.") QMessageBox.information(self, "Model update check", f"{spec['repo']}\n\n{message}\n\nAutoTalk revision: {spec['revision']}\nUpstream revision: {latest}\n\nThis check does not download or replace model files.") - self.start_job("Checking model updates…", partial(check_model_update, spec), checked) + self.start_job("Checking model updates…", partial(check_model_update, spec), checked, save_before=False) def release_gpu(self): if not self.speech.guard.locked() and self.speech.process: diff --git a/src/autotalk/inspector.py b/src/autotalk/inspector.py index dd559a8..57ff485 100644 --- a/src/autotalk/inspector.py +++ b/src/autotalk/inspector.py @@ -6,7 +6,10 @@ """ from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import QWidget, QVBoxLayout, QCheckBox -from .ui import label, button, combo, column, number, form, disclosure +from .ui import label, button, column, number, disclosure +from .settings_schema import SCHEMA +from .settings_fields import SettingField +from .settings import inheritance class InspectorPanel(QWidget): @@ -21,29 +24,27 @@ def __init__(self, window): box = QVBoxLayout(self) box.setContentsMargins(10, 10, 10, 10) box.setSpacing(10) - self.layout().addWidget(label("This slide", "title")) + self.layout().addWidget(label("Slide flow", "title")) self.slide_include = QCheckBox("Include in presentation") self.slide_include.toggled.connect(self.include_changed) self.layout().addWidget(self.slide_include) - self.slide_after = combo([("Use talk setting", None), ("Advance automatically", "advance"), ("Pause for live demo", "demo"), ("Wait for presenter", "pause")], self.after_changed) - self.slide_after_source = label("After this slide") - self.layout().addWidget(self.slide_after_source) - self.layout().addWidget(self.slide_after) + self.after_field = SettingField(SCHEMA['after'], 2, self.edit, compact=True) + self.slide_after = self.after_field.editor + self.layout().addWidget(label('After this slide')) + self.layout().addWidget(self.after_field) timing = column(margin=0) self.slide_budget = number(0, 14400, 0, self.budget_changed, " sec") self.slide_budget.setSpecialValueText("Automatic") timing.layout().addWidget(self.slide_budget) - self.inherit_pause = QCheckBox("Use talk pause") - self.inherit_pause.toggled.connect(self.pause_changed) - timing.layout().addWidget(self.inherit_pause) - self.slide_pause = number(0, 10, .6, self.pause_changed, " sec") - self.slide_pause.setSingleStep(.1) - form(timing).addRow("Pause after slide", self.slide_pause) + self.pause_field = SettingField(SCHEMA['pause_seconds'], 2, self.edit, compact=True) + self.slide_pause = self.pause_field.editor + timing.layout().addWidget(label('Pause after slide')) + timing.layout().addWidget(self.pause_field) disclosure(self, "Timing", timing) - self.layout().addWidget(button("Slide voice && delivery…", lambda: self.open_settings.emit("Voice & language"))) + self.layout().addWidget(button("Slide sound…", lambda: self.open_settings.emit("Voice & language"))) self.delivery_summary = label("") self.layout().addWidget(self.delivery_summary) - self.layout().addWidget(button("Additional audio…", lambda: self.open_settings.emit("Presentation & recording"))) + self.layout().addWidget(button("Additional audio…", lambda: self.open_settings.emit("Audio & recording"))) self.layout().addWidget(label("Changes here affect only the selected slide.")) self.layout().addStretch() @@ -53,15 +54,8 @@ def refresh(self, project, slide): try: self.slide_budget.setValue(slide.budget_seconds) self.slide_include.setChecked(slide.included) - source = "app" if project.setting_source("after") == "Application default" else "talk" - wording = self.slide_after.itemText(self.slide_after.findData(project.setting("after"))) - self.slide_after_source.setText(f"After this slide\nFrom {source}: {wording}") - self.slide_after.setItemText(0, f"Use {source} setting") - self.slide_after.setCurrentIndex(self.slide_after.findData(slide.overrides.get("after"))) - self.inherit_pause.setText(f"Use talk pause ({project.setting('pause_seconds'):g} s)") - self.inherit_pause.setChecked("pause_seconds" not in slide.overrides) - self.slide_pause.setValue(project.setting("pause_seconds", slide)) - self.slide_pause.setEnabled(self.isEnabled() and not self.inherit_pause.isChecked()) + for key, field in [('after', self.after_field), ('pause_seconds', self.pause_field)]: + field.load(project.setting(key, slide), *inheritance(project, key, 2, slide)) self.delivery_summary.setText(project.setting("voice", slide).label + "\n" + project.setting_source("voice", slide) + "\n" + ("Reference delivery" if project.setting("voice", slide).source == "Base" else project.setting("delivery.style", slide))) finally: self.loading = False @@ -81,11 +75,6 @@ def budget_changed(self): slide = self.window.project.slides[self.window.transport.index] self.window.configuration_changed(lambda: setattr(slide, "budget_seconds", self.slide_budget.value()), edited=slide.page) - def after_changed(self): + def edit(self, name, value, inherit=False): if not self.loading: - value = self.slide_after.currentData() - self.window.edit_setting("after", value, value is None, scope=2, slide=self.window.transport.index) - - def pause_changed(self): - if not self.loading: - self.window.edit_setting("pause_seconds", self.slide_pause.value(), self.inherit_pause.isChecked(), scope=2, slide=self.window.transport.index) + self.window.edit_setting(name, value, inherit, scope=2, slide=self.window.transport.index) diff --git a/src/autotalk/options.py b/src/autotalk/options.py index 0a2a991..02cba43 100644 --- a/src/autotalk/options.py +++ b/src/autotalk/options.py @@ -1,257 +1,25 @@ # SPDX-License-Identifier: MIT -"""Editors for the project's delivery and workflow configuration.""" +"""Reusable delivery presets write only to the shared library.""" import json import uuid from dataclasses import asdict -from PySide6.QtCore import Signal -from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, - QFormLayout, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSpinBox, - QVBoxLayout, QWidget) - -from .project import Delivery, STYLES, SETTING_DEFAULTS, read_settings +from PySide6.QtWidgets import QWidget,QVBoxLayout,QComboBox,QInputDialog,QMessageBox +from .project import Delivery,SETTING_DEFAULTS,read_settings from .runtime import data_dir +from .ui import button,row,label -ATTRIBUTES = { - "pitch": ("Low", "Medium", "High"), - "texture": ("Clear", "Warm", "Breathy", "Raspy"), - "energy": ("Low", "Moderate", "High"), - "pace": ("Slow", "Moderate", "Brisk"), - "age": ("Young adult", "Middle-aged adult", "Older adult"), - "articulation": ("Natural", "Precise", "Relaxed"), - "projection": ("Soft", "Conversational", "Confident"), - "accent": ("Beijing Mandarin", "Sichuan Mandarin"), - "expression": ("Restrained laughter", "Audible sigh", "Thoughtful hesitation", "Enthusiastic interjection"), -} -SAMPLING = {"temperature": (0.0, 2.0, 0.9), "top_k": (1, 200, 50), - "top_p": (0.01, 1.0, 1.0), "repetition_penalty": (1.0, 2.0, 1.05), - "max_new_tokens": (128, 2048, 2048)} - - -class SettingsPanel(QWidget): - changed = Signal() - - def __init__(self, dialog): - from .ui import disclosure +class DeliveryPresets(QWidget): + def __init__(self,dialog): super().__init__() - self.dialog = dialog - self.inheritance = {} - self.project = None - self.loading = False - self.fields = {} - self.rows = {} - self.vocal = [] - box = QVBoxLayout(self) - self.form = QFormLayout() - box.addLayout(self.form) - self.combo("language_policy", "Language arrangement", [ - ("Separate versions and mixed passages", "mixed"), - ("Separate versions; one language per slide", "slide"), - ("Separate single-language versions", "version")]) - self.combo("quick_timing", "Quick timing policy", [ - ("Fit, then start (up to three revisions)", "fit"), - ("Generate once, then start", "once"), - ("Require timing match before start", "require")]) - self.combo("realtime_script", "Realtime narration", [ - ("Write the complete script first", "whole"), - ("Plan the deck; write ahead by slide", "ahead")]) - self.combo("speech_priority", "Realtime speech priority", [("Consistency first", "consistency"), ("Earliest playback", "earliest")]) - buffer = QDoubleSpinBox() - buffer.setRange(2, 30) - buffer.setSuffix(" seconds") - self.bind("buffer_seconds", "Realtime startup/refill buffer", buffer, buffer.valueChanged) - main_form = self.form - self.delivery_widget = QWidget() - delivery_box = QVBoxLayout(self.delivery_widget) - self.form = QFormLayout() - delivery_box.addLayout(self.form) - self.combo("writing_style", "Writing style", [(s, s) for s in STYLES]) - self.combo("delivery.style", "Spoken delivery", [(s, s) for s in STYLES]) - self.presets = QComboBox() - self.form.addRow("Delivery presets — shared by all talks", self.presets) - row = QHBoxLayout() - self.use_preset_button = QPushButton("Use preset") - for button, callback in ((QPushButton("Save delivery preset…"), self.save_preset), (self.use_preset_button, self.use_preset)): - button.clicked.connect(callback) - row.addWidget(button) - self.form.addRow(row) + self.dialog=dialog + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0,0,0,0) + self.layout().addWidget(label('Delivery presets are saved to the shared library immediately. They contain delivery directions, not voice identity or synthesis sampling.')) + self.presets=QComboBox() + self.layout().addWidget(self.presets) + self.use_preset_button=button('Use preset',self.use_preset) + self.layout().addLayout(row(button('Save delivery preset…',self.save_preset),self.use_preset_button)) self.refresh_presets() - advanced = QWidget() - self.form = QFormLayout(advanced) - disclosure(self.delivery_widget, "More vocal attributes", advanced) - for name, values in ATTRIBUTES.items(): - widget = self.combo("delivery.attributes." + name, name.capitalize(), - [("Model default", "")] + [(v, v) for v in values]) - self.vocal.append(widget) - for field, title, placeholder in ( - ("delivery.attributes.persona", "Speaker background / persona", "For example: an experienced software researcher"), - ("delivery.instructions", "Custom vocal directions", "Combine attributes and optional expressive cues"), - ("delivery.progression", "Gradual delivery across slides", "For example: calm opening, build energy, reflective conclusion")): - widget = QLineEdit() - widget.setPlaceholderText(placeholder) - self.bind(field, title, widget, widget.textChanged) - self.vocal.append(widget) - self.explanation = QLabel() - self.explanation.setWordWrap(True) - self.form.addRow(self.explanation) - self.synthesis_widget = QWidget() - self.form = QFormLayout(self.synthesis_widget) - self.override = QCheckBox("Override main speech generator sampling defaults") - self.override.toggled.connect(self.sampling_changed) - self.form.addRow("Advanced synthesis", self.field("delivery.sampling", self.override)) - self.sampling = {} - for name, (low, high, default) in SAMPLING.items(): - spin = QSpinBox() if isinstance(default, int) else QDoubleSpinBox() - spin.setRange(low, high) - if isinstance(spin, QDoubleSpinBox): - spin.setSingleStep(0.05) - spin.setValue(default) - spin.valueChanged.connect(self.sampling_changed) - self.sampling[name] = spin - self.form.addRow(name.replace("_", " ").capitalize(), spin) - self.recording_widget = QWidget() - self.form = QFormLayout(self.recording_widget) - self.record = self.read_only() if dialog.scope == 1 else QCheckBox("Record presentation as a video") - self.fields["record_presentation"] = self.record - if dialog.scope != 1: - self.record.toggled.connect(lambda value: self.dialog.edit_setting("record_presentation", value)) - self.record.setToolTip("Recording begins when the presentation starts. Choose slides or screen recording in Presentation settings → Recording.") - self.combo("recording_source", "Recording source", [("Slide video + narration", "slides"), ("Screen + system audio (Linux)", "screen")]) - microphone = QCheckBox("Include microphone in screen recording") - self.bind("capture_microphone", "Live commentary", microphone, microphone.toggled) - self.combo("recording_policy", "Recording pauses", [ - ("Keep fullscreen pauses; omit time outside fullscreen", "fullscreen"), - ("Keep all elapsed time", "all"), - ("Omit manual pauses, buffering, and time outside fullscreen", "content")]) - path = QLineEdit() - path.setPlaceholderText("Default: a new video in this project's recordings folder") - self.bind("recording_destination", "Video destination", path, path.textChanged) - self.destination_button = QPushButton("Choose video destination…") - self.destination_button.clicked.connect(self.choose_destination) - self.form.addRow(self.destination_button) - self.combo("export_rate", "Export audio sample rate", [("24 kHz (native)", 24000), ("44.1 kHz", 44100), ("48 kHz", 48000)]) - self.combo("export_bitrate", "MP4 / M4A audio encoding", [("AAC 96 kbit/s", 96000), ("AAC 128 kbit/s", 128000), ("AAC 192 kbit/s", 192000)]) - self.form = main_form - - def read_only(self): - widget = QLabel() - widget.setWordWrap(True) - return widget - - def bind(self, path, label, widget, signal): - self.fields[path] = widget - self.form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) - field = self.field(path, widget) - if path in ("language_policy", "buffer_seconds", "delivery.attributes.age", "recording_policy", "export_rate", "export_bitrate"): - self.rows[path] = (label, field) - else: - self.form.addRow(label, field) - signal.connect(lambda *args, p=path: self.edit(p)) - return widget - - def combo(self, path, label, choices): - widget = QComboBox() - for text, value in choices: - widget.addItem(text, value) - return self.bind(path, label, widget, widget.currentIndexChanged) - - def place(self, path, layout): - layout.addRow(*self.rows.pop(path)) - - def field(self, path, widget): - self.fields[path] = widget - if path not in SETTING_DEFAULTS: - return widget - container = QWidget() - box = QHBoxLayout(container) - box.setContentsMargins(0, 0, 0, 0) - box.addWidget(widget, 1) - reset = QPushButton() - reset.setToolTip("Remove this override and use the parent setting") - reset.clicked.connect(lambda: self.dialog.edit_setting(path, None, inherit=True)) - box.addWidget(reset) - self.inheritance[path] = (container, reset) - return container - - def edit(self, path): - if self.loading: - return - widget = self.fields[path] - value = widget.currentData() if isinstance(widget, QComboBox) else widget.isChecked() if isinstance(widget, QCheckBox) else widget.text() if isinstance(widget, QLineEdit) else widget.value() - if path in SETTING_DEFAULTS: - self.dialog.edit_setting(path, value) - elif self.project: - setattr(self.project, path, value) - self.changed.emit() - - def sampling_changed(self, *_): - if not self.loading: - self.dialog.edit_setting("delivery.sampling", {k: v.value() for k, v in self.sampling.items()} if self.override.isChecked() else {}) - - def load(self, project): - self.project, self.loading = project, True - w = self.dialog - scope = w.scope - for path, widget in self.fields.items(): - value = w.setting_value(path) if path in SETTING_DEFAULTS else getattr(project, path, "") - if path == "background_gain": - value *= 100 - if isinstance(widget, QLabel): - widget.setText((("On" if value else "Off") if isinstance(value, bool) else str(value)) + " · " + w.setting_source(path)) - elif isinstance(widget, QComboBox): - index = widget.findData(value) - widget.setCurrentIndex(index if index >= 0 else max(0, widget.findText(str(value)))) - elif isinstance(widget, QCheckBox): - widget.setChecked(bool(value)) - elif isinstance(widget, QLineEdit): - widget.setText(value) - else: - widget.setValue(value) - if path in self.inheritance: - container, reset = self.inheritance[path] - applicable = scope in SETTING_DEFAULTS[path][1] - container.setVisible(applicable) - layout = next((form for form in container.parentWidget().findChildren(QFormLayout) if form.indexOf(container) >= 0), None) - caption = layout.labelForField(container) if isinstance(layout, QFormLayout) else None - if caption: - caption.setVisible(applicable) - source = w.setting_source(path) - inherited = source != ("Slide override" if scope == 2 else "Talk setting") - reset.setText("From " + ("app" if source == "Application default" else "talk") if inherited else "Use " + ("talk" if scope == 2 else "app")) - reset.setVisible(scope > 0) - reset.setEnabled(not inherited) - voice = w.setting_value("voice") - supported = voice.source != "Base" - self.fields["delivery.style"].setEnabled(supported) - for widget in self.vocal: - widget.setEnabled(supported) - self.fields["delivery.attributes.accent"].setEnabled(supported and w.setting_value("language") == "Chinese") - self.fields["delivery.attributes.age"].setEnabled(voice.source == "VoiceDesign") - self.explanation.setText("Reference voices reuse the recorded delivery; direct vocal controls are unavailable. Writing style still applies to new text." if not supported else "Delivery instructions guide speech; listen to assess the result.") - sampling = w.setting_value("delivery.sampling") - self.override.setChecked(bool(sampling)) - for name, spin in self.sampling.items(): - spin.setValue(sampling.get(name, SAMPLING[name][2])) - spin.setEnabled(bool(sampling)) - self.fields["capture_microphone"].setEnabled(w.setting_value("recording_source") == "screen") - self.fields["recording_policy"].setEnabled(w.setting_value("recording_source") == "slides") - destination = self.fields["recording_destination"] - destination.setVisible(scope == 1) - self.recording_widget.layout().labelForField(destination).setVisible(scope == 1) - self.destination_button.setVisible(scope == 1) - for path, applicable in (("quick_timing", w.setting_value("mode") == "Quick"), ("realtime_script", w.setting_value("mode") == "Realtime"), ("speech_priority", w.setting_value("mode") == "Realtime")): - container = self.inheritance.get(path, (self.fields[path],))[0] - container.setVisible(applicable) - caption = self.form.labelForField(container) - if caption: - caption.setVisible(applicable) - self.fields["language"].setEnabled(scope != 2 or w.setting_value("language_policy") != "version") - self.setVisible(w.setting_value("mode") != "Prepared") - self.loading = False - - def choose_destination(self): - path, _ = QFileDialog.getSaveFileName(self, "Save presentation video", "presentation.mp4", "MP4 video (*.mp4)") - if path: - self.fields["recording_destination"].setText(path) def refresh_presets(self): self.presets.clear() @@ -263,6 +31,7 @@ def refresh_presets(self): self.use_preset_button.setEnabled(bool(self.presets.count())) + def save_preset(self): name, accepted = QInputDialog.getText(self, "Save delivery", "Preset name") if accepted and name.strip(): @@ -270,11 +39,12 @@ def save_preset(self): directory = data_dir() / "delivery" directory.mkdir(parents=True, exist_ok=True) (directory / (uuid.uuid4().hex + ".json")).write_text(json.dumps({ - "name": name.strip(), "delivery": {k: v for k, v in asdict(self.dialog.voice_context().delivery).items() if k != "sampling"}}, ensure_ascii=False), encoding="utf-8") + "name": name.strip(), "delivery": {k: v for k, v in asdict(self.dialog.voice.voice_context().delivery).items() if k != "sampling"}}, ensure_ascii=False), encoding="utf-8") self.refresh_presets() except OSError as error: QMessageBox.warning(self, "Save preset", str(error)) + def use_preset(self): if not self.presets.currentData(): return @@ -283,7 +53,6 @@ def use_preset(self): delivery = Delivery(**(value | {"sampling": {}})) settings = {key: delivery.attributes.get(key.split(".")[2], "") if key.startswith("delivery.attributes.") else getattr(delivery, key.split(".")[1]) for key in SETTING_DEFAULTS if key.startswith("delivery.") and key != "delivery.sampling"} - for key, setting in read_settings(settings).items(): - self.dialog.edit_setting(key, setting) + self.dialog.edit_many([(key,value,False) for key,value in read_settings(settings).items() if self.dialog.scope in SETTING_DEFAULTS[key][1]]) except (OSError, ValueError, TypeError, KeyError) as error: QMessageBox.warning(self, "Load preset", str(error)) diff --git a/src/autotalk/preferences.py b/src/autotalk/preferences.py new file mode 100644 index 0000000..98c57ec --- /dev/null +++ b/src/autotalk/preferences.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: MIT +"""Machine preferences apply immediately; no document transaction lives here. + +Controller reads are limited to project, transport, recorder and codex_settings. +Operations use signals; the controller owns engine lifetime and persistence. +""" +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QApplication, QDialog, QButtonGroup, QRadioButton +from .ui import SectionDialog, label, button, row, combo, form + + +class PreferencesDialog(SectionDialog): + sign_in_requested = Signal() + sign_out_requested = Signal() + load_requested = Signal() + unload_requested = Signal() + model_check_requested = Signal() + engine_policy_changed = Signal() + test_audio_requested = Signal() + audio_settings_requested = Signal() + + def __init__(self, window, config): + super().__init__(window, 'Preferences') + self.config = config + self.display_name = config.value('presentation_display', '') + build_display(self); build_account(self); build_engine(self) + self.screen.currentTextChanged.connect(lambda value: config.setValue('presentation_display', value)) + self.navigation.setCurrentRow(0) + + def done(self, result): + QDialog.done(self, result) + + def show_section(self, section='Display & sound', focus=None): + super().show_section(section, getattr(self, focus) if isinstance(focus,str) else focus) + + def sync(self, editable, job=None): + super().sync(editable, job) + self.connection.setText(self.window.codex_settings.get('account', 'Not signed in to ChatGPT.')) + self.codex_signin.setEnabled(editable and not self.window.codex_settings.get('signed_in',False)) + self.codex_signout.setEnabled(editable and bool(self.window.codex_settings.get('signed_in'))) + self.audio_test_button.setEnabled(editable) + + def sync_engine(self, owner, config, job): + state = owner.state + ready = owner.matches(config) if config else False + descriptions = {"unloaded": "Not loaded", "loading": "Loading speech model…", "ready": "Ready on GPU", + "generating": "Generating speech", "in_use": "Speech model in use", "unloading": "Unloading model…", "failed": "Speech engine stopped or failed"} + consequence = {"session": "Kept until AutoTalk closes.", "presentation": "Will unload at the next presentation end.", + "idle": "Will unload after five idle minutes.", "operation": "Will unload after the next preparation or preview finishes."} + status = descriptions[state] + if state == "ready": + status += " · " + owner.config["model"]["repo"].rsplit("/", 1)[-1] + status += " · " + ("Unloading requested." if owner.release_requested else consequence[owner.retention]) + self.engine_state.setText(status) + self.engine_model.setText((f"Selected slide {self.window.transport.index + 1}: " if self.window.project else "Application default: ") + config["model"]["repo"].rsplit("/", 1)[-1] if config else + "Open a talk and configure its voice to select a speech model.") + self.load_gpu_button.setText("Load selected model" if owner.process and not ready else "Load model now") + self.load_gpu_button.setEnabled(bool(config and not job and not owner.release_requested and state not in ("loading", "generating", "unloading") and not ready)) + self.unload_gpu_button.setEnabled(bool(owner.process and not owner.guard.locked() and not owner.release_requested and state in ("ready", "failed"))) + self.check_model_button.setEnabled(bool(config and not job and not self.window.transport.active and not self.window.transport.preview_path)) + self.engine_policy_note.setText("Changes apply immediately. Automatic release follows the next safe boundary; closing Preferences does not unload the model.") + return descriptions[state] + + + +def build_display(w): + presentation = w.add("Display & sound", "Display & sound") + w.screen = combo([(f"Display {i+1}: {screen.name()}", i) for i, screen in enumerate(QApplication.screens())]) + w.screen.setCurrentIndex(max(0, w.screen.findText(w.display_name))) + fields = form(presentation) + fields.addRow("Fullscreen display on this computer", w.screen) + w.audio_test_button = button("Test audio", w.test_audio_requested.emit) + presentation.layout().addLayout(row(button("System audio settings…", w.audio_settings_requested.emit), w.audio_test_button)) + presentation.layout().addWidget(label("Display and sound choices apply immediately on this computer.")) + presentation.layout().addStretch() + + + +def build_account(w): + account = w.add("Account — Codex", "Account") + w.connection = label("Not signed in to ChatGPT.") + account.layout().addWidget(w.connection) + w.codex_signin = button("Sign in", w.sign_in_requested.emit) + w.codex_signout = button("Sign out", lambda: w.sign_out_requested.emit()) + account.layout().addLayout(row(w.codex_signin, w.codex_signout)) + account.layout().addWidget(label("Sign-in and sign-out act immediately on the shared Codex account on this computer. Model and reasoning use application defaults with optional talk overrides.")) + account.layout().addStretch() + + + +def build_engine(w): + speech = w.add("Speech engine — Qwen", "Speech engine") + w.engine_state, w.engine_model = label("Not loaded"), label("") + speech.layout().addWidget(w.engine_state) + speech.layout().addWidget(w.engine_model) + w.load_gpu_button = button("Load model now", w.load_requested.emit, True) + w.unload_gpu_button = button("Unload model now", w.unload_requested.emit) + speech.layout().addLayout(row(w.load_gpu_button, w.unload_gpu_button)) + w.check_model_button = button("Check for model updates…", w.model_check_requested.emit) + speech.layout().addWidget(w.check_model_button) + for name, title, choices in ( + ("gpu_loading", "When to load an unloaded model", [("When speech is first needed (recommended)", "needed"), ("When I start a presentation", "start")]), + ("gpu_retention", "When to release a loaded model", [("Never — keep loaded until I close AutoTalk", "session"), + ("When the presentation ends", "presentation"), ("After 5 minutes without speech generation", "idle"), + ("After each preparation or voice preview", "operation")])): + speech.layout().addWidget(label(title, "title")) + group = QButtonGroup(speech) + setattr(w, name, group) + for index, (title, value) in enumerate(choices): + choice = QRadioButton(title) + choice.setProperty("value", value) + group.addButton(choice, index) + speech.layout().addWidget(choice) + group.buttons()[0].setChecked(True) + group.buttonClicked.connect(lambda: w.engine_policy_changed.emit()) + speech.layout().addWidget(label("Prepared audio can play during background loading. Previews always load a needed model. Manual loading follows the saved release policy; leaving fullscreen is not presentation end. Unloading keeps downloaded files.")) + w.engine_policy_note = label("") + speech.layout().addWidget(w.engine_policy_note) + speech.layout().addStretch() diff --git a/src/autotalk/settings.py b/src/autotalk/settings.py index 931a971..e308640 100644 --- a/src/autotalk/settings.py +++ b/src/autotalk/settings.py @@ -1,774 +1,357 @@ # SPDX-License-Identifier: MIT -"""Fixed-scope owners; Project remains the settings resolution authority. - -Controller reads/calls are restricted to project, edit_setting, codex_settings, -error, log_message, start_job, voice_context, configuration_changed, transport -(selection/previews), recorder (read-only), and refresh/request_refresh. -Application defaults are borrowed input, never a second authority. Transaction -begin/finish callbacks and operation/navigation signals keep application-level -persistence, session lifetime and sibling navigation in the controller. +"""Draft-owned scoped settings. Project remains the resolution authority. + +The controller interface is project, edit_setting, codex_settings, error, +log_message, start_job, voice_context, configuration_changed, transport, +recorder (read-only), refresh/request_refresh. Begin/finish callbacks own live +state and persistence; signals route preferences, fitting and voice recording. +Components see only the draft. apply_edit is shared by drafting and Save replay. """ import copy -import json -import html -import wave -from functools import partial -from pathlib import Path - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import (QApplication, QDialog, QDialogButtonBox, QTableWidgetItem, - QFileDialog, QInputDialog, QTabBar, QPlainTextEdit, QLineEdit, QTableWidget, - QButtonGroup, QRadioButton, QCheckBox, QAbstractItemView, QHeaderView, QScrollArea, QWidget) - -from .project import Project, Voice, SETTING_DEFAULTS, LANGUAGES, SPEAKERS, read_settings, wav_duration -from .runtime import data_dir -from .services import synthesize, preview_path, preview_text, extract_scope -from .media import import_clip -from .voices import library, load_voice, save_voice -from .options import SettingsPanel -from .ui import SectionDialog, label, button, column, combo, number, row, form, disclosure - - -class SettingsDialog(SectionDialog): - details_changed = Signal() - sign_in_requested = Signal() - sign_out_requested = Signal() - load_requested = Signal() - unload_requested = Signal() - model_check_requested = Signal() - engine_policy_changed = Signal() - test_audio_requested = Signal() - audio_settings_requested = Signal() - fit_requested = Signal() - save_requested = Signal() +from dataclasses import asdict + +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QScrollArea, QWidget +from .project import SETTING_DEFAULTS +from .ui import SectionDialog, label, button, form, disclosure, column +from .settings_schema import SCHEMA, PAGES +from .settings_fields import SettingField +from .settings_components import VoiceEditor, CodexModelPicker, AudioAssets, ConferencePanel +from .options import DeliveryPresets + + +def apply_edit(project, scope, index, operation): + key, value, inherit = copy.deepcopy(operation) + if key in SETTING_DEFAULTS: + if scope == 0: + values = dict(project.defaults) + values.pop(key, None) if inherit else values.update({key: value}) + project.set_defaults(values) + else: + project.set_setting(key, value, project.slides[index] if scope == 2 else None, inherit) + elif key == 'clips': + project.slides[index].clips = value + else: + setattr(project, key, value) + + +def inheritance(project, key, scope, slide=None): + local = (key in project.defaults if scope == 0 else key in slide.overrides if scope == 2 else + key in project.overrides or key == 'language' and bool(project.version.language)) + if local: + return 'set here', True + if scope == 2 and project.setting_source(key) == 'Talk setting': + return 'from talk', False + return ('from app' if scope and key in project.defaults else 'built-in'), False + + +class ScopedSettingsDialog(SectionDialog): + preferences_requested = Signal(str, str) record_requested = Signal(object) - application_requested = Signal(str, str) + fit_requested = Signal() - def __init__(self, window, scope, defaults, display_name, begin, finish): - super().__init__(window, "Settings") + def __init__(self, window, scope, begin, finish): + super().__init__(window, 'Settings') self.scope, self.slide_index = scope, 0 - self.defaults, self.display_name = defaults, display_name self.begin, self.finish = begin, finish - self.loading = True - self.before = None - self.options = SettingsPanel(self) - self.options.changed.connect(window.configuration_changed) + self.project = None + self.operations, self.fields = [], {} + self.loading, self.operation = False, None self.buttons.setStandardButtons(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) - for name in ("Voice & language", "Talk & preparation", "Presentation & recording", "AI & speech engine", "Application"): - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setWidget(column("paper", 16)) - self.navigation.addItem(name) - self.pages.addWidget(scroll) - build_settings(self) - for builder in ((build_display, build_account, build_engine) if scope == 0 else - (build_conference, build_timing) if scope == 1 else (build_clips,)): - builder(self) - self.navigation.setCurrentRow(0) - self.hint = label(("Application defaults affect talks and slides inheriting them. " if scope == 0 else - "Save applies only to this talk. " if scope == 1 else "Save applies only to this slide. ") + - "Account, manual model and library actions take effect immediately.") + self.hint = label('') + self.hint.setContentsMargins(12, 0, 12, 0) self.layout().insertWidget(self.layout().count()-1, self.hint) - self.loading = False - - @property - def slide(self): - return self.window.project.slides[self.slide_index] if self.scope == 2 and self.window.project else None + self.build_pages() + self.order_focus() + self.navigation.currentRowChanged.connect(self.page_changed) + self.navigation.setCurrentRow(0) - def setting_value(self, name): - w = self.window - return w.project.setting(name, self.slide) if self.scope and w.project else self.defaults.get(name, copy.deepcopy(SETTING_DEFAULTS[name][0])) + def order_focus(self): + # Derive keyboard order from the same layouts as the schema pages. + widgets = [] + def collect(layout): + for i in range(layout.count()): + item = layout.itemAt(i) + widget = item.widget() + if isinstance(widget, QScrollArea): + collect(widget.widget().layout()) + elif widget and widget.focusPolicy() & Qt.FocusPolicy.TabFocus: + widgets.append(widget) + elif widget and widget.layout(): + collect(widget.layout()) + elif item.layout(): + collect(item.layout()) + collect(self.layout()) + for previous, following in zip(widgets, widgets[1:]): + QWidget.setTabOrder(previous, following) - def setting_source(self, name): - return self.window.project.setting_source(name, self.slide) if self.scope and self.window.project else "Application default" + @property + def pending(self): + return self.operation if self.operation and self.operation.project_returning else None - def edit_setting(self, name, value, inherit=False): - if not self.loading: - self.window.edit_setting(name, value, inherit, scope=self.scope, slide=self.slide_index) + @property + def slide(self): + return self.project.slides[self.slide_index] if self.scope == 2 and self.project else None - def load_settings(self, *_, preserve_candidate=False): - if not self.isVisible(): - self.slide_index = self.window.transport.index - previous, self.loading = self.loading, True - voice = self.setting_value("voice") - if not preserve_candidate or self.voice_source.currentIndex() != 3: - self.voice_source.setCurrentIndex(2 if voice.origin == "designed" and voice.source == "Base" else ["CustomVoice", "Base", "VoiceDesign"].index(voice.source)) - self.speaker.setCurrentIndex(self.speaker.findData(voice.speaker)) - self.voice_description.setText(voice.description) - self.voice_identity.setText(voice.label + " · " + self.setting_source("voice")) - self.voice_inherit.setText("Use talk voice" if self.scope == 2 else "Use application voice") - self.voice_inherit.setVisible(self.scope > 0) - self.voice_inherit.setEnabled(self.setting_source("voice") == ("Slide override" if self.scope == 2 else "Talk setting")) - ref = voice.references.get(self.setting_value("language"), voice.references.get("default")) - if self.transcript.toPlainText() != (ref.transcript if ref else ""): - self.transcript.setPlainText(ref.transcript if ref else "") - if self.scope == 2 and self.slide: - selected = self.clip_select.currentIndex() - self.clip_select.blockSignals(True) - self.clip_select.clear() - self.clip_select.addItems([Path(clip.file).name for clip in self.slide.clips]) - self.clip_select.setCurrentIndex(min(max(0, selected), len(self.slide.clips)-1)) - self.clip_select.blockSignals(False) - self.show_clip() - self.options.load(self.window.project) - self.refresh_models() - self.loading = previous + def setting_value(self, key): + if key not in SETTING_DEFAULTS: + return getattr(self.project, key) + if self.scope == 0: + return self.project.defaults.get(key, copy.deepcopy(SETTING_DEFAULTS[key][0])) + return self.project.setting(key, self.slide) + + def add_field(self, spec, parent): + field = SettingField(spec, self.scope, self.edit_setting) + self.fields[spec.key] = field + caption = {'language': ('Default narration language', 'Language', 'Language of this slide'), + 'mode': ('Default mode', 'Mode', 'Mode')}.get(spec.key) + layout = self.field_layout(parent) + layout.addRow(caption[self.scope] if caption else spec.label, field) + return field + + @staticmethod + def field_layout(parent): + return next((parent.layout().itemAt(i).layout() for i in range(parent.layout().count()) + if isinstance(parent.layout().itemAt(i).layout(), QFormLayout)), None) or form(parent) + + def build_pages(self): + for page in PAGES: + self.build_page(page) + for index in range(self.pages.count()): + self.pages.widget(index).widget().layout().addStretch() + + def build_page(self, page): + rows = [s for s in SCHEMA.values() if s.page == page and self.scope in s.scopes] + if page == 'Talk': + if self.scope == 1: + section = self.add('Talk & conference', page) + self.conference = ConferencePanel(self) + section.layout().addWidget(self.conference) + section = self.add('Inherited settings', page) + section.layout().addWidget(label('Keep the currently inherited values with this talk so future changes to Talk defaults do not affect it.')) + section.layout().addWidget(button('Keep these settings for this talk', self.pin)) + return + sections = {} + if page == 'Voice & language': + self.voice = VoiceEditor(self) + self.add('Voice', page).layout().addWidget(self.voice) + if page == 'AI model' and self.scope < 2: + self.models = CodexModelPicker(self) + self.add('Narration AI — Codex', page).layout().addWidget(self.models) + for spec in rows: + if spec.kind == 'custom': + continue + if spec.section not in sections: + if spec.section == "Vocal attributes": + parent = self.add("Vocal attributes", page) + sections[spec.section] = column(margin=0) + disclosure(parent, "More vocal attributes", sections[spec.section]) + else: + sections[spec.section] = self.add(spec.section, page) + self.add_field(spec, sections[spec.section]) + if spec.key == "recording_destination": + sections[spec.section].layout().addWidget(button("Choose video destination…", self.choose_destination)) + self.build_extras(page, sections) + + def build_extras(self, page, sections): + if page == 'Writing & delivery': + self.presets = DeliveryPresets(self) + self.add('Delivery library', page).layout().addWidget(self.presets) + elif page == 'Timing & playback' and self.scope < 2: + section = self.add('Measured timing' if self.scope == 1 else 'This computer', page) + if self.scope == 1: + self.timing_summary = label('') + self.fit_button = button('Save and fit…', self.save_and_fit) + section.layout().addWidget(self.timing_summary) + section.layout().addWidget(self.fit_button) + section.layout().addWidget(button('Display & sound settings…', lambda: self.preferences_requested.emit('Display & sound', 'screen'))) + elif page == 'Audio & recording' and self.scope: + self.assets = AudioAssets(self) + section = sections.get('Background audio') if self.scope == 1 else None + section = section or self.add('Slide clips', page) + section.layout().insertWidget(1, self.assets) + elif page == 'AI model' and self.scope < 2: + section = self.add('Speech model — Qwen', page) + self.voice_model = label('') + section.layout().addWidget(self.voice_model) + section.layout().addWidget(button('Speech engine settings…', lambda: self.preferences_requested.emit('Speech engine', 'engine_state'))) + section.layout().addWidget(self.voice.synthesis_widget) def show_section(self, section=None, focus=None): - w = self.window - if self.scope and not w.project: + if self.scope and not self.window.project: return if not self.isVisible(): - self.slide_index = w.transport.index + self.slide_index = self.window.transport.index self.begin(self) - self.setWindowTitle("Application defaults" if self.scope == 0 else - f"Talk settings — {w.project.title}" if self.scope == 1 else - f"Slide settings — {self.slide_index + 1}") + self.operations = [] + self.existing_files = self.media_files() + self.setWindowTitle('Talk defaults' if self.scope == 0 else 'Talk settings — '+self.project.title if self.scope == 1 else f'Slide sound — slide {self.slide_index+1}') self.load_settings() - self.refresh_library() - self.options.refresh_presets() - self.show() - super().show_section(section or ("Application" if self.scope == 0 else "Talk & preparation" if self.scope == 1 else "Voice & language"), - getattr(self, focus) if isinstance(focus, str) else focus) - - def sync(self, editable, job=None, engine=""): - w = self.window - super().sync(editable, job) - for index in range(self.pages.count()): - sections = self.pages.widget(index).widget().findChildren(QWidget, options=Qt.FindChildOption.FindDirectChildrenOnly) - applicable = [s for s in sections if self.scope in (s.property("scopes") or ())] - self.navigation.item(index).setHidden(not applicable) - for section in sections: - section.setVisible(section in applicable) - section.setEnabled(not w.recorder.source and (list(section.property("scopes") or ()) == [0] or editable)) - for container, _ in self.options.inheritance.values(): - container.setEnabled(editable) - self.voice_audition.setVisible(self.navigation.currentItem() is not None and self.navigation.currentItem().text() == "Voice & language") - if self.isVisible() and self.scope and editable and w.project and self.before is None: - self.before = copy.deepcopy(w.project) - if self.isVisible(): - self.sync_voice(editable) - self.buffer_toggle.setVisible(self.scope < 2 and self.setting_value("mode") == "Realtime") - self.codex_connection.setText(self.window.codex_settings.get("account", "Not signed in to ChatGPT.")) - if self.scope == 0: - self.connection.setText(self.codex_connection.text()) - self.codex_signin.setEnabled(editable and not self.window.codex_settings.get("signed_in", False)) - self.codex_signout.setEnabled(editable and bool(self.window.codex_settings.get("signed_in"))) - elif self.scope == 1 and self.window.project: - self.sync_talk(editable) - self.voice_model.setText("Qwen3-TTS 1.7B — " + self.setting_value("voice").source + ("\n" + engine if self.scope == 1 else "")) - - def sync_voice(self, editable): - w = self.window - voice = self.setting_value("voice") - for i in range(self.voice_panels.layout().count()): - self.voice_panels.layout().itemAt(i).widget().setVisible(i == self.voice_source.currentIndex()) - for widget in (self.voice_source, self.voice_panels, self.voice_audition, self.codex_model, self.codex_effort, self.options.synthesis_widget): - widget.setEnabled(editable) - self.library_use.setEnabled(editable and self.voice_library.currentRow() >= 0) - self.voice_preview_button.setText("Stop sample" if w.transport.preview_path else "Listen to selected voice" if self.voice_source.currentIndex() == 3 else "Listen to a sample") - context = self.voice_context() - self.voice_preview_button.setEnabled(editable and (self.voice_library.currentRow() >= 0 if self.voice_source.currentIndex() == 3 else bool(context.voice_file) if voice.source == "Base" else bool(voice.description.strip()) if voice.source == "VoiceDesign" else True)) - self.accept_voice_button.setVisible(voice.source == "VoiceDesign") - self.accept_voice_button.setEnabled(editable and voice.source == "VoiceDesign" and preview_path(context).is_file()) - self.save_voice_button.setEnabled(editable and self.voice_source.currentIndex() != 3 and (self.accept_voice_button.isEnabled() if voice.source == "VoiceDesign" else self.voice_preview_button.isEnabled())) - self.transcript.setEnabled(bool(context.voice_file) and editable) - - def sync_engine(self, owner, config, job, preferences): - state = owner.state - ready = owner.matches(config) if config else False - descriptions = {"unloaded": "Not loaded", "loading": "Loading speech model…", "ready": "Ready on GPU", - "generating": "Generating speech", "in_use": "Speech model in use", "unloading": "Unloading model…", "failed": "Speech engine stopped or failed"} - consequence = {"session": "Kept until AutoTalk closes.", "presentation": "Will unload at the next presentation end.", - "idle": "Will unload after five idle minutes.", "operation": "Will unload after the next preparation or preview finishes."} - status = descriptions[state] - if state == "ready": - status += " · " + owner.config["model"]["repo"].rsplit("/", 1)[-1] - status += " · " + ("Unloading requested." if owner.release_requested else consequence[owner.retention]) - self.engine_state.setText(status) - self.engine_model.setText((f"Selected slide {self.window.transport.index + 1}: " if self.window.project else "Application default: ") + config["model"]["repo"].rsplit("/", 1)[-1] if config else - "Open a talk and configure its voice to select a speech model.") - self.load_gpu_button.setText("Load selected model" if owner.process and not ready else "Load model now") - self.load_gpu_button.setEnabled(bool(config and not job and not owner.release_requested and state not in ("loading", "generating", "unloading") and not ready)) - self.unload_gpu_button.setEnabled(bool(owner.process and not owner.guard.locked() and not owner.release_requested and state in ("ready", "failed"))) - self.check_model_button.setEnabled(bool(config and not job and not self.window.transport.active and not self.window.transport.preview_path)) - dirty = any(getattr(self, name).checkedButton().property("value") != preferences.value(name, default) - for name, default in (("gpu_loading", "needed"), ("gpu_retention", "session"))) - self.engine_policy_note.setText("Automatic settings have unsaved changes. Load/unload actions take effect immediately." if dirty else - "Automatic settings are saved. Load/unload actions take effect immediately.") - return descriptions[state] - - def sync_talk(self, editable): - from .app import clock - p = self.window.project - for widget in (self.url, self.conference_scope, self.audience, self.objective, self.conference_button): - widget.setEnabled(p.mode != "Quick") - self.background_button.setText("Background: " + Path(p.background.file).name[:16] + "…" if p.background else "Add background track…") - for widget in (self.remove_background_button,): - widget.setEnabled(editable and p.background is not None) - self.sources.setText("Sources: " + " · ".join( - f'{html.escape(url)}' for url in p.sources) if p.sources else "") - ready = sum(p.ready(s) for s in p.included_slides) - self.timing_summary.setText(f"Target {clock(p.target_minutes*60)} · {clock(p.total_seconds)} prepared audio\n{ready}/{len(p.included_slides)} included slides have current audio. " + - ("Within requested tolerance." if p.within_target else - f"Difference: {p.total_seconds-p.target_minutes*60:+.1f} seconds (tolerance ±{p.tolerance_seconds:g}s)." if p.prepared - else "Start prepares missing text and audio automatically.")) - self.fit_button.setEnabled(editable and all(p.text_ready(s) for s in p.included_slides)) - - def done(self, result): - if self.finish(self, result): - self.before = None - QDialog.done(self, result) - - - def voice_context(self): - # A synthesis request snapshot; never adopted as the UI's document. - candidate = Project(data_dir()) if self.scope == 0 or not self.window.project else copy.deepcopy(self.window.project) - candidate.overrides = {key: copy.deepcopy(self.setting_value(key)) for key in SETTING_DEFAULTS if key != "language"} - candidate.language = self.setting_value("language") - return candidate - - - def voice_changed(self): - if not self.loading: - voice = copy.deepcopy(self.setting_value("voice")) - ref = voice.references.get(self.setting_value("language"), voice.references.get("default")) - if ref: - ref.transcript = self.transcript.toPlainText() - self.edit_setting("voice", voice) - - - def import_voice(self): - path, _ = QFileDialog.getOpenFileName(self, "Import your voice", "", "PCM WAV recording (*.wav)") - if path: - self.set_voice(Path(path)) - - - def set_voice(self, path): - try: - self.window.transport.stop() - duration = wav_duration(path) - if not 3 <= duration <= 60: - raise ValueError("Use a WAV reference recording between 3 and 60 seconds.") - candidate = self.voice_context() - candidate.set_voice(path) - self.edit_setting("voice", candidate.voice) - except (OSError, ValueError, EOFError, wave.Error) as error: - self.window.error(str(error)) + self.voice.refresh_library() + self.presets.refresh_presets() + if isinstance(focus, str): + focus = self.fields[focus].editor + super().show_section(section or ('Talk' if self.scope == 1 else 'Voice & language'), focus) - - def toggle_recording(self): - self.record_requested.emit(self) - - - def preview_voice(self): - if self.window.transport.preview_path: - self.window.transport.stop() - return - if self.window.recorder.source: - self.window.error("Stop recording before previewing the voice.") + def load_settings(self, *_, preserve_candidate=False): + if not self.project: return - def done(path): - self.window.transport.preview(path) - self.window.log_message("Playing your voice preview.") - candidate = self.voice_context() - if self.voice_source.currentIndex() == 3: - item = self.voice_library.item(self.voice_library.currentRow(), 0) - if item is None: - return - try: - load_voice(candidate, item.data(Qt.ItemDataRole.UserRole)) - except (OSError, ValueError) as error: - self.window.error(str(error)) - return - self.window.start_job("Generating voice preview…", partial(synthesize, candidate, preview=True), done) - - - def refresh_models(self): - previous = self.loading self.loading = True - wanted = self.setting_value("codex_model") - self.codex_model.clear() - default = next((m.get("displayName") or m["model"] for m in self.window.codex_settings.get("models", []) if m["model"] == self.window.codex_settings.get("model")), self.window.codex_settings.get("model", "")) - self.codex_model.addItem("Account default" + (f" ({default})" if default else ""), "") - for model in self.window.codex_settings.get("models", []): - if "image" in model.get("inputModalities", ["text", "image"]): - self.codex_model.addItem(model.get("displayName") or model["model"], model["model"]) - if wanted and self.codex_model.findData(wanted) < 0: - self.codex_model.addItem(wanted + " (refresh availability)", wanted) - self.codex_model.setCurrentIndex(max(0, self.codex_model.findData(wanted))) - self.refresh_efforts() - self.loading = previous - - - def refresh_efforts(self): - previous = self.loading - self.loading = True - model = self.codex_model.currentData() or self.window.codex_settings.get("model") - selected = next((m for m in self.window.codex_settings.get("models", []) if m["model"] == model), {}) - wanted = self.setting_value("codex_effort") - self.codex_effort.clear() - default = self.window.codex_settings.get("effort") or selected.get("defaultReasoningEffort", "") - self.codex_effort.addItem("Codex default" + (f" ({default})" if default else ""), "") - for option in selected.get("supportedReasoningEfforts", []): - effort = option["reasoningEffort"] - self.codex_effort.addItem(effort, effort) - self.codex_effort.setItemData(self.codex_effort.count()-1, option.get("description", ""), Qt.ItemDataRole.ToolTipRole) - if wanted and self.codex_effort.findData(wanted) < 0: - self.codex_effort.addItem(wanted + " (refresh availability)", wanted) - self.codex_effort.setCurrentIndex(max(0, self.codex_effort.findData(wanted))) - self.loading = previous - - - def model_changed(self): - if not self.loading: - self.edit_setting("codex_model", self.codex_model.currentData() or "") - self.edit_setting("codex_effort", "") - - - def model_settings_changed(self): - if not self.loading: - self.edit_setting("codex_effort", self.codex_effort.currentData() or "") - - - def voice_settings_changed(self): - if self.loading: - return - voice = copy.deepcopy(self.setting_value("voice")) - if self.voice_source.currentIndex() == 3: - self.window.refresh() - return - source = self.voice_source.tabData(self.voice_source.currentIndex()) - if source != voice.source: - voice = Voice(source=source) - voice.source = source - voice.speaker = self.speaker.currentData() - voice.description = self.voice_description.text() - voice.name = voice.speaker if voice.source == "CustomVoice" else "Designed voice" if voice.source == "VoiceDesign" else "My voice" - voice.origin = "designed" if voice.source == "VoiceDesign" else voice.origin - self.edit_setting("voice", voice) - - - def refresh_library(self): - selected = self.voice_library.item(self.voice_library.currentRow(), 0) - selected = selected.data(Qt.ItemDataRole.UserRole) if selected else None - self.voice_library.setRowCount(0) - for path in library(): - try: - voice = json.loads(path.read_text(encoding="utf-8")) - languages = ", ".join(k for k in voice["references"] if k != "default") or "Multilingual" - values = (voice["name"], read_settings({"voice": voice})["voice"].label.split(":", 1)[0], languages) - except (OSError, ValueError, KeyError): - continue - if self.library_language.currentText() not in ("All languages", languages) and self.library_language.currentText() not in languages and languages != "Multilingual": - continue - row = self.voice_library.rowCount() - self.voice_library.insertRow(row) - for col, value in enumerate(values): - item = QTableWidgetItem(value) - item.setData(Qt.ItemDataRole.UserRole, str(path)) - self.voice_library.setItem(row, col, item) - self.voice_library.resizeRowsToContents() - self.voice_library.selectRow(next((row for row in range(self.voice_library.rowCount()) - if self.voice_library.item(row, 0).data(Qt.ItemDataRole.UserRole) == selected), 0)) - - - def use_saved_voice(self): - item = self.voice_library.item(self.voice_library.currentRow(), 0) - if item: - try: - candidate = self.voice_context() - load_voice(candidate, item.data(Qt.ItemDataRole.UserRole)) - self.edit_setting("voice", candidate.voice) - except (OSError, ValueError) as error: - self.window.error(str(error)) - - - def save_personal_voice(self): - name, ok = QInputDialog.getText(self, "Save reusable voice", "Voice name", text=self.setting_value("voice").name) - if not ok or not name.strip(): - return - if self.setting_value("voice").source == "VoiceDesign" and not self.accept_designed_voice(): - return try: - save_voice(self.voice_context(), name) - self.refresh_library() - except (OSError, ValueError) as error: - self.window.error(str(error)) - - - def accept_designed_voice(self): - candidate = self.voice_context() - if candidate.voice.source != "VoiceDesign": - return False - path = preview_path(candidate) - if not path.exists(): - self.window.error("Preview this design first, then accept the voice you heard.") - return False - description = candidate.voice.description - try: - candidate.set_voice(path, preview_text(candidate)) - except (OSError, ValueError) as error: - self.window.error(str(error)) - return False - voice = copy.deepcopy(candidate.voice) - voice.name, voice.origin, voice.description = "Designed voice", "designed", description - self.edit_setting("voice", voice) - return True - - - def background_changed(self, *_): - if not self.loading: - gain, loop = self.background_gain.value() / 100, self.background_loop.isChecked() - self.edit_setting("background_gain", gain) - self.edit_setting("background_loop", loop) - - - def pin_settings(self): - if self.window.project and self.scope == 1: - for name in SETTING_DEFAULTS: - self.window.project.set_setting(name, self.window.project.setting(name)) - for version in self.window.project.versions.values(): - if not version.language: - version.language = self.window.project.defaults.get("language", "English") - version.name = version.language - self.load_settings() - self.window.configuration_changed() - - + with self.project.pass_cache(): + for key, field in self.fields.items(): + source, overridden = inheritance(self.project, key, self.scope, self.slide) if key in SETTING_DEFAULTS else ('', False) + field.load(self.setting_value(key), source, overridden) + self.voice.load(preserve_candidate) + if self.scope < 2: + self.models.refresh_models() + if self.scope: + self.assets.load() + finally: + self.loading = False + self.page_changed() + + def page_changed(self): + self.sync(not self.operation) + if self.scope == 1 and self.project and self.navigation.currentItem().text() == 'Timing & playback': + self.sync_timing() + def edit_setting(self, name, value, inherit=False): + self.edit_many([(name, value, inherit)]) - def read_conference(self): - if not self.url.text().strip(): - self.window.error("Enter a conference website URL, or type the scope directly below.") + def edit_many(self, operations): + if self.loading or not self.project: return - def done(result): - self.window.project.sources = result["sources"] - self.conference_scope.setPlainText(result["scope"]) - self.window.project.save() - self.window.log_message("Review the extracted conference scope before writing the talk text.") - self.window.start_job("Reading conference scope…", lambda task: extract_scope(self.url.text().strip(), task, self.window.project.codex_model, self.window.project.codex_effort), done) + for operation in copy.deepcopy(operations): + apply_edit(self.project, self.scope, self.slide_index, operation) + self.operations.append(operation) + self.load_settings(preserve_candidate=True) + def choose_destination(self): + path, _ = QFileDialog.getSaveFileName(self, "Save presentation video", "presentation.mp4", "MP4 video (*.mp4)") + if path: + self.edit_setting("recording_destination", path) + def pin(self): + self.edit_many([(key, self.project.setting(key), False) for key, (_, scopes) in SETTING_DEFAULTS.items() if 1 in scopes]) - def show_clip(self, *_): - if not self.window.project: + def sync(self, editable, job=None): + if not self.project: return - previous = self.loading - self.loading = True - clips = self.slide.clips - index = self.clip_select.currentIndex() - self.remove_clip_button.setEnabled(0 <= index < len(clips)) - self.clip_gain.setEnabled(0 <= index < len(clips)) - self.clip_placement.setEnabled(0 <= index < len(clips)) - if 0 <= index < len(clips): - if self.clip_gain.value() != clips[index].gain: - self.clip_gain.setValue(clips[index].gain) - self.clip_placement.setCurrentIndex(self.clip_placement.findData(clips[index].placement)) - self.loading = previous - - - - def clip_changed(self, *_): - if not self.loading and self.window.project: - clips = self.slide.clips - index = self.clip_select.currentIndex() - if 0 <= index < len(clips): - clips[index].gain = self.clip_gain.value() - clips[index].placement = self.clip_placement.currentData() - self.window.configuration_changed() - - + self.buttons.setEnabled(True) + self.buttons.button(QDialogButtonBox.StandardButton.Save).setEnabled(not self.pending and not self.window.recorder.source) + self.cancel_job.setVisible(job is not None or bool(self.window.recorder.source)) + self.cancel_job.setText('Stop voice recording' if self.window.recorder.source else 'Cancel operation') + if not self.isVisible(): + return + page = self.navigation.currentItem().text() + self.pages.setEnabled(editable and not self.pending) + self.voice.voice_audition.setVisible(page == 'Voice & language') + if page == 'Voice & language': + self.voice.sync(editable and not self.operation) + self.hint.setText('Save applies to talks and slides that inherit these values.' if self.scope == 0 else 'Save applies to this talk.' if self.scope == 1 else 'Save applies to this slide.') + if page in ('Voice & language', 'Writing & delivery'): + self.hint.setText(self.hint.text()+' Library actions save to the shared library immediately.') + self.sync_fields() + if self.scope < 2: + self.models.refresh_models() + self.models.connection.setText(self.window.codex_settings.get('account','Not signed in to ChatGPT.')) + self.voice_model.setText('Qwen3-TTS 1.7B — '+self.setting_value('voice').source) + if self.scope == 1: + self.conference.load() + + def sync_fields(self): + voice = self.setting_value('voice') + for key, field in self.fields.items(): + if key.startswith('delivery.') and key != 'delivery.sampling': + field.setEnabled(voice.source != 'Base') + self.fields['delivery.attributes.age'].setEnabled(voice.source == 'VoiceDesign') + self.fields['delivery.attributes.accent'].setEnabled(voice.source != 'Base' and self.setting_value('language') == 'Chinese') + self.fields['language'].setEnabled(self.scope != 2 or self.setting_value('language_policy') != 'version') + for key, mode in [('quick_timing','Quick'),('realtime_script','Realtime'),('speech_priority','Realtime'),('buffer_seconds','Realtime')]: + if key in self.fields: + self.fields[key].setEnabled(self.setting_value('mode') == mode) + if self.scope < 2: + screen = self.setting_value('recording_source') == 'screen' + self.fields['capture_microphone'].setEnabled(screen) + self.fields['recording_policy'].setEnabled(not screen) + + def sync_timing(self): + from .app import clock + p = self.project + with p.pass_cache(): + ready = sum(p.ready(s) for s in p.included_slides) + self.timing_summary.setText(f'Target {clock(p.target_minutes*60)} · Audio {clock(p.total_seconds)}\n{ready}/{len(p.included_slides)} included slides have current audio.') + self.fit_button.setEnabled(not self.pending and all(p.text_ready(s) for s in p.included_slides)) + + def start_job(self, title, function, callback, *, project_returning=False): + draft, existing = self.project, self.existing_files + def delivered(result): + if self.isVisible() and self.project is draft: + callback(result) + job = self.window.start_job(title, function, delivered, save_before=False) + if job: + self.operation = job + job.project_returning = project_returning + def finished(): + if self.operation is job: + self.operation = None + if job.task.cancelled.is_set(): + self.clean_media(draft, existing) + if self.isVisible(): + self.sync(True) + job.finished.connect(finished) + self.sync(False, job) + return job + + def media_files(self, project=None): + project = project or self.project + return set(project.root.glob('media/*.wav')) | set(project.root.glob('voice/**/*.wav')) + + def clean_media(self, draft, existing): + def references(value): + if isinstance(value, dict): + return {value.get('file', '')} | set().union(*(references(v) for v in value.values())) + if isinstance(value, list): + return set().union(*(references(v) for v in value)) + return set() + live = self.window.project + protected = references(asdict(live)) if live and live.root == draft.root else set() + for path in self.media_files(draft) - existing: + if str(path.relative_to(draft.root)) not in protected: + path.unlink(missing_ok=True) - def add_clip(self): - path, _ = QFileDialog.getOpenFileName(self, "Choose presentation audio", "", "Audio (*.wav *.mp3 *.flac *.ogg *.m4a *.mp4);;All files (*)") - if not path: + def done(self, result): + if self.project is None: + QDialog.done(self, result) return - index = self.slide_index - def done(clip): - self.window.project.slides[index].clips.append(clip) - self.load_settings() - self.window.start_job("Importing audio clip…", partial(import_clip, self.window.project, Path(path)), done) + if result == QDialog.DialogCode.Accepted and self.pending: + self.status.setText('Wait for the current import or conference operation before saving.') + return + if not self.finish(self, result): + return + if result != QDialog.DialogCode.Accepted: + if self.operation: + self.operation.task.cancelled.set() + self.clean_media(self.project, self.existing_files) + QDialog.done(self, result) + + def save_and_fit(self): + self.accept() + if not self.isVisible(): + self.fit_requested.emit() +class TalkDefaultsDialog(ScopedSettingsDialog): + def __init__(self, window, begin, finish): + super().__init__(window, 0, begin, finish) - def remove_clip(self): - clips = self.slide.clips - index = self.clip_select.currentIndex() - if 0 <= index < len(clips): - clips.pop(index) - self.load_settings() - self.window.configuration_changed() +class TalkSettingsDialog(ScopedSettingsDialog): + def __init__(self, window, begin, finish): + super().__init__(window, 1, begin, finish) - def add_background(self): - path, _ = QFileDialog.getOpenFileName(self, "Choose background audio", "", "Audio (*.wav *.mp3 *.flac *.ogg *.m4a);;All files (*)") - if path: - def done(clip): - clip.gain = self.window.project.background_gain - clip.loop = self.window.project.background_loop - self.window.project.background = clip - self.window.transport.refresh_audio() - self.window.refresh() - self.save_requested.emit() - self.window.start_job("Importing background track…", partial(import_clip, self.window.project, Path(path), placement="background"), done) - - - - def remove_background(self): - self.window.project.background = None - self.window.configuration_changed() - - - -def build_settings(w): - codex = w.add("Narration AI — Codex", "AI & speech engine", (0, 1)) - codex.layout().addWidget(label("Model and reasoning for the selected scope. Slide images, text and conference context are sent to Codex; voice references stay local.")) - w.codex_connection = label("") - codex.layout().addWidget(w.codex_connection) - codex.layout().addWidget(button("Account settings…", lambda: w.application_requested.emit("Application", "codex_signin"))) - w.codex_model = combo([("Account default", "")], w.model_changed) - w.codex_effort = combo([("Codex default", "")], w.model_settings_changed) - fields = form(codex) - fields.addRow("Model", w.options.field("codex_model", w.codex_model)) - fields.addRow("Reasoning effort", w.options.field("codex_effort", w.codex_effort)) - codex.layout().addStretch() - timing = w.add("Preparation & timing", "Talk & preparation", (0, 1)) - w.tolerance = number(0, 600, 15, lambda: w.edit_setting("tolerance_seconds", w.tolerance.value()), " sec") - form(timing).addRow("Allowed timing difference", w.options.field("tolerance_seconds", w.tolerance)) - timing.layout().addWidget(w.options) - w.settings_mode = w.options.read_only() if w.scope == 1 else combo(["Prepared", "Quick", "Realtime"], lambda: w.edit_setting("mode", w.settings_mode.currentText())) - form(timing).addRow("Default mode" if w.scope == 0 else "Mode", w.options.field("mode", w.settings_mode)) - language = w.add("Language", "Voice & language") - w.settings_language = w.options.read_only() if w.scope == 1 else combo(LANGUAGES, lambda: w.edit_setting("language", w.settings_language.currentText())) - w.settings_language.setToolTip("Selecting a talk language restores its saved version or starts an empty version. Create talk text or Start generates missing narration from the slides and talk context, without translating another version. A slide language change affects only that slide.") - form(language).addRow(("Default narration language", "Language", "Language of this slide")[w.scope], w.options.field("language", w.settings_language)) - language.layout().addWidget(label("Talk language selection preserves separate versions automatically. Slide language overrides and explicit passages keep their own language.")) - buffer = column(margin=0) - for name, destination in (("language_policy", language), ("buffer_seconds", buffer)): - w.options.place(name, form(destination)) - w.buffer_toggle = disclosure(timing, "Advanced Realtime buffering", buffer) - timing.layout().addStretch() - language.layout().addStretch() - - voice = w.add("Voice", "Voice & language") - w.voice_source = QTabBar() - for title, source in (("Predefined", "CustomVoice"), ("My voice", "Base"), ("Design", "VoiceDesign"), ("Saved", "Saved")): - w.voice_source.setTabData(w.voice_source.addTab(title), source) - w.voice_source.currentChanged.connect(w.voice_settings_changed) - w.voice_identity = label("") - w.voice_inherit = button("Use parent voice", lambda: w.edit_setting("voice", None, inherit=True)) - voice.layout().addLayout(row(w.voice_identity, w.voice_inherit)) - voice.layout().addWidget(w.voice_source) - w.voice_panels = column(margin=0) - voice.layout().addWidget(w.voice_panels) - predefined, personal, designed, library_page = (column(margin=0) for _ in range(4)) - for page in (predefined, personal, designed, library_page): - w.voice_panels.layout().addWidget(page) - w.speaker = combo([(f"{name} — {description}", name) for name, description in SPEAKERS.items()], w.voice_settings_changed) - form(predefined).addRow("Voice", w.speaker) - predefined.layout().addWidget(label("Every voice supports the talk language. Native languages indicate strongest pronunciation. Listen before choosing; no personal recording is needed.")) - predefined.layout().addStretch() - personal.layout().addWidget(label("Record or import 10–30 seconds, then enter the exact words. Recording stops after 30 seconds.")) - w.record_button = button("Record my voice", w.toggle_recording) - personal.layout().addLayout(row(w.record_button, button("Import voice recording…", w.import_voice))) - w.transcript = QPlainTextEdit() - w.transcript.setPlaceholderText("The exact words in your reference recording") - w.transcript.textChanged.connect(w.voice_changed) - personal.layout().addWidget(w.transcript) - w.voice_description = QLineEdit() - w.voice_description.setPlaceholderText("For example: warm, calm, clear pronunciation") - w.voice_description.textChanged.connect(w.voice_settings_changed) - design_form = form(designed) - design_form.addRow("Describe the voice", w.voice_description) - w.options.place("delivery.attributes.age", design_form) - w.accept_voice_button = button("Use this designed voice", w.accept_designed_voice) - designed.layout().addWidget(w.accept_voice_button) - designed.layout().addStretch() - w.library_language = combo(["All languages", *LANGUAGES], w.refresh_library) - library_page.layout().addWidget(label("Choose a saved voice to audition or use. To add one, choose Predefined, My voice or Design, then Save reusable voice.")) - library_page.layout().addWidget(w.library_language) - w.voice_library = QTableWidget(0, 3) - w.voice_library.setHorizontalHeaderLabels(["Voice", "Description", "Languages"]) - w.library_use = button("Use selected voice", w.use_saved_voice) - w.voice_library.itemSelectionChanged.connect(w.window.refresh) - library_page.layout().addWidget(w.library_use) - library_page.layout().addWidget(w.voice_library, 1) - delivery = w.add("Writing & delivery", "Voice & language") - delivery.layout().addWidget(label("Writing style guides new text; spoken delivery guides audio. Save delivery preset stores delivery directions immediately in the shared library, without voice identity or synthesis sampling. Cancel does not remove saved presets.")) - delivery.layout().addWidget(w.options.delivery_widget) - delivery.layout().addStretch() - model = w.add("Speech model — Qwen", "AI & speech engine", (0, 1)) - w.voice_model = label("") - model.layout().addWidget(w.voice_model) - model.layout().addWidget(label("The voice workflow selects the Qwen3-TTS 1.7B variant. Base reuses reference delivery; CustomVoice and VoiceDesign support vocal directions.")) - if w.scope > 0: - model.layout().addWidget(button("Speech engine settings…", lambda: w.application_requested.emit("AI & speech engine", "engine_state"))) - disclosure(model, "Advanced synthesis", w.options.synthesis_widget) - model.layout().addStretch() - w.voice_audition = column(margin=12) - w.voice_audition.layout().addWidget(label("Listen before choosing. Save reusable voice stores the voice identity and references immediately in the shared library; Cancel does not remove it. First use downloads several GB. Progress and Cancel appear here.")) - w.voice_preview_button = button("Listen to a sample", w.preview_voice, True) - w.save_voice_button = button("Save reusable voice…", w.save_personal_voice) - w.voice_audition.layout().addLayout(row(w.voice_preview_button, w.save_voice_button)) - w.layout().insertWidget(w.layout().indexOf(w.status), w.voice_audition) - - w.pause = number(0, 10, .6, lambda: w.edit_setting("pause_seconds", w.pause.value()), " sec") - w.pause.setSingleStep(.1) - playback = w.add("Playback", "Presentation & recording", (0, 1)) - form(playback).addRow("Pause after slide", w.options.field("pause_seconds", w.pause)) - w.settings_after = combo([("Advance automatically", "advance"), ("Pause for live demo", "demo"), ("Wait for presenter", "pause")], lambda: w.edit_setting("after", w.settings_after.currentData())) - form(playback).addRow("After slide", w.options.field("after", w.settings_after)) - if w.scope == 1: - playback.layout().addWidget(button("Display && sound settings…", lambda: w.application_requested.emit("Application", "screen"))) - recording = w.add("Recording", "Presentation & recording", (0, 1)) - form(recording).addRow("Record presentation as a video" if w.scope == 1 else "Recording", w.options.field("record_presentation", w.options.record)) - recording.layout().addWidget(label("Screen capture continues until End presentation and save video. Slides-and-speech recordings finish after the last slide. Screen sharing is authorized separately from the presentation display.")) - recording.layout().addWidget(w.options.recording_widget) - for title, names in (("Advanced recording", ("recording_policy",)), ("Output quality", ("export_rate", "export_bitrate"))): - panel = column(margin=0) - fields = form(panel) - for name in names: - w.options.place(name, fields) - disclosure(recording, title, panel) - recording.layout().addStretch() - background = w.add("Background audio", "Presentation & recording", (0, 1)) - if w.scope == 1: - w.background_button = button("Add background track…", w.add_background) - w.remove_background_button = button("Remove background", w.remove_background) - background.layout().addLayout(row(w.background_button, w.remove_background_button)) - w.background_gain = number(0, 100, 15, w.background_changed, " %") - w.background_gain.setSingleStep(5) - w.background_loop = QCheckBox("Loop") - w.background_loop.toggled.connect(w.background_changed) - form(background).addRow("Background volume", w.options.field("background_gain", w.background_gain)) - background.layout().addWidget(w.options.field("background_loop", w.background_loop)) - background.layout().addStretch() - - voice.parentWidget().layout().removeWidget(language) - voice.parentWidget().layout().insertWidget(1, language) - # Keep keyboard traversal independent of section construction/parenting order. - QWidget.setTabOrder(w.tolerance, w.options.fields["language_policy"]) - QWidget.setTabOrder(w.options.fields["language_policy"], w.options.fields["quick_timing"]) - tail = ("delivery.sampling", "after", "language", "mode", "pause_seconds", "tolerance_seconds", - "codex_model", "codex_effort", "background_gain", "background_loop") - previous = w.background_loop - for path in [p for p in w.options.fields if p not in tail and p in w.options.inheritance] + list(tail): - reset = w.options.inheritance[path][1] - QWidget.setTabOrder(previous, reset) - previous = reset - assert not w.options.rows - del w.options.rows - w.pages.currentChanged.connect(lambda: (w.load_settings(), w.window.refresh())) - for table in (w.voice_library,): - table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - table.verticalHeader().hide() - table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - - -def build_conference(w): - conference = w.add("Talk & conference", "Talk & preparation", (1,)) - conference.parentWidget().layout().removeWidget(conference) - conference.parentWidget().layout().insertWidget(0, conference) - fields = form(conference) - for attr, title, default in (("talk_title", "Talk title", ""), ("audience", "Audience", "General conference audience"), - ("objective", "Talk objective", "Explain the key ideas and takeaways"), ("url", "Conference website", "")): - field = QLineEdit(default) - field.textChanged.connect(w.details_changed) - setattr(w, attr, field) - fields.addRow(title, field) - w.url.setPlaceholderText("https://conference.example/call-for-papers") - w.conference_button = button("Read conference website", w.read_conference) - conference.layout().addWidget(w.conference_button) - conference.layout().addWidget(label("Optional conference scope: enter topics or review the website summary.")) - w.conference_scope = QPlainTextEdit() - w.conference_scope.setPlaceholderText("Themes, tracks, typical topics, edition/year…") - w.conference_scope.textChanged.connect(w.details_changed) - conference.layout().addWidget(w.conference_scope, 1) - w.sources = label("") - w.sources.setOpenExternalLinks(True) - conference.layout().addWidget(w.sources) - - -def build_display(w): - presentation = w.add("Display & sound", "Application", (0,)) - w.screen = combo([(f"Display {i+1}: {screen.name()}", i) for i, screen in enumerate(QApplication.screens())]) - w.screen.setCurrentIndex(max(0, w.screen.findText(w.display_name))) - fields = form(presentation) - fields.addRow("Fullscreen display on this computer", w.screen) - w.audio_test_button = button("Test audio", w.test_audio_requested.emit) - presentation.layout().addLayout(row(button("System audio settings…", w.audio_settings_requested.emit), w.audio_test_button)) - presentation.layout().addWidget(label("System sound routing changes immediately. Cancel restores the display selection; external sound settings take effect immediately.")) - presentation.layout().addStretch() - - -def build_timing(w): - timing = w.add("Measured timing", "Talk & preparation", (1,)) - w.timing_summary = label("") - timing.layout().addWidget(w.timing_summary) - w.fit_button = button("Fit duration…", w.fit_requested.emit) - timing.layout().addWidget(w.fit_button) - timing.layout().addWidget(button("Keep these settings for this talk", w.pin_settings)) - - -def build_account(w): - account = w.add("Account — Codex", "Application", (0,)) - w.connection = label("Not signed in to ChatGPT.") - account.layout().addWidget(w.connection) - w.codex_signin = button("Sign in", w.sign_in_requested.emit) - w.codex_signout = button("Sign out", lambda: w.sign_out_requested.emit()) - account.layout().addLayout(row(w.codex_signin, w.codex_signout)) - account.layout().addWidget(label("Sign-in and sign-out act immediately on the shared Codex account on this computer. Model and reasoning use application defaults with optional talk overrides.")) - account.layout().addStretch() - - -def build_engine(w): - speech = w.add("Speech engine — Qwen", "AI & speech engine", (0,)) - w.engine_state, w.engine_model = label("Not loaded"), label("") - speech.layout().addWidget(w.engine_state) - speech.layout().addWidget(w.engine_model) - w.load_gpu_button = button("Load model now", w.load_requested.emit, True) - w.unload_gpu_button = button("Unload model now", w.unload_requested.emit) - speech.layout().addLayout(row(w.load_gpu_button, w.unload_gpu_button)) - w.check_model_button = button("Check for model updates…", w.model_check_requested.emit) - speech.layout().addWidget(w.check_model_button) - for name, title, choices in ( - ("gpu_loading", "When to load an unloaded model", [("When speech is first needed (recommended)", "needed"), ("When I start a presentation", "start")]), - ("gpu_retention", "When to release a loaded model", [("Never — keep loaded until I close AutoTalk", "session"), - ("When the presentation ends", "presentation"), ("After 5 minutes without speech generation", "idle"), - ("After each preparation or voice preview", "operation")])): - speech.layout().addWidget(label(title, "title")) - group = QButtonGroup(speech) - setattr(w, name, group) - for index, (title, value) in enumerate(choices): - choice = QRadioButton(title) - choice.setProperty("value", value) - group.addButton(choice, index) - speech.layout().addWidget(choice) - group.buttons()[0].setChecked(True) - group.buttonClicked.connect(lambda: w.engine_policy_changed.emit()) - speech.layout().addWidget(label("Prepared audio can play during background loading. Previews always load a needed model. Manual loading follows the saved release policy; leaving fullscreen is not presentation end. Unloading keeps downloaded files.")) - w.engine_policy_note = label("") - speech.layout().addWidget(w.engine_policy_note) - speech.layout().addStretch() - - - -def build_clips(w): - extras = w.add("Slide audio clips", "Presentation & recording", (2,)) - w.clip_select = combo([], w.show_clip) - extras.layout().addWidget(w.clip_select) - w.clip_gain = number(0, 2, 1, w.clip_changed) - w.clip_gain.setSingleStep(.05) - w.clip_placement = combo([("Before narration", "before"), ("After narration", "after")], w.clip_changed) - fields = form(extras) - fields.addRow("Volume", w.clip_gain) - fields.addRow("Play", w.clip_placement) - w.remove_clip_button = button("Remove", w.remove_clip) - extras.layout().addLayout(row(button("Add…", w.add_clip), w.remove_clip_button)) - extras.layout().addStretch() +class SlideSettingsDialog(ScopedSettingsDialog): + def __init__(self, window, begin, finish): + super().__init__(window, 2, begin, finish) diff --git a/src/autotalk/settings_components.py b/src/autotalk/settings_components.py new file mode 100644 index 0000000..0d5b528 --- /dev/null +++ b/src/autotalk/settings_components.py @@ -0,0 +1,481 @@ +# SPDX-License-Identifier: MIT +"""Scoped dialog components. Only a draft crosses the component boundary. + +Controller access: project, edit_setting, codex_settings, error, log_message, +start_job, voice_context, configuration_changed, transport (previews), recorder +(read-only), refresh/request_refresh. Dialog-owned jobs and edits use the draft. +""" +import copy +import html +import json +import wave +from functools import partial +from pathlib import Path +from PySide6.QtCore import Qt +from PySide6.QtWidgets import (QWidget, QVBoxLayout, QTabBar, QPlainTextEdit, QLineEdit, + QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, QFileDialog, + QInputDialog, QCheckBox, QDoubleSpinBox, QSpinBox) +from .project import Voice, SETTING_DEFAULTS, LANGUAGES, SPEAKERS, read_settings, wav_duration +from .services import synthesize, preview_path, preview_text, extract_scope +from .media import import_clip +from .voices import library, load_voice, save_voice +from .ui import label, button, column, combo, number, row, form +from .settings_schema import SCHEMA, Setting +from .settings_fields import SettingField + +SAMPLING = {"temperature": (0.0, 2.0, 0.9), "top_k": (1, 200, 50), + "top_p": (0.01, 1.0, 1.0), "repetition_penalty": (1.0, 2.0, 1.05), + "max_new_tokens": (128, 2048, 2048)} + + +class VoiceEditor(QWidget): + def __init__(self, dialog): + super().__init__() + self.dialog, self.loading = dialog, True + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0,0,0,0) + self.voice_identity = label('') + self.layout().addWidget(self.voice_identity) + self.voice_source = QTabBar() + for title, source in (("Predefined", "CustomVoice"), ("My voice", "Base"), ("Design", "VoiceDesign"), ("Saved", "Saved")): + self.voice_source.setTabData(self.voice_source.addTab(title), source) + self.voice_source.currentChanged.connect(self.voice_settings_changed) + voice_field = SettingField(Setting('voice','Voice','Voice & language','Voice',kind='custom'), dialog.scope, dialog.edit_setting, self.voice_source) + dialog.fields['voice'] = voice_field + self.layout().addWidget(voice_field) + self.voice_panels = column(margin=0) + self.layout().addWidget(self.voice_panels) + predefined, personal, designed, saved = (column(margin=0) for _ in range(4)) + for page in (predefined, personal, designed, saved): + self.voice_panels.layout().addWidget(page) + self.speaker = combo([(f"{name} — {description}", name) for name,description in SPEAKERS.items()], self.voice_settings_changed) + form(predefined).addRow('Voice', self.speaker) + predefined.layout().addWidget(label('Every voice supports the talk language. Native languages indicate strongest pronunciation. Listen before choosing; no personal recording is needed.')) + personal.layout().addWidget(label('Record or import 10–30 seconds, then enter the exact words. Recording stops after 30 seconds.')) + self.record_button = button('Record my voice', self.toggle_recording) + personal.layout().addLayout(row(self.record_button,button('Import voice recording…',self.import_voice))) + self.transcript = QPlainTextEdit() + self.transcript.setMaximumHeight(100) + self.transcript.setPlaceholderText('The exact words in your reference recording') + self.transcript.textChanged.connect(self.voice_changed) + personal.layout().addWidget(self.transcript) + self.voice_description = QLineEdit() + self.voice_description.setPlaceholderText('For example: warm, calm, clear pronunciation') + self.voice_description.textChanged.connect(self.voice_settings_changed) + form(designed).addRow('Describe the voice', self.voice_description) + self.accept_voice_button = button('Use this designed voice', self.accept_designed_voice) + designed.layout().addWidget(self.accept_voice_button) + self.library_language = combo(['All languages',*LANGUAGES],self.refresh_library) + saved.layout().addWidget(self.library_language) + self.voice_library = QTableWidget(0,3) + self.voice_library.setHorizontalHeaderLabels(['Voice','Description','Languages']) + self.voice_library.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.voice_library.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.voice_library.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.voice_library.verticalHeader().hide() + self.voice_library.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.voice_library.horizontalHeader().setSectionResizeMode(1,QHeaderView.ResizeMode.Stretch) + self.voice_library.setMaximumHeight(120) + self.voice_library.itemSelectionChanged.connect(lambda: self.sync(True)) + self.library_use = button('Use selected voice', self.use_saved_voice) + saved.layout().addWidget(self.library_use) + saved.layout().addWidget(self.voice_library) + self.voice_audition = column(margin=12) + self.voice_audition.layout().addWidget(label('First speech use downloads several GB; progress and cancellation appear here.')) + self.voice_preview_button = button('Listen to a sample', self.preview_voice, True) + self.save_voice_button = button('Save reusable voice…',self.save_personal_voice) + self.voice_audition.layout().addLayout(row(self.voice_preview_button,self.save_voice_button)) + dialog.layout().insertWidget(dialog.layout().indexOf(dialog.status),self.voice_audition) + if dialog.scope < 2: + self.synthesis_widget = column(margin=0) + self.sampling = {} + self.override = QCheckBox('Override main speech generator sampling defaults') + self.override.toggled.connect(self.sampling_changed) + field = SettingField(Setting('delivery.sampling','Sampling','AI model','Speech model',kind='custom'),dialog.scope,dialog.edit_setting,self.override) + dialog.fields['delivery.sampling'] = field + self.synthesis_widget.layout().addWidget(field) + fields = form(self.synthesis_widget) + for name,(low,high,default) in SAMPLING.items(): + spin = QSpinBox() if isinstance(default,int) else QDoubleSpinBox() + spin.setRange(low,high) + spin.setValue(default) + spin.valueChanged.connect(self.sampling_changed) + self.sampling[name]=spin + fields.addRow(name.replace('_',' ').capitalize(),spin) + self.loading = False + + def load(self, preserve_candidate=False): + self.loading = True + try: + voice = self.dialog.setting_value('voice') + if not preserve_candidate or self.voice_source.currentIndex()!=3: + self.voice_source.setCurrentIndex(2 if voice.origin=='designed' and voice.source=='Base' else ['CustomVoice','Base','VoiceDesign'].index(voice.source)) + self.speaker.setCurrentIndex(self.speaker.findData(voice.speaker)) + self.voice_description.setText(voice.description) + self.voice_identity.setText(voice.label) + ref=voice.references.get(self.dialog.setting_value('language'),voice.references.get('default')) + if self.transcript.toPlainText() != (ref.transcript if ref else ''): + self.transcript.setPlainText(ref.transcript if ref else '') + if self.dialog.scope < 2: + sampling=self.dialog.setting_value('delivery.sampling') + self.override.setChecked(bool(sampling)) + for key,spin in self.sampling.items(): + if spin.value()!=sampling.get(key,SAMPLING[key][2]): + spin.setValue(sampling.get(key,SAMPLING[key][2])) + spin.setEnabled(bool(sampling)) + finally: + self.loading = False + + def sampling_changed(self): + if not self.loading: + self.dialog.edit_setting('delivery.sampling',{k:v.value() for k,v in self.sampling.items()} if self.override.isChecked() else {}) + + def voice_context(self): + candidate = copy.deepcopy(self.dialog.project) + candidate.overrides = {key:copy.deepcopy(self.dialog.setting_value(key)) for key in SETTING_DEFAULTS if key!='language'} + candidate.language = self.dialog.setting_value('language') + return candidate + + def toggle_recording(self): + self.dialog.record_requested.emit(self) + + def sync(self, editable): + w = self.dialog.window + voice = self.dialog.setting_value("voice") + for i in range(self.voice_panels.layout().count()): + self.voice_panels.layout().itemAt(i).widget().setVisible(i == self.voice_source.currentIndex()) + for widget in (self.voice_source, self.voice_panels, self.voice_audition): + widget.setEnabled(editable) + self.library_use.setEnabled(editable and self.voice_library.currentRow() >= 0) + self.voice_preview_button.setText("Stop sample" if w.transport.preview_path else "Listen to selected voice" if self.voice_source.currentIndex() == 3 else "Listen to a sample") + context = self.voice_context() + self.voice_preview_button.setEnabled(editable and (self.voice_library.currentRow() >= 0 if self.voice_source.currentIndex() == 3 else bool(context.voice_file) if voice.source == "Base" else bool(voice.description.strip()) if voice.source == "VoiceDesign" else True)) + self.accept_voice_button.setVisible(voice.source == "VoiceDesign") + self.accept_voice_button.setEnabled(editable and voice.source == "VoiceDesign" and preview_path(context).is_file()) + self.save_voice_button.setEnabled(editable and self.voice_source.currentIndex() != 3 and (self.accept_voice_button.isEnabled() if voice.source == "VoiceDesign" else self.voice_preview_button.isEnabled())) + self.transcript.setEnabled(bool(context.voice_file) and editable) + + + def voice_changed(self): + if not self.loading: + voice = copy.deepcopy(self.dialog.setting_value("voice")) + ref = voice.references.get(self.dialog.setting_value("language"), voice.references.get("default")) + if ref: + ref.transcript = self.transcript.toPlainText() + self.dialog.edit_setting("voice", voice) + + + def import_voice(self): + path, _ = QFileDialog.getOpenFileName(self, "Import your voice", "", "PCM WAV recording (*.wav)") + if path: + self.set_voice(Path(path)) + + + def set_voice(self, path): + try: + self.dialog.window.transport.stop() + duration = wav_duration(path) + if not 3 <= duration <= 60: + raise ValueError("Use a WAV reference recording between 3 and 60 seconds.") + candidate = self.voice_context() + candidate.set_voice(path) + self.dialog.edit_setting("voice", candidate.voice) + except (OSError, ValueError, EOFError, wave.Error) as error: + self.dialog.window.error(str(error)) + + + def preview_voice(self): + if self.dialog.window.transport.preview_path: + self.dialog.window.transport.stop() + return + if self.dialog.window.recorder.source: + self.dialog.window.error("Stop recording before previewing the voice.") + return + def done(path): + self.dialog.window.transport.preview(path) + self.dialog.window.log_message("Playing your voice preview.") + candidate = self.voice_context() + if self.voice_source.currentIndex() == 3: + item = self.voice_library.item(self.voice_library.currentRow(), 0) + if item is None: + return + try: + load_voice(candidate, item.data(Qt.ItemDataRole.UserRole)) + except (OSError, ValueError) as error: + self.dialog.window.error(str(error)) + return + self.dialog.start_job("Generating voice preview…", partial(synthesize, candidate, preview=True), done) + + + def voice_settings_changed(self): + if self.loading: + return + voice = copy.deepcopy(self.dialog.setting_value("voice")) + if self.voice_source.currentIndex() == 3: + self.sync(True) + return + source = self.voice_source.tabData(self.voice_source.currentIndex()) + if source != voice.source: + voice = Voice(source=source) + voice.source = source + voice.speaker = self.speaker.currentData() + voice.description = self.voice_description.text() + voice.name = voice.speaker if voice.source == "CustomVoice" else "Designed voice" if voice.source == "VoiceDesign" else "My voice" + voice.origin = "designed" if voice.source == "VoiceDesign" else voice.origin + self.dialog.edit_setting("voice", voice) + + + def refresh_library(self): + selected = self.voice_library.item(self.voice_library.currentRow(), 0) + selected = selected.data(Qt.ItemDataRole.UserRole) if selected else None + self.voice_library.setRowCount(0) + for path in library(): + try: + voice = json.loads(path.read_text(encoding="utf-8")) + languages = ", ".join(k for k in voice["references"] if k != "default") or "Multilingual" + values = (voice["name"], read_settings({"voice": voice})["voice"].label.split(":", 1)[0], languages) + except (OSError, ValueError, KeyError): + continue + if self.library_language.currentText() not in ("All languages", languages) and self.library_language.currentText() not in languages and languages != "Multilingual": + continue + row = self.voice_library.rowCount() + self.voice_library.insertRow(row) + for col, value in enumerate(values): + item = QTableWidgetItem(value) + item.setData(Qt.ItemDataRole.UserRole, str(path)) + self.voice_library.setItem(row, col, item) + self.voice_library.resizeRowsToContents() + self.voice_library.selectRow(next((row for row in range(self.voice_library.rowCount()) + if self.voice_library.item(row, 0).data(Qt.ItemDataRole.UserRole) == selected), 0)) + + + def use_saved_voice(self): + item = self.voice_library.item(self.voice_library.currentRow(), 0) + if item: + try: + candidate = self.voice_context() + load_voice(candidate, item.data(Qt.ItemDataRole.UserRole)) + self.dialog.edit_setting("voice", candidate.voice) + except (OSError, ValueError) as error: + self.dialog.window.error(str(error)) + + + def save_personal_voice(self): + name, ok = QInputDialog.getText(self, "Save reusable voice", "Voice name", text=self.dialog.setting_value("voice").name) + if not ok or not name.strip(): + return + if self.dialog.setting_value("voice").source == "VoiceDesign" and not self.accept_designed_voice(): + return + try: + save_voice(self.voice_context(), name) + self.refresh_library() + except (OSError, ValueError) as error: + self.dialog.window.error(str(error)) + + + def accept_designed_voice(self): + candidate = self.voice_context() + if candidate.voice.source != "VoiceDesign": + return False + path = preview_path(candidate) + if not path.exists(): + self.dialog.window.error("Preview this design first, then accept the voice you heard.") + return False + description = candidate.voice.description + try: + candidate.set_voice(path, preview_text(candidate)) + except (OSError, ValueError) as error: + self.dialog.window.error(str(error)) + return False + voice = copy.deepcopy(candidate.voice) + voice.name, voice.origin, voice.description = "Designed voice", "designed", description + self.dialog.edit_setting("voice", voice) + return True + + + +class CodexModelPicker(QWidget): + def __init__(self, dialog): + super().__init__() + self.dialog, self.loading = dialog, True + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0,0,0,0) + self.connection=label('') + self.layout().addWidget(self.connection) + self.layout().addWidget(button('Account settings…',lambda:dialog.preferences_requested.emit('Account','codex_signin'))) + self.codex_model=combo([('Account default','')],self.model_changed) + self.codex_effort=combo([('Codex default','')],self.model_settings_changed) + fields=form(self) + for key,editor in [('codex_model',self.codex_model),('codex_effort',self.codex_effort)]: + field=SettingField(SCHEMA[key],dialog.scope,dialog.edit_setting,editor) + dialog.fields[key]=field + fields.addRow(SCHEMA[key].label,field) + self.loading=False + + def refresh_models(self): + previous = self.loading + self.loading = True + wanted = self.dialog.setting_value("codex_model") + self.codex_model.clear() + default = next((m.get("displayName") or m["model"] for m in self.dialog.window.codex_settings.get("models", []) if m["model"] == self.dialog.window.codex_settings.get("model")), self.dialog.window.codex_settings.get("model", "")) + self.codex_model.addItem("Account default" + (f" ({default})" if default else ""), "") + for model in self.dialog.window.codex_settings.get("models", []): + if "image" in model.get("inputModalities", ["text", "image"]): + self.codex_model.addItem(model.get("displayName") or model["model"], model["model"]) + if wanted and self.codex_model.findData(wanted) < 0: + self.codex_model.addItem(wanted + " (refresh availability)", wanted) + self.codex_model.setCurrentIndex(max(0, self.codex_model.findData(wanted))) + self.refresh_efforts() + self.loading = previous + + + def refresh_efforts(self): + previous = self.loading + self.loading = True + model = self.codex_model.currentData() or self.dialog.window.codex_settings.get("model") + selected = next((m for m in self.dialog.window.codex_settings.get("models", []) if m["model"] == model), {}) + wanted = self.dialog.setting_value("codex_effort") + self.codex_effort.clear() + default = self.dialog.window.codex_settings.get("effort") or selected.get("defaultReasoningEffort", "") + self.codex_effort.addItem("Codex default" + (f" ({default})" if default else ""), "") + for option in selected.get("supportedReasoningEfforts", []): + effort = option["reasoningEffort"] + self.codex_effort.addItem(effort, effort) + self.codex_effort.setItemData(self.codex_effort.count()-1, option.get("description", ""), Qt.ItemDataRole.ToolTipRole) + if wanted and self.codex_effort.findData(wanted) < 0: + self.codex_effort.addItem(wanted + " (refresh availability)", wanted) + self.codex_effort.setCurrentIndex(max(0, self.codex_effort.findData(wanted))) + self.loading = previous + + + def model_changed(self): + if not self.loading: + self.dialog.edit_many([('codex_model',self.codex_model.currentData() or '',False),('codex_effort','',False)]) + + def model_settings_changed(self): + if not self.loading: + self.dialog.edit_setting("codex_effort", self.codex_effort.currentData() or "") + + + +class AudioAssets(QWidget): + def __init__(self, dialog): + super().__init__() + self.dialog, self.loading = dialog, False + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0,0,0,0) + if dialog.scope == 2: + self.clip_select=combo([],self.show_clip) + self.layout().addWidget(self.clip_select) + self.clip_gain=number(0,2,1,self.clip_changed) + self.clip_gain.setSingleStep(.05) + self.clip_placement=combo([('Before narration','before'),('After narration','after')],self.clip_changed) + fields=form(self) + fields.addRow('Volume',self.clip_gain) + fields.addRow('Play',self.clip_placement) + self.remove_clip_button=button('Remove',self.remove_clip) + self.layout().addLayout(row(button('Add…',self.add_clip),self.remove_clip_button)) + else: + self.background_button=button('Add background track…',self.add_background) + self.remove_background_button=button('Remove background',lambda:dialog.edit_setting('background',None)) + self.layout().addLayout(row(self.background_button,self.remove_background_button)) + + def load(self): + if self.dialog.scope!=2: + background=self.dialog.project.background + self.background_button.setText('Background: '+Path(background.file).name[:16]+'…' if background else 'Add background track…') + self.remove_background_button.setEnabled(background is not None) + return + self.loading=True + try: + selected=self.clip_select.currentIndex() + self.clip_select.clear() + self.clip_select.addItems([Path(c.file).name for c in self.dialog.slide.clips]) + self.clip_select.setCurrentIndex(min(max(0,selected),len(self.dialog.slide.clips)-1)) + finally: + self.loading=False + self.show_clip() + + def show_clip(self): + if self.loading or not self.dialog.project: + return + self.loading=True + try: + clips=self.dialog.slide.clips + index=self.clip_select.currentIndex() + valid=0<=index{html.escape(url)}' for url in p.sources) if p.sources else '') + + def read_conference(self): + url=self.dialog.project.conference_url.strip() + if not url: + self.dialog.window.error('Enter a conference website URL, or type the scope directly below.') + return + def done(result): + self.dialog.edit_many([('scope',result['scope'],False),('sources',result['sources'],False)]) + self.dialog.start_job('Reading conference website…',partial(extract_scope,url,model=self.dialog.project.codex_model,effort=self.dialog.project.codex_effort),done,project_returning=True) diff --git a/src/autotalk/settings_fields.py b/src/autotalk/settings_fields.py new file mode 100644 index 0000000..17424cd --- /dev/null +++ b/src/autotalk/settings_fields.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: MIT +"""One field builder and loader for schema rows and inspector inheritance.""" +from PySide6.QtWidgets import QWidget, QLineEdit, QPlainTextEdit, QCheckBox, QDoubleSpinBox, QHBoxLayout, QVBoxLayout +from .ui import combo, button, label + + +class SettingField(QWidget): + def __init__(self, spec, scope, edit, editor=None, compact=False): + super().__init__() + self.spec, self.scope = spec, scope + self.read_only = scope in spec.read_only_scopes + self.read, self.write = None, None + if self.read_only: + editor = label('') + elif editor is None: + editor = self.make_editor(edit) + self.editor = editor + editor.setToolTip(spec.help) + editor.setProperty('setting_key', spec.key) + editor.setProperty('setting_scope', scope) + editor.setProperty('value_editor', not self.read_only) + self.source = label('') + self.source.setWordWrap(False) + self.reset = button('Reset', lambda: edit(spec.key, None, True)) + self.reset.setToolTip('Remove this override and inherit the parent value') + box = QVBoxLayout(self) if compact else QHBoxLayout(self) + box.setContentsMargins(0, 0, 0, 0) + box.addWidget(editor, 1) + if compact: + tail = QHBoxLayout() + box.addLayout(tail) + else: + tail = box + tail.addWidget(self.source) + tail.addWidget(self.reset) + self.source.setVisible(not spec.metadata) + self.reset.setVisible(not spec.metadata) + + def make_editor(self, edit): + spec = self.spec + def changed(*_): + edit(spec.key, self.read()) + if spec.kind == 'choice': + widget = combo(spec.choices) + widget.setMinimumContentsLength(0) + widget.setSizeAdjustPolicy(widget.SizeAdjustPolicy.AdjustToContents) + self.read = widget.currentData + self.write = lambda value: widget.setCurrentIndex(widget.findData(value)) + widget.currentIndexChanged.connect(changed) + elif spec.kind == 'bool': + widget = QCheckBox() + self.read, self.write = widget.isChecked, widget.setChecked + widget.toggled.connect(changed) + elif spec.kind == 'number': + widget = QDoubleSpinBox() + widget.setRange(*spec.limits) + widget.setSuffix(spec.unit) + widget.setSingleStep(.1 if spec.key == 'pause_seconds' else 5 if spec.key == 'background_gain' else 1) + self.read = lambda: widget.value() / spec.scale + self.write = lambda value: widget.setValue(value * spec.scale) + widget.valueChanged.connect(changed) + elif spec.kind == 'multiline': + widget = QPlainTextEdit() + self.read, self.write = widget.toPlainText, widget.setPlainText + widget.textChanged.connect(changed) + else: + widget = QLineEdit() + self.read, self.write = widget.text, widget.setText + widget.textChanged.connect(changed) + return widget + + def load(self, value, source, overridden): + self.source.setText(source) + self.reset.setEnabled(overridden) + blocked = self.editor.blockSignals(True) + try: + if self.read_only: + self.editor.setText(('On' if value else 'Off') if isinstance(value,bool) else str(value)) + elif self.write and self.read() != value: + self.write(value) + finally: + self.editor.blockSignals(blocked) diff --git a/src/autotalk/settings_schema.py b/src/autotalk/settings_schema.py new file mode 100644 index 0000000..15c61eb --- /dev/null +++ b/src/autotalk/settings_schema.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: MIT +"""Presentation metadata for scoped settings; Project alone resolves values. + +Rows describe editors, not a second set of defaults. Custom owners supply voice, +sampling, shared presets and imported audio; their fields are placed only once. +""" +from dataclasses import dataclass, replace +from .project import SETTING_DEFAULTS, SETTING_CHOICES + +PAGES = ('Talk', 'Voice & language', 'Writing & delivery', 'Timing & playback', 'Audio & recording', 'AI model') +CUSTOM_OWNED = {'voice', 'delivery.sampling'} +CUSTOM_AREAS = ('delivery presets', 'clips', 'background') +ATTRIBUTES = { + 'pitch': ('Low', 'Medium', 'High'), 'texture': ('Clear', 'Warm', 'Breathy', 'Raspy'), + 'energy': ('Low', 'Moderate', 'High'), 'pace': ('Slow', 'Moderate', 'Brisk'), + 'age': ('Young adult', 'Middle-aged adult', 'Older adult'), + 'articulation': ('Natural', 'Precise', 'Relaxed'), 'projection': ('Soft', 'Conversational', 'Confident'), + 'accent': ('Beijing Mandarin', 'Sichuan Mandarin'), + 'expression': ('Restrained laughter', 'Audible sigh', 'Thoughtful hesitation', 'Enthusiastic interjection'), +} +LABELS = { + 'language_policy': ('Separate versions and mixed passages', 'Separate versions; one language per slide', 'Separate single-language versions'), + 'quick_timing': ('Fit, then start (up to three revisions)', 'Generate once, then start', 'Require timing match before start'), + 'realtime_script': ('Write the complete script first', 'Plan the deck; write ahead by slide'), + 'speech_priority': ('Consistency first', 'Earliest playback'), + 'recording_source': ('Slide video + narration', 'Screen + system audio (Linux)'), + 'recording_policy': ('Keep fullscreen pauses; omit time outside fullscreen', 'Keep all elapsed time', 'Skip pauses, buffering and time outside fullscreen'), + 'after': ('Advance automatically', 'Wait for presenter', 'Pause for live demo'), + 'export_rate': ('24 kHz (native)', '44.1 kHz', '48 kHz'), + 'export_bitrate': ('AAC 96 kbit/s', 'AAC 128 kbit/s', 'AAC 192 kbit/s'), +} + +@dataclass(frozen=True) +class Setting: + key: str + label: str + page: str + section: str + kind: str = 'choice' + scopes: tuple = () + read_only_scopes: tuple = () + choices: tuple = () + limits: tuple = (0, 100) + unit: str = '' + scale: float = 1 + metadata: bool = False + help: str = '' + order: int = 0 + + +def setting(key, label, page, section, **kw): + choices = SETTING_CHOICES.get(key, ()) + return Setting(key, label, page, section, scopes=SETTING_DEFAULTS.get(key, (None, (1,)))[1], + choices=tuple(zip(LABELS.get(key, choices), choices)), **kw) + +ROWS = [ + *(setting(key, label, 'Talk', 'Talk & conference', kind=kind, metadata=True) for key, label, kind in ( + ('title', 'Talk title', 'text'), ('audience', 'Audience', 'text'), ('objective', 'Talk objective', 'text'), + ('conference_url', 'Conference website', 'text'), ('scope', 'Conference scope', 'multiline'))), + setting('language', 'Language', 'Voice & language', 'Language', read_only_scopes=(1,), + help='Selecting a talk language restores its saved version or starts an empty version. Missing narration is generated from the slides, never translated from another version.'), + setting('language_policy', 'Language arrangement', 'Voice & language', 'Language'), + setting('writing_style', 'Writing style', 'Writing & delivery', 'Writing & delivery'), + setting('delivery.style', 'Spoken delivery', 'Writing & delivery', 'Writing & delivery'), + setting('delivery.instructions', 'Custom vocal directions', 'Writing & delivery', 'Writing & delivery', kind='text'), + setting('delivery.attributes.persona', 'Speaker background / persona', 'Writing & delivery', 'Vocal attributes', kind='text'), + setting('delivery.progression', 'Gradual delivery across slides', 'Writing & delivery', 'Vocal attributes', kind='text'), + setting('mode', 'Mode', 'Timing & playback', 'Preparation', read_only_scopes=(1,)), + setting('tolerance_seconds', 'Allowed timing difference', 'Timing & playback', 'Preparation', kind='number', limits=(0,600), unit=' sec'), + setting('quick_timing', 'Quick timing policy', 'Timing & playback', 'Preparation'), + setting('realtime_script', 'Realtime narration', 'Timing & playback', 'Preparation'), + setting('speech_priority', 'Realtime speech priority', 'Timing & playback', 'Preparation'), + setting('buffer_seconds', 'Realtime startup/refill buffer', 'Timing & playback', 'Preparation', kind='number', limits=(2,30), unit=' seconds'), + setting('pause_seconds', 'Pause after slide', 'Timing & playback', 'Playback', kind='number', limits=(0,10), unit=' sec'), + setting('after', 'After slide', 'Timing & playback', 'Playback'), + setting('record_presentation', 'Record presentation as a video', 'Audio & recording', 'Recording', kind='bool', read_only_scopes=(1,)), + setting('recording_source', 'Recording source', 'Audio & recording', 'Recording'), + setting('capture_microphone', 'Include microphone', 'Audio & recording', 'Recording', kind='bool'), + setting('recording_policy', 'Recording pauses', 'Audio & recording', 'Recording'), + setting('recording_destination', 'Video destination', 'Audio & recording', 'Recording', kind='text', metadata=True), + setting('export_rate', 'Export audio sample rate', 'Audio & recording', 'Output quality'), + setting('export_bitrate', 'MP4 / M4A audio encoding', 'Audio & recording', 'Output quality'), + setting('background_gain', 'Background volume', 'Audio & recording', 'Background audio', kind='number', limits=(0,100), unit=' %', scale=100), + setting('background_loop', 'Loop background audio', 'Audio & recording', 'Background audio', kind='bool'), + setting('codex_model', 'Model', 'AI model', 'Narration AI — Codex', kind='custom'), + setting('codex_effort', 'Reasoning effort', 'AI model', 'Narration AI — Codex', kind='custom'), +] +index = next(i for i,r in enumerate(ROWS) if r.key == 'delivery.attributes.persona') +ROWS[index:index] = [Setting('delivery.attributes.'+key, key.capitalize(), 'Writing & delivery', 'Vocal attributes', + scopes=SETTING_DEFAULTS['delivery.attributes.'+key][1], choices=(('Model default',''),)+tuple((v,v) for v in values)) for key,values in ATTRIBUTES.items()] +SCHEMA = {r.key: replace(r, order=i, scopes=(0,1) if r.key in ('after','pause_seconds') else r.scopes) for i,r in enumerate(ROWS)} +del ROWS diff --git a/src/autotalk/ui.py b/src/autotalk/ui.py index b12c25c..c8a1c3d 100644 --- a/src/autotalk/ui.py +++ b/src/autotalk/ui.py @@ -194,7 +194,7 @@ def __init__(self, window, title): self.buttons.rejected.connect(self.reject) outer.addWidget(self.buttons) - def add(self, title, page_name="", scopes=(0, 1, 2)): + def add(self, title, page_name=""): name = page_name or title matches = self.navigation.findItems(name, Qt.MatchFlag.MatchExactly) if matches: @@ -208,7 +208,6 @@ def add(self, title, page_name="", scopes=(0, 1, 2)): self.pages.addWidget(scroll) self.navigation.addItem(name) section = column(margin=0) - section.setProperty("scopes", scopes) section.layout().addWidget(label(title, "title")) body.layout().addWidget(section) return section @@ -244,23 +243,23 @@ def build(w): w.setStyleSheet(STYLE) w.actions, w.control_actions, w.dialogs = [], [], [] - from .settings import SettingsDialog - w.settings = {scope: SettingsDialog(w, scope, w.defaults, w.preferences.value("presentation_display", ""), w.begin_settings, w.finish_settings) for scope in (0, 1, 2)} + from .settings import TalkDefaultsDialog, TalkSettingsDialog, SlideSettingsDialog + from .preferences import PreferencesDialog + w.preferences = PreferencesDialog(w, w.config) + w.settings = {scope: kind(w, w.begin_settings, w.finish_settings) for scope, kind in enumerate((TalkDefaultsDialog, TalkSettingsDialog, SlideSettingsDialog))} for dialog in w.settings.values(): - dialog.application_requested.connect(lambda page, focus: w.settings[0].show_section(page, focus=focus)) + dialog.preferences_requested.connect(lambda page, focus: w.preferences.show_section(page, focus=focus)) dialog.record_requested.connect(w.record_voice) - dialog.save_requested.connect(w.save) - dialog.finished.connect(w.settings_closed) - w.settings[1].details_changed.connect(w.details_changed) - application = w.settings[0] - for requested, action in ((application.sign_in_requested, w.connect_chatgpt), - (application.sign_out_requested, lambda: w.connect_chatgpt(sign_out=True)), - (application.load_requested, w.load_gpu), (application.unload_requested, w.release_gpu), - (application.model_check_requested, w.check_model_updates), - (application.engine_policy_changed, w.update_speech_controls), - (application.test_audio_requested, w.test_audio), (application.audio_settings_requested, w.system_audio_settings), + dialog.finished.connect(w.place_progress) + for requested, action in ((w.preferences.sign_in_requested, w.connect_chatgpt), + (w.preferences.sign_out_requested, lambda: w.connect_chatgpt(sign_out=True)), + (w.preferences.load_requested, w.load_gpu), (w.preferences.unload_requested, w.release_gpu), + (w.preferences.model_check_requested, w.check_model_updates), + (w.preferences.engine_policy_changed, lambda: w.speech_preferences(True)), + (w.preferences.test_audio_requested, w.test_audio), (w.preferences.audio_settings_requested, w.system_audio_settings), (w.settings[1].fit_requested, w.fit_duration)): requested.connect(action) + w.preferences.finished.connect(w.place_progress) w.export_dialog = SectionDialog(w, "Recordings & export") exports = w.export_dialog.add("Recordings") exports.layout().addWidget(label("Saved and unfinished recordings", "title")) @@ -278,7 +277,7 @@ def build(w): exports.layout().addWidget(w.recordings, 1) w.recordings_empty = label("No recordings yet. Enable recording before presenting, or export a prepared talk below.") exports.layout().addWidget(w.recordings_empty) - exports.layout().addWidget(button("Presentation settings…", lambda: w.settings[1].show_section("Presentation & recording"))) + exports.layout().addWidget(button("Presentation settings…", lambda: w.settings[1].show_section("Audio & recording"))) w.recover_button = button("Save selected recording…", w.recover_recording) exports.layout().addWidget(w.recover_button) w.output_button = button("Open video / audio", lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(w.output_path)), True) @@ -324,7 +323,7 @@ def command(menu, title, callback, shortcut="", locked=False, control=None): w.mode.setItemData(i, explanation, Qt.ItemDataRole.ToolTipRole) w.mode.currentTextChanged.connect(w.mode_changed) toolbar.layout().addWidget(w.mode) - w.talk_action = command("Talk", "Talk settings…", lambda: w.settings[1].show_section("Talk & preparation"), "Ctrl+T") + w.talk_action = command("Talk", "Talk settings…", lambda: w.settings[1].show_section("Talk"), "Ctrl+T") talk_button = button("Talk settings…", w.talk_action.trigger) w.talk_action.changed.connect(lambda: talk_button.setEnabled(w.talk_action.isEnabled())) w.start_button = button("Start", w.start_mode, True) @@ -485,7 +484,7 @@ def command(menu, title, callback, shortcut="", locked=False, control=None): w.record_footer.layout().setDirection(QVBoxLayout.Direction.LeftToRight) w.record_footer.layout().addWidget(w.preparation) w.record_footer.layout().addWidget(w.record) - w.presentation_button = button("Presentation settings…", lambda: w.settings[1].show_section("Presentation & recording")) + w.presentation_button = button("Presentation settings…", lambda: w.settings[1].show_section("Audio & recording")) w.record_footer.layout().addWidget(w.presentation_button) summary.layout().addWidget(w.record_footer) w.view_switch.currentChanged.connect(lambda i: w.workspace.setCurrentWidget(w.presenter if i else w.quick_page if w.project and w.project.mode == "Quick" else w.editor)) @@ -514,7 +513,7 @@ def command(menu, title, callback, shortcut="", locked=False, control=None): recent_menu = menus["File"].addMenu("Open recent") def recent(): recent_menu.clear() - roots = w.preferences.value("recent_projects", []) + roots = w.config.value("recent_projects", []) for path in roots if isinstance(roots, list) else [roots]: manifest = Path(path) / "talk.autotalk.json" if manifest.exists(): @@ -563,8 +562,8 @@ def enlarge(source=w.image): command("Talk", "Create talk text", w.create_narration, control=w.talk_text_button) command("Talk", "Rewrite all talk text…", lambda: w.create_narration(rewrite=True), control=w.regenerate_button) command("Talk", "Create talk audio", w.prepare_talk, control=w.talk_audio_button) - command("Talk", "Fit duration…", w.fit_duration, control=w.settings[1].fit_button) - command("Talk", "Language options…", lambda: w.settings[1].show_section("Voice & language", focus=w.settings[1].options.fields["language_policy"]), control=talk_button) + w.fit_action = command("Talk", "Fit duration…", w.fit_duration) + command("Talk", "Language options…", lambda: w.settings[1].show_section("Voice & language", focus=w.settings[1].fields["language_policy"].editor), control=talk_button) slides_menu = menus["Talk"].addMenu("Selected slide") w.regenerate_action = command(slides_menu, "Rewrite slide text…", w.regenerate_slide, control=w.regenerate_button) command(slides_menu, "Create slide audio", w.prepare_slide, control=w.slide_audio_button) @@ -573,7 +572,7 @@ def enlarge(source=w.image): include.setCheckable(True) slides_menu.aboutToShow.connect(lambda: include.setChecked(w.inspector.slide_include.isChecked())) command(slides_menu, "Insert language passage…", w.insert_passage, control=w.regenerate_button) - command(slides_menu, "Additional audio…", lambda: w.settings[2].show_section("Presentation & recording"), control=w.regenerate_button) + command(slides_menu, "Additional audio…", lambda: w.settings[2].show_section("Audio & recording"), control=w.regenerate_button) modes = menus["Talk"].addMenu("Mode") for mode in ("Prepared", "Quick", "Realtime"): action = command(modes, mode, lambda m=mode: w.mode.setCurrentText(m), control=w.mode) @@ -590,20 +589,26 @@ def enlarge(source=w.image): w.previous_action = command("View", "Previous slide", lambda: w.navigate_slide(-1), "Ctrl+PgUp") w.next_action = command("View", "Next slide", lambda: w.navigate_slide(1), "Ctrl+PgDown") w.end_action = command("Presentation", "End presentation", w.stop_presentation, control=w.end_button) - command("Presentation", "Presentation settings…", lambda: w.settings[1].show_section("Presentation & recording"), control=w.presentation_button) + command("Presentation", "Presentation settings…", lambda: w.settings[1].show_section("Audio & recording"), control=w.presentation_button) command("Presentation", "System audio settings…", w.system_audio_settings) - command("Settings", "Application settings…", lambda: w.settings[0].show_section("Application"), "Ctrl+,") - command("Settings", "Account…", lambda: w.settings[0].show_section("Application", focus=w.settings[0].codex_signin)) - w.engine_action = command("Settings", "Speech engine…", lambda: w.settings[0].show_section("AI & speech engine", focus=w.settings[0].engine_state)) + command("Settings", "Preferences…", lambda: w.preferences.show_section(), "Ctrl+,") + command("Settings", "Talk defaults…", lambda: w.settings[0].show_section()) + command("Settings", "Account…", lambda: w.preferences.show_section("Account", focus="codex_signin")) + w.engine_action = command("Settings", "Speech engine…", lambda: w.preferences.show_section("Speech engine", focus="engine_state")) w.load_gpu_action = command("Settings", "Load speech model now", w.load_gpu) w.unload_gpu_action = command("Settings", "Unload speech model now", w.release_gpu) w.engine_status = button("Speech engine: Not loaded…", w.engine_action.trigger) w.engine_status.setMaximumWidth(330) w.statusBar().addPermanentWidget(w.engine_status) - command("Help", "Getting started…", lambda: QMessageBox.information(w, "Getting started", "Open a PDF, choose duration and language, then prepare text/audio or start. Voice & speech selects the voice; Talk settings supplies audience and conference context. The main checkbox enables recording; Presentation settings configures it. Application settings selects display and sound. File → Recordings & export finds saved results. Appearance follows the system.")) - command("Help", "Keyboard shortcuts…", lambda: QMessageBox.information(w, "Keyboard shortcuts", "Ctrl+N: Open PDF · Ctrl+O: Open saved talk · Ctrl+S: Save\nCtrl+T: Talk settings (audience and preparation) · Ctrl+,: Application settings\nF5: Start / Prepare and start · Shift+F5: Start selected · F6: Continue\nCtrl+PgUp / Ctrl+PgDn: Previous / next slide in the current view\nFullscreen: Space pauses/resumes, arrows change slides, Esc returns.")) + command("Help", "Getting started…", lambda: QMessageBox.information(w, "Getting started", "Open a PDF, choose duration and language, then prepare text/audio or start. Voice & speech selects the voice; Talk settings supplies audience and conference context. The main checkbox enables recording; Presentation settings configures it. Preferences selects display and sound. Talk defaults supplies inherited values. Scoped settings edit a draft: Save applies changes; Cancel discards them. File → Recordings & export finds saved results. Appearance follows the system.")) + command("Help", "Keyboard shortcuts…", lambda: QMessageBox.information(w, "Keyboard shortcuts", "Ctrl+N: Open PDF · Ctrl+O: Open saved talk · Ctrl+S: Save\nCtrl+T: Talk settings (audience and preparation) · Ctrl+,: Preferences\nF5: Start / Prepare and start · Shift+F5: Start selected · F6: Continue\nCtrl+PgUp / Ctrl+PgDn: Previous / next slide in the current view\nFullscreen: Space pauses/resumes, arrows change slides, Esc returns.")) command("Help", "About AutoTalk…", lambda: QMessageBox.about(w, "About AutoTalk", "AutoTalk 0.3\nPDF-to-talk preparation and presentation.\nCodex narration · Qwen3-TTS local speech\nPrepared, Quick and Realtime. System style and palette.")) + for key, control in (("target_minutes", w.minutes), ("mode", w.mode), ("language", w.language), ("record_presentation", w.record)): + control.setProperty("setting_key", key) + control.setProperty("setting_scope", 1) + control.setProperty("value_editor", True) + # Size outer controls only; the platform style owns embedded editor geometry. for widget in w.findChildren(QWidget): if isinstance(widget, (QComboBox, QLineEdit, QAbstractSpinBox)) and not isinstance(widget.parentWidget(), (QComboBox, QAbstractSpinBox)): diff --git a/tests/test_automatic_languages.py b/tests/test_automatic_languages.py index 10e8f2c..f48cbe1 100644 --- a/tests/test_automatic_languages.py +++ b/tests/test_automatic_languages.py @@ -62,18 +62,20 @@ def choose(language): def test_settings_cancel_restores_versions_and_source_edits(qtbot, project): make_audio(project) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() + stored_before = Project.load(project.manifest).versions w.narration.setPlainText('My unsaved English edit.') project.version.language = 'English' before = copy.deepcopy(project) w.defaults['language'] = 'German'; w.apply_defaults(project) dialog = w.settings[1]; dialog.show_section('Voice & language') - dialog.options.inheritance['language'][1].click() - assert len(project.versions) == 2 + dialog.fields['language'].reset.click() + assert len(w.settings[1].project.versions) == 2 assert project.versions['main'].slides[0].narration == 'My unsaved English edit.' dialog.reject() assert w.project.versions == before.versions and w.project.active_version == before.active_version assert w.narration.toPlainText() == 'My unsaved English edit.' - assert Project.load(project.manifest).versions == before.versions + assert Project.load(project.manifest).versions == stored_before + assert w.isWindowModified() def test_talk_inheritance_selects_default_version_preserving_other_language(project): diff --git a/tests/test_backend_progress.py b/tests/test_backend_progress.py index bb39e36..96367c1 100644 --- a/tests/test_backend_progress.py +++ b/tests/test_backend_progress.py @@ -104,7 +104,7 @@ def event(value): assert any('3.88 GiB' in text for _, text in seen) assert any('1 / 1 files' in text for _, text in seen) assert window.progress.maximum() == 1 and window.progress.value() == 0 - assert not window.settings[0].load_gpu_button.isEnabled() + assert not window.preferences.load_gpu_button.isEnabled() if cancel: window.cancel() else: diff --git a/tests/test_codex.py b/tests/test_codex.py index 5e22d5a..fc88674 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -176,20 +176,22 @@ def test_discovery_updates_ui_without_changing_saved_selections(fake_server, qtb window = MainWindow(); qtbot.addWidget(window); window.adopt(project) window.connect_chatgpt(interactive=False) qtbot.waitUntil(lambda: window.job is None) - assert 'Connected to ChatGPT' in window.settings[0].connection.text() - window.settings[1].show_section('AI & speech engine') - assert window.settings[1].codex_model.itemText(0) == 'Account default (Vision)' - assert window.settings[1].codex_model.currentData() == 'test-vision' - assert window.settings[1].codex_effort.currentData() == 'high' - assert window.settings[1].codex_model.isEnabled() + assert 'Connected to ChatGPT' in window.preferences.connection.text() + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.itemText(0) == 'Account default (Vision)' + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' + assert window.settings[1].models.codex_model.isEnabled() saved = Project.load(project.manifest) assert (saved.codex_model, saved.codex_effort) == ('test-vision', 'high') # A different talk reuses account discovery but retains its own unavailable selections. saved.codex_model, saved.codex_effort = 'old-model', 'old-effort' + window.settings[1].reject() window.adopt(saved) - assert window.settings[1].codex_model.currentData() == 'old-model' - assert window.settings[1].codex_effort.currentData() == 'old-effort' - assert 'refresh availability' in window.settings[1].codex_model.currentText() + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.currentData() == 'old-model' + assert window.settings[1].models.codex_effort.currentData() == 'old-effort' + assert 'refresh availability' in window.settings[1].models.codex_model.currentText() @pytest.mark.parametrize('failure', ['missing', 'offline', 'cancel']) @@ -209,7 +211,7 @@ def unavailable(task, **kwargs): qtbot.waitUntil(lambda: window.job is None) assert window.welcome.isEnabled() and not window.codex_settings.get('models') if failure != 'cancel': - assert 'Restart AutoTalk to retry' in window.settings[0].connection.text() + assert 'Restart AutoTalk to retry' in window.preferences.connection.text() window.close() @@ -224,9 +226,9 @@ def window(): def event_loop(): qtbot.waitUntil(lambda: bool(windows[0].codex_settings) and windows[0].job is None) assert bool(windows[0].project) == open_saved - windows[0].settings[1 if open_saved else 0].show_section('AI & speech engine') - assert windows[0].settings[1 if open_saved else 0].codex_model.count() == 2 - assert windows[0].settings[1 if open_saved else 0].codex_effort.findData('high') >= 0 + windows[0].settings[1 if open_saved else 0].show_section('AI model') + assert windows[0].settings[1 if open_saved else 0].models.codex_model.count() == 2 + assert windows[0].settings[1 if open_saved else 0].models.codex_effort.findData('high') >= 0 windows[0].close() return 0 project.save() @@ -249,12 +251,12 @@ def test_discovery_protocol_failure_can_be_retried_explicitly(fake_server, monke window = MainWindow(); qtbot.addWidget(window) window.connect_chatgpt(interactive=False) qtbot.waitUntil(lambda: window.job is None) - assert 'offline' in window.settings[0].connection.text() and window.welcome.isEnabled() + assert 'offline' in window.preferences.connection.text() and window.welcome.isEnabled() fake_server.write_text(healthy) window.connect_chatgpt() qtbot.waitUntil(lambda: window.job is None) - window.settings[0].show_section('AI & speech engine') - assert window.settings[0].codex_model.count() == 2 and 'Connected' in window.settings[0].connection.text() + window.settings[0].show_section('AI model') + assert window.settings[0].models.codex_model.count() == 2 and 'Connected' in window.preferences.connection.text() def test_ui_displays_configured_effort_instead_of_catalog_suggestion(fake_server, qtbot, project): @@ -265,9 +267,9 @@ def test_ui_displays_configured_effort_instead_of_catalog_suggestion(fake_server window = MainWindow(); qtbot.addWidget(window); window.adopt(project) window.connect_chatgpt(interactive=False) qtbot.waitUntil(lambda: window.job is None) - window.settings[1].show_section('AI & speech engine') - assert window.settings[1].codex_effort.currentText() == 'Codex default (high)' - assert window.settings[1].codex_effort.currentData() == '' + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_effort.currentText() == 'Codex default (high)' + assert window.settings[1].models.codex_effort.currentData() == '' assert project.codex_model == project.codex_effort == '' @@ -294,24 +296,24 @@ def test_auth_buttons_follow_account_and_logout_preserves_talk(fake_server, qtbo fake_server.write_text(healthy.replace("{'account':{'type':'chatgpt','planType':'plus'}}", "{'account':None}")) window = MainWindow(); qtbot.addWidget(window); window.adopt(project) window.connect_chatgpt(interactive=False) - assert not window.settings[0].codex_signin.isEnabled() and not window.settings[0].codex_signout.isEnabled() + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() qtbot.waitUntil(lambda: window.job is None) - assert window.settings[0].codex_signin.isEnabled() == (not signed_in) - assert window.settings[0].codex_signout.isEnabled() == signed_in + assert window.preferences.codex_signin.isEnabled() == (not signed_in) + assert window.preferences.codex_signout.isEnabled() == signed_in if not signed_in: fake_server.write_text(healthy) - qtbot.mouseClick(window.settings[0].codex_signin, Qt.MouseButton.LeftButton) + qtbot.mouseClick(window.preferences.codex_signin, Qt.MouseButton.LeftButton) qtbot.waitUntil(lambda: window.job is None) - assert not window.settings[0].codex_signin.isEnabled() and window.settings[0].codex_signout.isEnabled() + assert not window.preferences.codex_signin.isEnabled() and window.preferences.codex_signout.isEnabled() before = len(requests(fake_server)) - qtbot.mouseClick(window.settings[0].codex_signout, Qt.MouseButton.LeftButton) - assert not window.settings[0].codex_signin.isEnabled() and not window.settings[0].codex_signout.isEnabled() + qtbot.mouseClick(window.preferences.codex_signout, Qt.MouseButton.LeftButton) + assert not window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() qtbot.waitUntil(lambda: window.job is None) - assert window.settings[0].codex_signin.isEnabled() and not window.settings[0].codex_signout.isEnabled() + assert window.preferences.codex_signin.isEnabled() and not window.preferences.codex_signout.isEnabled() assert not window.codex_settings['signed_in'] and window.codex_settings['models'] == [] - window.settings[1].show_section('AI & speech engine') - assert window.settings[1].codex_model.currentData() == 'test-vision' - assert window.settings[1].codex_effort.currentData() == 'high' + window.settings[1].show_section('AI model') + assert window.settings[1].models.codex_model.currentData() == 'test-vision' + assert window.settings[1].models.codex_effort.currentData() == 'high' saved = Project.load(project.manifest) assert (saved.codex_model, saved.codex_effort) == ('test-vision', 'high') methods = [r['method'] for r in requests(fake_server)[before:]] @@ -330,15 +332,15 @@ def test_catalog_failure_keeps_account_known_and_signout_available(fake_server, window.connect_chatgpt(interactive=False) qtbot.waitUntil(lambda: window.job is None) assert window.codex_settings['signed_in'] and not window.codex_settings['models'] - assert not window.settings[0].codex_signin.isEnabled() and window.settings[0].codex_signout.isEnabled() + assert not window.preferences.codex_signin.isEnabled() and window.preferences.codex_signout.isEnabled() errors = [] monkeypatch.setattr(QMessageBox, 'warning', lambda *a: errors.append(a[-1])) fake_server.write_text(healthy.replace(" elif method=='thread/start':", """ elif method=='account/logout': send({'id':rid,'error':{'message':'Sign-out failed'}}) continue elif method=='thread/start':""")) - window.settings[0].codex_signout.click() + window.preferences.codex_signout.click() qtbot.waitUntil(lambda: window.job is None) assert errors == ['Sign-out failed'] assert window.codex_settings['signed_in'] - assert not window.settings[0].codex_signin.isEnabled() and window.settings[0].codex_signout.isEnabled() + assert not window.preferences.codex_signin.isEnabled() and window.preferences.codex_signout.isEnabled() diff --git a/tests/test_edit_navigation.py b/tests/test_edit_navigation.py index 83b3bd3..5ef3bf2 100644 --- a/tests/test_edit_navigation.py +++ b/tests/test_edit_navigation.py @@ -13,7 +13,7 @@ from autotalk.app import MainWindow from autotalk.project import Project -from autotalk.settings import SettingsDialog +from autotalk.settings import ScopedSettingsDialog from tools.bench_ui import prepared_project from test_surface_refresh import equivalent, snapshot @@ -30,7 +30,7 @@ def edit(w, field): if field == 'after': inspector.slide_after.setCurrentIndex(inspector.slide_after.findData('pause')) elif field == 'pause_seconds': - inspector.inherit_pause.setChecked(False) + inspector.slide_pause.setValue(1.2) elif field == 'budget_seconds': inspector.slide_budget.setValue(75) elif field == 'included': @@ -66,7 +66,7 @@ def test_inspector_edits_only_owned_field(qtbot, tmp_path, field): def test_edit_has_one_refresh_no_hidden_loads_and_two_readiness_passes(qtbot, tmp_path, field): w = window(qtbot, tmp_path, 60) codes = {inspect.unwrap(method).__code__: name for name, method in - [('refresh', MainWindow.refresh), ('loads', SettingsDialog.load_settings), + [('refresh', MainWindow.refresh), ('loads', ScopedSettingsDialog.load_settings), ('ready', Project.ready), ('changed', MainWindow.configuration_changed)]} counts = Counter() def trace(frame, event, arg): diff --git a/tests/test_gpu_lifecycle.py b/tests/test_gpu_lifecycle.py index 2982b81..fc94411 100644 --- a/tests/test_gpu_lifecycle.py +++ b/tests/test_gpu_lifecycle.py @@ -43,14 +43,14 @@ def finish_job(w, qtbot): def select_policy(w, name, value): - next(b for b in getattr(w.settings[0], name).buttons() if b.property('value') == value).click() + next(b for b in getattr(w.preferences, name).buttons() if b.property('value') == value).click() def save_policy(w, retention, loading='needed'): - w.settings[0].show_section("AI & speech engine") + w.preferences.show_section("Speech engine") select_policy(w, 'gpu_retention', retention) select_policy(w, 'gpu_loading', loading) - w.settings[0].buttons.button(QDialogButtonBox.StandardButton.Save).click() + w.preferences.buttons.button(QDialogButtonBox.StandardButton.Close).click() def load(w, qtbot): @@ -138,20 +138,20 @@ def test_deferred_release_finishes_owning_operation_before_unloading(engine, pro def test_saved_preferences_and_cancel_do_not_undo_manual_load(engine, project, qtbot): w = MainWindow(); qtbot.addWidget(w, before_close_func=lambda widget: finish_job(widget, qtbot)); w.adopt(project); w.show() - w.settings[0].show_section("AI & speech engine") + w.preferences.show_section("Speech engine") select_policy(w, 'gpu_retention', 'operation') - assert w.speech.retention == 'session' and 'unsaved' in w.settings[0].engine_policy_note.text() + assert w.speech.retention == 'operation' and 'immediately' in w.preferences.engine_policy_note.text() pid = load(w, qtbot) - w.settings[0].reject() - assert w.speech.process.pid == pid and w.speech.retention == 'session' + w.preferences.reject() + assert w.speech.process.pid == pid and w.speech.retention == 'operation' save_policy(w, 'operation', 'start') assert w.speech.process.pid == pid reopened = MainWindow(); qtbot.addWidget(reopened) - assert reopened.settings[0].gpu_loading.checkedButton().property('value') == 'start' + assert reopened.preferences.gpu_loading.checkedButton().property('value') == 'start' assert reopened.speech.retention == 'operation' and reopened.speech.process is None w.unload_gpu_action.trigger() qtbot.waitUntil(lambda: w.speech.process is None) - assert w.preferences.value('gpu_loading') == 'start' and w.speech.retention == 'operation' + assert w.config.value('gpu_loading') == 'start' and w.speech.retention == 'operation' @pytest.mark.parametrize('loading', ['needed', 'start']) @@ -236,13 +236,13 @@ def test_cancel_load_and_end_during_load_leave_no_worker(engine, project, qtbot) engine.with_name('delay').write_text('1') make_audio(project, seconds=3) w = MainWindow(); qtbot.addWidget(w, before_close_func=lambda widget: finish_job(widget, qtbot)); w.adopt(project); w.show() - w.settings[0].show_section("AI & speech engine"); w.settings[0].load_gpu_button.click() + w.preferences.show_section("Speech engine"); w.preferences.load_gpu_button.click() qtbot.waitUntil(lambda: w.speech.process is not None) - assert w.progress.isVisible() and w.progress.parentWidget() is w.settings[0] - w.settings[0].cancel_job.click() + assert w.progress.isVisible() and w.progress.parentWidget() is w.preferences + w.preferences.cancel_job.click() qtbot.waitUntil(lambda: w.job is None) assert w.speech.process is None and w.speech.state == 'unloaded' - w.settings[0].reject() + w.preferences.reject() save_policy(w, 'presentation', 'start'); w.present() assert w.transport.playing and w.job is not None w.stop_presentation() @@ -260,11 +260,11 @@ def operation(task): try: w.start_job('Preparing speech…', operation) qtbot.waitUntil(lambda: w.speech.state == 'in_use') - w.settings[0].show_section("AI & speech engine") - assert w.settings[0].gpu_retention.buttons()[0].isEnabled() - assert not w.settings[0].unload_gpu_button.isEnabled() + w.preferences.show_section("Speech engine") + assert w.preferences.gpu_retention.buttons()[0].isEnabled() + assert not w.preferences.unload_gpu_button.isEnabled() select_policy(w, 'gpu_retention', 'operation') - w.settings[0].accept() + w.preferences.accept() assert w.speech.process is not None and w.speech.retention == 'operation' finally: release.set(); qtbot.waitUntil(lambda: w.job is None) @@ -308,11 +308,11 @@ def test_slow_gpu_teardown_keeps_prepared_audio_and_controls_responsive(engine, position = w.transport.position w.unload_gpu_action.trigger() qtbot.waitUntil(lambda: w.speech.state == 'unloading') - w.settings[0].show_section("AI & speech engine") - assert not w.settings[0].load_gpu_button.isEnabled() and not w.settings[0].unload_gpu_button.isEnabled() + w.preferences.show_section("Speech engine") + assert not w.preferences.load_gpu_button.isEnabled() and not w.preferences.unload_gpu_button.isEnabled() qtbot.waitUntil(lambda: w.transport.position > position + .1) assert w.speech.process is not None and w.transport.playing - w.settings[0].reject() + w.preferences.reject() qtbot.waitUntil(lambda: w.speech.state == 'unloaded') assert w.transport.playing and w.presentation is not None w.stop_presentation() @@ -330,15 +330,15 @@ def test_explicit_update_check_keeps_loaded_model_and_policies(engine, project, w = MainWindow(); qtbot.addWidget(w, before_close_func=lambda widget: finish_job(widget, qtbot)) w.adopt(project); w.show(); save_policy(w, 'operation'); pid = load(w, qtbot) manifest = project.manifest.read_bytes() - w.settings[0].show_section("AI & speech engine"); w.settings[0].check_model_button.click() + w.preferences.show_section("Speech engine"); w.preferences.check_model_button.click() qtbot.waitUntil(lambda: w.job is None) assert messages and ('different upstream revision' in messages[0]) == newer assert w.speech.process.pid == pid and w.speech.retention == 'operation' assert spec['revision'] == services.speech_config(project)['model']['revision'] assert project.manifest.read_bytes() == manifest - w.settings[0].reject() + w.preferences.reject() make_audio(project, seconds=3); w.adopt(project); w.present() - assert not w.settings[0].check_model_button.isEnabled() + assert not w.preferences.check_model_button.isEnabled() w.stop_presentation() diff --git a/tests/test_language_changes.py b/tests/test_language_changes.py index c203e7f..7fb9e36 100644 --- a/tests/test_language_changes.py +++ b/tests/test_language_changes.py @@ -30,7 +30,7 @@ def test_language_change_generates_fresh_text_before_start(qtbot, project, monke else: dialog = w.settings[scope] dialog.show_section('Voice & language') - dialog.settings_language.setCurrentText('German') + dialog.fields['language'].editor.setCurrentText('German') dialog.accept() assert w.narration_progress.value() == (1 if scope == 2 else 0) assert w.slide_progress.value() == (1 if scope == 2 else 0) @@ -80,27 +80,28 @@ def test_language_cancel_and_reset_restore_readiness(qtbot, project, scope): before = copy.deepcopy(w.project.versions) selected = w.project.active_version dialog = w.settings[1]; dialog.show_section('Voice & language') - dialog.options.inheritance['language'][1].click() - assert w.project.prepared and w.narration_progress.value() == 2 + dialog.fields['language'].reset.click() + assert dialog.project.prepared and not w.project.prepared dialog.reject() assert w.project.versions == before and w.project.active_version == selected assert not w.project.prepared and not w.narration.toPlainText() dialog.show_section('Voice & language') - dialog.options.inheritance['language'][1].click() + dialog.fields['language'].reset.click() dialog.accept() assert w.project.prepared and w.narration_progress.value() == 2 return dialog = w.settings[scope] dialog.show_section('Voice & language') - dialog.settings_language.setCurrentText('German') - assert w.narration_progress.value() == (1 if scope == 2 else 0) + dialog.fields['language'].editor.setCurrentText('German') + assert w.narration_progress.value() == 2 + assert dialog.setting_value('language') == 'German' dialog.reject() assert w.project.prepared and w.narration_progress.value() == 2 assert w.narration.toPlainText() == w.project.slides[0].narration dialog.show_section('Voice & language') - dialog.settings_language.setCurrentText('German') + dialog.fields['language'].editor.setCurrentText('German') if scope: - dialog.options.inheritance['language'][1].click() + dialog.fields['language'].reset.click() assert w.project.prepared and w.narration_progress.value() == 2 dialog.accept() @@ -196,6 +197,9 @@ def test_manual_language_markers_remain_explicit(project): def test_language_arrangement_caption_follows_its_scope(qtbot, project, scope): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() dialog = w.settings[scope]; dialog.show_section('Voice & language') - container, _ = dialog.options.inheritance['language_policy'] + if scope == 2: + assert 'language_policy' not in dialog.fields + return + container = dialog.fields['language_policy'] form = next(form for form in container.parentWidget().findChildren(QFormLayout) if form.indexOf(container) >= 0) assert form.labelForField(container).isVisible() == container.isVisible() == (scope != 2) diff --git a/tests/test_model_progress.py b/tests/test_model_progress.py index fa0658b..2726ffe 100644 --- a/tests/test_model_progress.py +++ b/tests/test_model_progress.py @@ -110,7 +110,7 @@ def work(task): commands.put({'type':'model_progress','stage':'Loading model into GPU and starting speech engine'}) qtbot.waitUntil(lambda: 'Progress not reported' in w.progress.text()) assert w.progress.minimum() == 0 and w.progress.maximum() == 1 and w.progress.value() == 0 - w.settings[0].show_section("AI & speech engine") + w.settings[0].show_section("AI model") assert w.progress.parentWidget() is w.settings[0] and w.progress.isVisible() w.log_message('Codex is creating narration…'); w.update_timing() assert 'Loading model into GPU' in w.progress.text() diff --git a/tests/test_refinement.py b/tests/test_refinement.py index dc48cd8..ae9b3f3 100644 --- a/tests/test_refinement.py +++ b/tests/test_refinement.py @@ -224,16 +224,16 @@ def test_desktop_workspaces_and_remembered_policy(qtbot,project): assert w.workspace.currentWidget() is w.quick_page w.mode.setCurrentText('Realtime') assert w.workspace.currentWidget() is w.editor - w.settings[0].show_section("AI & speech engine") - next(b for b in w.settings[0].gpu_retention.buttons() if b.property('value') == 'idle').click() - assert w.speech.retention == 'session' - w.settings[0].accept() + w.preferences.show_section("Speech engine") + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value') == 'idle').click() + assert w.speech.retention == 'idle' + w.preferences.accept() assert w.speech.retention=='idle' w2=MainWindow();qtbot.addWidget(w2) - assert w2.settings[0].gpu_retention.checkedButton().property('value')=='idle' - w.settings[0].show_section("AI & speech engine") - next(b for b in w.settings[0].gpu_retention.buttons() if b.property('value') == 'session').click() - w.settings[0].accept() + assert w2.preferences.gpu_retention.checkedButton().property('value')=='idle' + w.preferences.show_section("Speech engine") + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value') == 'session').click() + w.preferences.accept() def test_selected_segmentation_invalidates_audio(project): @@ -385,7 +385,9 @@ def test_accepted_designed_voice_uses_exact_preview(qtbot, project, monkeypatch, f.setparams((1, 2, 24000, 0, 'NONE', 'not compressed')) f.writeframes(b'\1\0' * 24000 * 4) w = MainWindow(); qtbot.addWidget(w); w.adopt(project) - assert w.settings[1].accept_designed_voice() + w.settings[1].show_section('Voice & language') + assert w.settings[1].voice.accept_designed_voice() + w.settings[1].accept() assert project.voice.source == 'Base' assert file_hash(project.asset(project.reference().file)) == file_hash(preview) assert w.job is None @@ -534,24 +536,26 @@ def test_voice_library_preview_selection_and_cancel(qtbot, project, monkeypatch, project.voice.speaker = original w = MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() w.settings[1].show_section("Voice & language") - w.settings[1].voice_source.setCurrentIndex(3) - assert w.settings[1].voice_library.rowCount() == 1 - assert w.settings[1].voice_library.item(0, 0).text() == 'My conference voice' - w.settings[1].library_use.click() - assert w.project.voice.speaker == 'Aiden' + w.settings[1].voice.voice_source.setCurrentIndex(3) + assert w.settings[1].voice.voice_library.rowCount() == 1 + assert w.settings[1].voice.voice_library.item(0, 0).text() == 'My conference voice' + w.settings[1].voice.library_use.click() + assert w.settings[1].project.voice.speaker == 'Aiden' + assert w.project.voice.speaker == original assert w.settings[1].navigation.currentItem().text() == "Voice & language" - assert w.settings[1].voice_library.item(w.settings[1].voice_library.currentRow(), 0).text() == 'My conference voice' + assert w.settings[1].voice.voice_library.item(w.settings[1].voice.voice_library.currentRow(), 0).text() == 'My conference voice' w.settings[1].reject() assert w.project.voice.speaker == original def test_recording_sources_update_capabilities_and_restore_on_cancel(qtbot, project): w = MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() - w.settings[1].show_section("Presentation & recording") - w.settings[1].options.fields['recording_source'].setCurrentIndex(1) - assert project.recording_source == 'screen' - assert w.settings[1].options.fields['capture_microphone'].isEnabled() - assert not w.settings[1].options.fields['recording_policy'].isEnabled() + w.settings[1].show_section("Audio & recording") + w.settings[1].fields['recording_source'].editor.setCurrentIndex(1) + assert w.settings[1].project.recording_source == 'screen' + assert project.recording_source == 'slides' + assert w.settings[1].fields['capture_microphone'].editor.isEnabled() + assert not w.settings[1].fields['recording_policy'].editor.isEnabled() w.settings[1].reject() assert w.project.recording_source == 'slides' diff --git a/tests/test_scoped_settings.py b/tests/test_scoped_settings.py index 5b520ff..3abe779 100644 --- a/tests/test_scoped_settings.py +++ b/tests/test_scoped_settings.py @@ -7,7 +7,7 @@ import pytest from PySide6.QtCore import QSettings -from autotalk import settings +from autotalk import settings_components as settings from autotalk.app import MainWindow from autotalk.project import Project, Voice from autotalk.services import apply_narration, narration_result, synthesize, speech_config @@ -40,14 +40,15 @@ def test_precedence_empty_overrides_and_audio_identity(project): @pytest.mark.parametrize('save', [False, True]) def test_scopes_save_cancel_and_config_separation(qtbot, project, save): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - w.language.setCurrentText('French') + w.language.setCurrentText('French'); w.save() for scope, language in ((0, 'German'), (2, 'Spanish')): dialog = w.settings[scope] dialog.show_section('Voice & language') - dialog.settings_language.setCurrentText(language) + dialog.fields['language'].editor.setCurrentText(language) if scope == 2: - dialog.speaker.setCurrentIndex(dialog.speaker.findData('Aiden')) - assert w.project.setting('language', w.project.slides[0]) == 'Spanish' + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) + assert dialog.project.setting('language', dialog.project.slides[0]) == 'Spanish' + assert w.project.setting('language', w.project.slides[0]) == 'French' assert w.project.setting('language', w.project.slides[1]) == 'French' (dialog.accept if save else dialog.reject)() stored = json.loads(QSettings().value('setting_defaults', '{}')) @@ -62,14 +63,14 @@ def test_scopes_save_cancel_and_config_separation(qtbot, project, save): def test_defaults_without_talk_then_pin_portable_settings(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.show() w.settings[0].show_section('Voice & language') - w.settings[0].settings_language.setCurrentText('German') - w.settings[0].speaker.setCurrentIndex(w.settings[0].speaker.findData('Aiden')) + w.settings[0].fields['language'].editor.setCurrentText('German') + w.settings[0].voice.speaker.setCurrentIndex(w.settings[0].voice.speaker.findData('Aiden')) w.settings[0].accept() project.overrides.clear() w.adopt(project) assert project.language == 'German' and project.voice.speaker == 'Aiden' - w.settings[1].show_section('Talk & preparation') - w.settings[1].pin_settings(); w.settings[1].accept() + w.settings[1].show_section('Talk') + w.settings[1].pin(); w.settings[1].accept() reopened = Project.load(project.manifest) assert reopened.language == 'German' and reopened.voice.speaker == 'Aiden' reopened.defaults = {'language': 'French', 'voice': Voice('Ryan')} @@ -126,12 +127,12 @@ def test_designed_origin_survives_accept_library_and_reopen(qtbot, project, monk monkeypatch.setattr(settings, 'preview_path', lambda p: audio) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.settings[1].show_section('Voice & language') - assert w.settings[1].accept_designed_voice() - assert w.project.voice.source == 'Base' and w.project.voice.label.startswith('Designed:') - assert w.settings[1].voice_source.currentIndex() == 2 - assert w.settings[1].voice_description.text() == 'Calm and clear' - assert not w.settings[1].options.fields['delivery.style'].isEnabled() - assert w.settings[1].options.fields['writing_style'].isEnabled() + assert w.settings[1].voice.accept_designed_voice() + assert w.settings[1].project.voice.source == 'Base' and w.settings[1].project.voice.label.startswith('Designed:') + assert w.settings[1].voice.voice_source.currentIndex() == 2 + assert w.settings[1].voice.voice_description.text() == 'Calm and clear' + assert not w.settings[1].fields['delivery.style'].editor.isEnabled() + assert w.settings[1].fields['writing_style'].editor.isEnabled() w.settings[1].accept() reopened = Project.load(project.manifest) saved = voices.save_voice(reopened, 'Conference narrator') @@ -217,7 +218,7 @@ def test_delivery_preset_validation_is_atomic(qtbot, project, monkeypatch, tmp_p before = copy.deepcopy(project.overrides) errors = [] monkeypatch.setattr(options.QMessageBox, 'warning', lambda *args: errors.append(args[-1])) - w.settings[1].options.use_preset() + w.settings[1].presets.use_preset() assert errors and project.overrides == before w.settings[1].reject() @@ -227,9 +228,10 @@ def test_editing_reference_transcript_preserves_cursor(qtbot, project): project.set_voice(project.audio(project.slides[0]), 'Original words') w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.settings[1].show_section('Voice & language') - w.settings[1].transcript.moveCursor(w.settings[1].transcript.textCursor().MoveOperation.End) - qtbot.keyClicks(w.settings[1].transcript, ' appended') - assert w.settings[1].transcript.toPlainText() == 'Original words appended' - assert w.settings[1].transcript.textCursor().position() == len('Original words appended') - assert project.voice_transcript == 'Original words appended' + w.settings[1].voice.transcript.moveCursor(w.settings[1].voice.transcript.textCursor().MoveOperation.End) + qtbot.keyClicks(w.settings[1].voice.transcript, ' appended') + assert w.settings[1].voice.transcript.toPlainText() == 'Original words appended' + assert w.settings[1].voice.transcript.textCursor().position() == len('Original words appended') + assert w.settings[1].project.voice_transcript == 'Original words appended' + assert project.voice_transcript == 'Original words' w.settings[1].reject() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 89cf597..cc09276 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -14,58 +14,60 @@ @pytest.mark.parametrize('caption, section, scope', [ - ('Talk settings…', 'Talk & preparation', 1), + ('Talk settings…', 'Talk', 1), ('Voice && speech…', 'Voice & language', 1), - ('Presentation settings…', 'Presentation & recording', 1), + ('Presentation settings…', 'Audio & recording', 1), ('Language options…', 'Voice & language', 1), - ('Additional audio…', 'Presentation & recording', 2), - ('Application settings…', 'Application', 0), - ('Account…', 'Application', 0), - ('Speech engine…', 'AI & speech engine', 0), + ('Additional audio…', 'Audio & recording', 2), + ('Talk defaults…', 'Voice & language', 0), + ('Preferences…', 'Display & sound', None), + ('Account…', 'Account', None), + ('Speech engine…', 'Speech engine', None), ]) def test_menu_shortcuts_open_the_fixed_scope_and_target(qtbot, project, caption, section, scope): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - dialog = w.settings[scope] + dialog = w.preferences if scope is None else w.settings[scope] next(a for a in w.findChildren(QAction) if a.text() == caption).trigger() assert QApplication.activeModalWidget() is dialog assert dialog.navigation.currentItem().text() == section - assert dialog.scope == scope - assert dialog.windowTitle() == ('Application defaults' if scope == 0 else - f'Talk settings — {project.title}' if scope == 1 else 'Slide settings — 1') + if scope is not None: + assert dialog.scope == scope + assert dialog.windowTitle() == ('Preferences' if scope is None else 'Talk defaults' if scope == 0 else + f'Talk settings — {project.title}' if scope == 1 else 'Slide sound — slide 1') assert not hasattr(dialog, 'scope_selector') assert dialog.pages.currentWidget().isVisible() assert [dialog.navigation.item(i).text() for i in range(dialog.navigation.count()) if not dialog.navigation.item(i).isHidden()] == { - 0: ['Voice & language', 'Talk & preparation', 'Presentation & recording', 'AI & speech engine', 'Application'], - 1: ['Voice & language', 'Talk & preparation', 'Presentation & recording', 'AI & speech engine'], - 2: ['Voice & language', 'Presentation & recording'], + None: ['Display & sound', 'Account', 'Speech engine'], + 0: ['Voice & language', 'Writing & delivery', 'Timing & playback', 'Audio & recording', 'AI model'], + 1: ['Talk', 'Voice & language', 'Writing & delivery', 'Timing & playback', 'Audio & recording', 'AI model'], + 2: ['Voice & language', 'Writing & delivery', 'Audio & recording'], }[scope] - qtbot.mouseClick(dialog.buttons.button(QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) + qtbot.mouseClick(dialog.buttons.button(QDialogButtonBox.StandardButton.Close if scope is None else QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) def test_buttons_and_nested_account_window_preserve_independent_transactions(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - for control, section, scope in ((w.voice_button, 'Voice & language', 1), - (w.presentation_button, 'Presentation & recording', 1), - (w.engine_status, 'AI & speech engine', 0)): + for control, section, dialog in ((w.voice_button, 'Voice & language', w.settings[1]), + (w.presentation_button, 'Audio & recording', w.settings[1]), + (w.engine_status, 'Speech engine', w.preferences)): qtbot.mouseClick(control, Qt.LeftButton) - assert QApplication.activeModalWidget() is w.settings[scope] - assert w.settings[scope].navigation.currentItem().text() == section - w.settings[scope].reject() - w.talk_action.trigger() - talk = w.settings[1] - original = w.project.audience - w.settings[1].audience.setText('A changed audience') - talk.show_section('AI & speech engine') + assert QApplication.activeModalWidget() is dialog + assert dialog.navigation.currentItem().text() == section + dialog.reject() + w.talk_action.trigger(); talk = w.settings[1] + original, manifest = w.project.audience, project.manifest.read_bytes() + talk.fields['audience'].editor.setText('A changed audience') + talk.show_section('AI model') qtbot.mouseClick(next(b for b in talk.findChildren(QPushButton) if b.text() == 'Account settings…'), Qt.LeftButton) - application = w.settings[0] - assert QApplication.activeModalWidget() is application and talk.isVisible() - application.show_section('Voice & language') - application.settings_language.setCurrentText('German') - qtbot.mouseClick(application.buttons.button(QDialogButtonBox.StandardButton.Save), Qt.LeftButton) - assert w.defaults['language'] == 'German' and w.project.audience == 'A changed audience' - qtbot.mouseClick(talk.buttons.button(QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) - assert w.project.audience == original and w.defaults['language'] == 'German' - assert Project.load(project.manifest).audience == original + assert QApplication.activeModalWidget() is w.preferences and talk.isVisible() + assert w.preferences.buttons.standardButtons() == QDialogButtonBox.StandardButton.Close + w.preferences.show_section('Speech engine') + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value') == 'idle').click() + w.preferences.reject() + assert w.project.audience == original and talk.project.audience == 'A changed audience' + talk.reject() + assert w.project.audience == original and w.speech.retention == 'idle' + assert project.manifest.read_bytes() == manifest @pytest.mark.parametrize('save', [False, True]) @@ -75,15 +77,15 @@ def test_talk_save_cancel_applies_across_its_sections_only(qtbot, project, save) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() dialog = w.settings[1] w.talk_action.trigger() - w.settings[1].audience.setText('Conference speakers') + w.settings[1].fields['audience'].editor.setText('Conference speakers') dialog.show_section('Voice & language') - dialog.speaker.setCurrentIndex(dialog.speaker.findData('Aiden')) - dialog.show_section('Presentation & recording') - dialog.pause.setValue(2) - dialog.background_loop.setChecked(True) - dialog.background_gain.setValue(35) + dialog.voice.speaker.setCurrentIndex(dialog.voice.speaker.findData('Aiden')) + dialog.show_section('Audio & recording') + dialog.fields['pause_seconds'].editor.setValue(2) + dialog.fields['background_loop'].editor.setChecked(True) + dialog.fields['background_gain'].editor.setValue(35) dialog.show_section('Voice & language') - dialog.options.fields['language_policy'].setCurrentIndex(2) + dialog.fields['language_policy'].editor.setCurrentIndex(2) qtbot.mouseClick(dialog.buttons.button(QDialogButtonBox.StandardButton.Save if save else QDialogButtonBox.StandardButton.Cancel), Qt.LeftButton) reopened = Project.load(project.manifest) assert reopened.audience == ('Conference speakers' if save else original.audience) @@ -99,55 +101,52 @@ def test_talk_save_cancel_applies_across_its_sections_only(qtbot, project, save) @pytest.mark.parametrize('save', [False, True]) def test_application_display_and_model_policies_are_transactional(qtbot, project, save): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - w.settings[0].screen.addItem('Second test display', 1) - dialog = w.settings[0] - dialog.show_section('Application') - w.settings[0].screen.setCurrentIndex(1) - dialog.show_section('AI & speech engine', focus=w.settings[0].engine_state) - next(b for b in w.settings[0].gpu_retention.buttons() if b.property('value') == 'operation').click() - assert w.speech.retention == 'session' - (dialog.accept if save else dialog.reject)() - assert w.settings[0].screen.currentIndex() == (1 if save else 0) - assert w.speech.retention == ('operation' if save else 'session') + d=w.preferences; d.screen.addItem('Second test display',1);d.show_section() + d.screen.setCurrentIndex(1);d.show_section('Speech engine',focus='engine_state') + next(b for b in d.gpu_retention.buttons() if b.property('value')=='operation').click() + assert w.speech.retention=='operation' + (d.accept if save else d.reject)() + assert d.screen.currentIndex()==1 and w.speech.retention=='operation' + assert w.config.value('presentation_display')=='Second test display' def test_application_settings_work_without_a_talk(qtbot): - w = MainWindow(); qtbot.addWidget(w); w.show() - dialog = w.settings[0] - dialog.show_section() - assert dialog.navigation.currentItem().text() == 'Application' - assert dialog.scope == 0 and not hasattr(dialog, 'scope_selector') - w.settings[1].show_section(); w.settings[2].show_section() + w=MainWindow();qtbot.addWidget(w);w.show() + d=w.settings[0];d.show_section() + assert d.navigation.currentItem().text()=='Voice & language' + assert d.scope==0 and not hasattr(d,'scope_selector') + w.settings[1].show_section();w.settings[2].show_section() assert not w.settings[1].isVisible() and not w.settings[2].isVisible() - dialog.show_section('AI & speech engine', focus=w.settings[0].engine_state) - next(b for b in w.settings[0].gpu_retention.buttons() if b.property('value') == 'idle').click() - dialog.accept() - assert w.speech.retention == 'idle' and w.project is None + d.fields['language'].editor.setCurrentText('German');d.accept() + assert w.defaults['language']=='German' + w.preferences.show_section('Speech engine') + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value')=='idle').click() + w.preferences.reject();assert w.speech.retention=='idle' -def test_slide_binding_stays_pinned_and_cancel_does_not_revert_other_scopes(qtbot, project): - w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - slide = w.settings[2] - slide.show_section('Voice & language') - w.transport.select(1) # A transport update must not redirect an open editor. - slide.settings_language.setCurrentText('Spanish') - assert project.setting('language', project.slides[0]) == 'Spanish' - assert project.setting('language', project.slides[1]) == 'English' - w.settings[0].show_section('Voice & language') - w.settings[0].settings_language.setCurrentText('German') - w.settings[0].accept() - slide.reject() - assert w.defaults['language'] == 'German' - assert 'language' not in w.project.slides[0].overrides - assert w.project.setting('language', w.project.slides[1]) == 'German' +def test_slide_binding_stays_pinned_and_cancel_does_not_revert_other_scopes(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + slide=w.settings[2];slide.show_section('Voice & language') + slide.fields['language'].editor.setCurrentText('Spanish') + w.transport.select(1) + assert slide.slide_index==0 and slide.setting_value('language')=='Spanish' + w.preferences.show_section('Speech engine') + next(b for b in w.preferences.gpu_retention.buttons() if b.property('value')=='idle').click() + w.preferences.reject();slide.reject() + assert w.project.setting('language',w.project.slides[0])=='English' + assert w.project.setting('language',w.project.slides[1])=='English' + assert w.speech.retention=='idle' @pytest.mark.parametrize('scope', [0, 1]) def test_mode_edit_updates_main_window_from_resolved_setting(qtbot, project, scope): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() dialog = w.settings[scope] - dialog.show_section('Talk & preparation') - (w.mode if scope == 1 else dialog.settings_mode).setCurrentText('Quick') + if scope == 1: + w.mode.setCurrentText('Quick') + dialog.show_section('Timing & playback') + if scope == 0: + dialog.fields['mode'].editor.setCurrentText('Quick') dialog.accept() assert w.mode.currentText() == w.project.mode == 'Quick' assert w.workspace.currentWidget() is w.quick_page @@ -159,12 +158,12 @@ def test_cancel_after_background_work_preserves_completed_result(qtbot, project) updated = copy.deepcopy(project); updated.audience = 'Newly prepared context' release = threading.Event() try: - w.start_job('Preparing…', lambda task: (release.wait(3), updated)[1], w.accept_result) - w.settings[0].show_section('AI & speech engine') + w.start_job('Preparing…', lambda task: (release.wait(3), updated.save(), updated)[2], w.accept_result) + w.preferences.show_section('Speech engine') release.set(); qtbot.waitUntil(lambda: w.job is None) - w.settings[0].reject() - w.settings[1].show_section('Talk & preparation') - w.settings[1].audience.setText('Unwanted later edit') + w.preferences.reject() + w.settings[1].show_section('Talk') + w.settings[1].fields['audience'].editor.setText('Unwanted later edit') w.settings[1].reject() assert w.project.audience == 'Newly prepared context' assert Project.load(project.manifest).audience == 'Newly prepared context' @@ -188,9 +187,9 @@ def test_main_duration_persists_independently_of_talk_dialog(qtbot, project, mod w.save() w.talk_action.trigger() assert not hasattr(w, 'settings_minutes') - w.settings[1].audience.setText('A different audience') + w.settings[1].fields['audience'].editor.setText('A different audience') w.settings[1].show_section('Voice & language') - w.settings[1].show_section('Talk & preparation') + w.settings[1].show_section('Talk') for dialog in w.settings.values(): assert spin not in dialog.findChildren(type(spin)) (w.settings[1].accept if save else w.settings[1].reject)() @@ -205,11 +204,12 @@ def test_clip_controls_stay_bound_to_the_open_slide(qtbot, project): slide.clips = [Clip(slide.audio_file, slide.audio_sha256, slide.duration)] w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() dialog = w.settings[2] - dialog.show_section('Presentation & recording') + dialog.show_section('Audio & recording') w.transport.select(1) - w.settings[2].clip_gain.setFocus(); w.settings[2].clip_gain.selectAll(); qtbot.keyClicks(w.settings[2].clip_gain, '0.5') - qtbot.keyClick(w.settings[2].clip_gain, Qt.Key.Key_Tab) - assert w.project.slides[0].clips[0].gain == .5 + w.settings[2].assets.clip_gain.setFocus(); w.settings[2].assets.clip_gain.selectAll(); qtbot.keyClicks(w.settings[2].assets.clip_gain, '0.5') + qtbot.keyClick(w.settings[2].assets.clip_gain, Qt.Key.Key_Tab) + assert dialog.project.slides[0].clips[0].gain == .5 + assert w.project.slides[0].clips[0].gain == 1 assert w.project.slides[1].clips[0].gain == 1 dialog.accept() saved = Project.load(project.manifest) diff --git a/tests/test_settings_drafts.py b/tests/test_settings_drafts.py new file mode 100644 index 0000000..be7983e --- /dev/null +++ b/tests/test_settings_drafts.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: MIT +"""Settings edits are isolated; Save is the sole document commit boundary.""" +import copy +import random +from dataclasses import asdict +from pathlib import Path +from unittest.mock import Mock + +import pytest +from PySide6.QtWidgets import QDialogButtonBox, QWidget +from autotalk.app import MainWindow +from autotalk.project import Project, SETTING_DEFAULTS, Voice, Clip +from autotalk.settings import apply_edit +from autotalk.settings_schema import SCHEMA, CUSTOM_OWNED, PAGES +from conftest import make_audio + + +def snapshot(project): + return copy.deepcopy(asdict(project)) + + +def test_schema_complete_and_scopes(): + assert set(SETTING_DEFAULTS) == (set(SCHEMA) & set(SETTING_DEFAULTS)) | CUSTOM_OWNED + assert not CUSTOM_OWNED & set(SCHEMA) + for key, spec in SCHEMA.items(): + if key in SETTING_DEFAULTS: + assert set(spec.scopes) <= set(SETTING_DEFAULTS[key][1]) + + +@pytest.mark.parametrize('scope',[0,1,2]) +def test_edit_isolated_and_cancel_never_saves(qtbot,project,monkeypatch,scope): + project.save() + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + w.minutes.setValue(7) + before, defaults = snapshot(project), copy.deepcopy(w.defaults) + manifest, mtime = project.manifest.read_bytes(), project.manifest.stat().st_mtime_ns + d=w.settings[scope];d.show_section() + refresh=Mock(wraps=w.refresh);changed=Mock(wraps=w.configuration_changed) + monkeypatch.setattr(w,'refresh',refresh);monkeypatch.setattr(w,'configuration_changed',changed) + from test_surface_refresh import snapshot as widget_snapshot + def main_state(): + widgets = [w] + w.findChildren(QWidget) + return [state for widget, state in zip(widgets, widget_snapshot(w)) if QWidget.window(widget) is w] + ui_before = main_state() + d.edit_setting('delivery.style','Conversational') + d.edit_setting('voice',Voice(speaker='Aiden')) + assert d.setting_value('voice').speaker=='Aiden' + assert snapshot(project)==before and w.defaults==defaults + assert main_state() == ui_before + assert refresh.call_count==changed.call_count==0 + d.reject() + assert refresh.call_count==changed.call_count==0 + assert snapshot(project)==before and w.defaults==defaults and w.isWindowModified() + assert project.manifest.read_bytes()==manifest and project.manifest.stat().st_mtime_ns==mtime + + +@pytest.mark.parametrize('scope',[0,1,2]) +def test_save_one_refresh_mix_and_write(qtbot,project,monkeypatch,scope): + make_audio(project) + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[scope];d.show_section() + d.edit_setting('delivery.style','Conversational') + refresh=Mock(wraps=w.refresh);mix=Mock(wraps=w.transport.refresh_audio) + save=Mock(wraps=project.save);config=Mock(wraps=w.config.setValue) + monkeypatch.setattr(w,'refresh',refresh);monkeypatch.setattr(w.transport,'refresh_audio',mix) + monkeypatch.setattr(project,'save',save);monkeypatch.setattr(w.config,'setValue',config) + d.accept() + assert not d.isVisible() + assert refresh.call_count==mix.call_count==1 + assert save.call_count==(0 if scope==0 else 1) + assert config.call_count==(1 if scope==0 else 0) + assert project.setting('delivery.style',project.slides[0] if scope==2 else None)=='Conversational' + assert ('audio needs updating' in w.status.text()) == (scope != 2) + + +@pytest.mark.parametrize('seed',range(8)) +def test_replay_matches_individual_edits(qtbot,project,seed): + make_audio(project) + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section() + expected=copy.deepcopy(project) + import shutil + media=project.root/'media/test.wav';media.parent.mkdir();shutil.copy2(project.audio(project.slides[0]),media) + rng=random.Random(seed) + choices=[('language','German',False),('language','English',False),('language',None,True), + ('voice',Voice(speaker='Aiden'),False),('voice',None,True), + ('delivery.style','Conversational',False),('delivery.attributes.energy','High',False), + ('pause_seconds',0,False),('pause_seconds',None,True), + ('scope','Conference context',False),('sources',['https://example.org'],False), + ('background',Clip('media/test.wav','123',1),False),('background',None,False)] + for _ in range(25): + operation=copy.deepcopy(rng.choice(choices)) + d.edit_setting(*operation) + key,value,inherit=operation + if key in SETTING_DEFAULTS: + expected.set_setting(key,value,inherit=inherit) + else: + setattr(expected,key,value) + # New version ids are random; compare per-language content, not uuid spelling. + assert d.project.language==expected.language + def versions(p): + return sorted((v.language,asdict(v)) for v in p.versions.values()) + assert versions(d.project)==versions(expected) + draft=snapshot(d.project) + d.accept() + assert versions(project)==sorted((v['language'],v) for v in draft['versions'].values()) + def normalized(p): + state = snapshot(p) + state['active_version'] = p.language + state['versions'] = dict(versions(p)) + return state + assert normalized(project) == normalized(expected) + + +def test_pin_and_clip_edits_are_logged(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section();d.pin() + assert not project.overrides.get('pause_seconds') + d.accept() + assert all(key in project.overrides or key=='language' for key,(_,scopes) in SETTING_DEFAULTS.items() if 1 in scopes) + d=w.settings[2];d.show_section('Audio & recording') + make_audio(project) + clip=Clip(project.slides[0].audio_file,project.slides[0].audio_sha256,.3) + d.edit_setting('clips',[clip]);d.assets.clip_gain.setValue(.5) + assert not project.slides[0].clips and d.slide.clips[0].gain==.5 + d.accept() + assert project.slides[0].clips[0].gain==.5 + + +def test_stale_save_refused(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section();d.edit_setting('audience','Draft audience') + updated=copy.deepcopy(project);updated.audience='New result' + w.accept_result(updated);d.accept() + assert d.isVisible() and 'changed while' in d.status.text() + assert w.project.audience=='New result' + d.reject() + + +def test_cancel_cleans_only_session_media(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + old=project.root/'media/old.wav';old.parent.mkdir();old.write_bytes(b'old') + d=w.settings[1];d.show_section() + new=project.root/'media/new.wav';new.write_bytes(b'new') + shared=project.root/'media/shared.wav';shared.write_bytes(b'shared') + project.background=Clip('media/shared.wav','123',1) + d.edit_setting('background',Clip('media/new.wav','456',1));d.reject() + assert old.exists() and shared.exists() and not new.exists() + + +def test_project_job_blocks_save_but_not_cancel(qtbot,project,monkeypatch): + import threading + project.save();before=project.manifest.read_bytes();mtime=project.manifest.stat().st_mtime_ns + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section();release=threading.Event() + try: + d.start_job('Reading conference…',lambda task:(release.wait(2),{'scope':'draft'})[1], + lambda result:d.edit_setting('scope',result['scope']),project_returning=True) + assert not d.buttons.button(QDialogButtonBox.StandardButton.Save).isEnabled() + assert d.buttons.button(QDialogButtonBox.StandardButton.Cancel).isEnabled() + d.reject();assert not d.isVisible() + finally: + release.set();qtbot.waitUntil(lambda:w.job is None) + assert project.manifest.read_bytes()==before and project.manifest.stat().st_mtime_ns==mtime + assert project.scope!='draft' + + +def test_page_order_and_inheritance(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + for scope,d in w.settings.items(): + d.show_section() + names=[d.navigation.item(i).text() for i in range(d.navigation.count())] + assert names==[page for page in PAGES if page in names] + for key,field in d.fields.items(): + if key in SETTING_DEFAULTS: + assert field.source.text() in ('from app','from talk','set here','built-in') + assert field.reset.text()=='Reset' + assert 'after' not in d.fields and 'pause_seconds' not in d.fields if scope==2 else 'mode' in d.fields + d.reject() + for field in (w.inspector.after_field,w.inspector.pause_field): + assert field.reset.text()=='Reset' and field.editor.isEnabled() + + +def test_preferences_immediate_close_only(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.preferences;d.show_section() + assert d.buttons.standardButtons()==QDialogButtonBox.StandardButton.Close + d.screen.addItem('Other display',1);d.screen.setCurrentIndex(1) + d.show_section('Speech engine') + next(b for b in d.gpu_retention.buttons() if b.property('value')=='idle').click() + d.reject() + assert w.config.value('gpu_retention')=='idle' and w.speech.retention=='idle' + assert w.config.value('presentation_display')=='Other display' + + +def test_every_setting_has_one_editor_per_scope(qtbot,project): + from collections import Counter + w=MainWindow();qtbot.addWidget(w);w.adopt(project) + owners=Counter((widget.property('setting_key'),widget.property('setting_scope')) + for widget in w.findChildren(QWidget) if widget.property('value_editor')) + for key,(_,scopes) in SETTING_DEFAULTS.items(): + for scope in scopes: + assert owners[key,scope]==1,(key,scope,owners[key,scope]) + assert all(count==1 for count in owners.values()) + assert owners['target_minutes',1]==1 + + +@pytest.mark.parametrize('scope',[0,1,2]) +def test_reference_import_cancel_preserves_library_and_existing_files(qtbot,project,tmp_path,scope,monkeypatch): + import wave + from autotalk import voices, app + monkeypatch.setattr(app,'data_dir',lambda:tmp_path/'data') + monkeypatch.setattr(voices,'data_dir',lambda:tmp_path/'library') + ref=tmp_path/'reference.wav' + with wave.open(str(ref),'wb') as audio: + audio.setparams((1,2,24000,0,'NONE','not compressed'));audio.writeframes(b'\x01\0'*4*24000) + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[scope];d.show_section();before=project.manifest.read_bytes() + d.voice.set_voice(ref) + path=d.project.asset(d.setting_value('voice').references['default'].file) + assert path.is_file() + voices.save_voice(d.voice.voice_context(),'Keep in library') + d.reject() + assert not path.exists() and list((tmp_path/'library').rglob('*.wav')) + assert project.manifest.read_bytes()==before + + +@pytest.mark.parametrize('kind',['clip','background','conference']) +@pytest.mark.parametrize('accept',[False,True]) +def test_dialog_jobs_only_commit_on_save(qtbot,project,tmp_path,monkeypatch,kind,accept): + from autotalk import settings_components as components + make_audio(project) + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[2 if kind=='clip' else 1];d.show_section() + before=project.manifest.read_bytes();mtime=project.manifest.stat().st_mtime_ns + if kind=='conference': + d.edit_setting('conference_url','https://example.org') + monkeypatch.setattr(components,'extract_scope',lambda *a,**kw:{'scope':'Draft scope','sources':['https://example.org']}) + d.conference.read_conference() + else: + monkeypatch.setattr(components.QFileDialog,'getOpenFileName',lambda *a,**kw:(str(project.audio(project.slides[0])),'')) + (d.assets.add_clip if kind=='clip' else d.assets.add_background)() + qtbot.waitUntil(lambda:w.job is None,timeout=10000) + assert project.manifest.read_bytes()==before and project.manifest.stat().st_mtime_ns==mtime + assert not project.background and not project.slides[0].clips and project.scope!='Draft scope' + (d.accept if accept else d.reject)() + loaded=Project.load(project.manifest) + actual=loaded.slides[0].clips if kind=='clip' else loaded.background if kind=='background' else loaded.scope=='Draft scope' + assert bool(actual)==accept + if not accept: + assert project.manifest.read_bytes()==before and project.manifest.stat().st_mtime_ns==mtime + assert not list(project.root.glob('media/*.wav')) + + +def test_cancelled_late_import_cannot_edit_reopened_draft(qtbot,project): + import threading + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section();release=threading.Event() + media=project.root/'media/late.wav';media.parent.mkdir() + def worker(task): + release.wait(2);media.write_bytes(b'late');return Clip('media/late.wav','123',1) + try: + d.start_job('Importing…',worker,lambda clip:d.edit_setting('background',clip),project_returning=True) + d.reject();d.show_section();d.edit_setting('audience','Next draft') + finally: + release.set();qtbot.waitUntil(lambda:w.job is None) + assert d.project.audience=='Next draft' and d.project.background is None + assert not media.exists() + assert d.buttons.button(QDialogButtonBox.StandardButton.Save).isEnabled() + d.reject() + + +def test_failed_save_can_be_retried_without_a_stale_generation(qtbot,project,monkeypatch): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[1];d.show_section();d.edit_setting('audience','Retry me') + save=project.save;errors=[] + monkeypatch.setattr(w,'error',errors.append) + def fail(): raise OSError('disk unavailable') + monkeypatch.setattr(project,'save',fail) + d.accept();assert d.isVisible() and errors + monkeypatch.setattr(project,'save',save) + d.accept() + assert not d.isVisible() and Project.load(project.manifest).audience=='Retry me' + + +@pytest.mark.parametrize('scope,caption,page,focus',[ + (0,'Account settings…','Account','codex_signin'),(1,'Account settings…','Account','codex_signin'), + (0,'Speech engine settings…','Speech engine','engine_state'),(1,'Speech engine settings…','Speech engine','engine_state'), + (0,'Display & sound settings…','Display & sound','screen'),(1,'Display & sound settings…','Display & sound','screen')]) +def test_scoped_links_open_preferences_not_another_transaction(qtbot,project,scope,caption,page,focus): + from PySide6.QtWidgets import QApplication,QPushButton + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.settings[scope];d.show_section('AI model' if page!='Display & sound' else 'Timing & playback') + d.edit_setting('delivery.style','Storytelling') + next(b for b in d.findChildren(QPushButton) if b.text()==caption).click() + assert QApplication.activeModalWidget() is w.preferences and d.isVisible() + assert w.preferences.navigation.currentItem().text()==page + assert getattr(w.preferences,focus).isVisible() + assert d.setting_value('delivery.style')=='Storytelling' and project.delivery.style!='Storytelling' + w.preferences.reject();d.reject() + + +@pytest.mark.parametrize('page',['Display & sound','Account','Speech engine']) +def test_navigation_shortcuts_do_not_cross_preferences(qtbot,project,page): + from PySide6.QtCore import Qt + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + d=w.preferences;d.show_section(page) + qtbot.keyClick(d,Qt.Key_PageDown,Qt.ControlModifier) + w.next_action.trigger() + assert w.transport.index==0 + d.reject() diff --git a/tests/test_start_interaction.py b/tests/test_start_interaction.py index bbbfb4b..9650d51 100644 --- a/tests/test_start_interaction.py +++ b/tests/test_start_interaction.py @@ -110,7 +110,7 @@ def test_audio_preview_restores_play_label_without_refresh(qtbot, project, endin if ending == 'stop': w.preview_button.click() qtbot.waitUntil(lambda: not w.transport.preview_path, timeout=5000) assert w.preview_button.text() == w.preview_action.text() == 'Play audio' - assert w.settings[1].voice_preview_button.text() == 'Listen to a sample' + assert w.settings[1].voice.voice_preview_button.text() == 'Listen to a sample' assert w.transport.state == 'stopped' assert w.preview_button.isEnabled() assert 'Presentation finished' not in w.duration_label.text() @@ -119,7 +119,7 @@ def test_audio_preview_restores_play_label_without_refresh(qtbot, project, endin def test_dialog_labels_have_ellipses_and_direct_actions_do_not(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.adopt(project) labels = {a.text() for a in w.findChildren(QAction)} | {b.text() for b in w.findChildren(QPushButton)} - for text in ('Open PDF', 'Open saved talk', 'Talk settings', 'Application settings', 'Save delivery preset', + for text in ('Open PDF', 'Open saved talk', 'Talk settings', 'Preferences', 'Save delivery preset', 'Rewrite slide text', 'Fit duration', 'Getting started', 'Keyboard shortcuts', 'About AutoTalk'): assert text + '…' in labels and text not in labels for text in ('Create slide audio', 'Play audio', 'Prepare and start', 'Save', 'Operation details'): @@ -133,11 +133,12 @@ def test_dialog_labels_have_ellipses_and_direct_actions_do_not(qtbot, project): def test_account_default_and_explicit_model_use_identical_name(qtbot, display): w = MainWindow(); qtbot.addWidget(w) w.codex_settings = {'model': 'gpt-6-astra', 'models': [{'model': 'gpt-6-astra', 'displayName': display}]} - w.settings[1].refresh_models() + w.settings[0].show_section('AI model') + w.settings[0].models.refresh_models() name = display or 'gpt-6-astra' - assert w.settings[1].codex_model.itemText(0) == f'Account default ({name})' - assert w.settings[1].codex_model.itemText(1) == name - assert w.settings[1].codex_model.itemData(0) == '' and w.settings[1].codex_model.itemData(1) == 'gpt-6-astra' + assert w.settings[0].models.codex_model.itemText(0) == f'Account default ({name})' + assert w.settings[0].models.codex_model.itemText(1) == name + assert w.settings[0].models.codex_model.itemData(0) == '' and w.settings[0].models.codex_model.itemData(1) == 'gpt-6-astra' def test_save_shortcut_persists_edited_narration(qtbot, project): diff --git a/tests/test_surface_refresh.py b/tests/test_surface_refresh.py index 1969a06..bf27092 100644 --- a/tests/test_surface_refresh.py +++ b/tests/test_surface_refresh.py @@ -63,7 +63,7 @@ def test_incremental_journey_matches_full_refresh(qtbot, project, monkeypatch, m w.inspector.slide_include.setChecked(False); equivalent(w) w.inspector.slide_include.setChecked(True); equivalent(w) w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.findData('pause')); equivalent(w) - w.inspector.inherit_pause.setChecked(False); w.inspector.slide_pause.setValue(0); equivalent(w) + w.inspector.slide_pause.setValue(0); equivalent(w) w.language.setCurrentIndex(w.language.findData('German')); equivalent(w) w.job = SimpleNamespace(title='Fake preparation', outcome='completed', deleteLater=lambda: None) w.job_started = __import__('time').monotonic() diff --git a/tests/test_ui.py b/tests/test_ui.py index b12ed97..c50412e 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -34,7 +34,9 @@ def test_recording_selection_visible_in_workspaces_and_persisted(qtbot, project) window.transport.state = 'paused' window.refresh() assert not checkbox.isEnabled() - assert not window.settings[1].options.recording_widget.isEnabled() + window.settings[1].show_section('Audio & recording') + assert not window.settings[1].fields['recording_source'].editor.isEnabled() + window.settings[1].reject() window.transport.stop() window.refresh() assert checkbox.isEnabled() @@ -241,33 +243,28 @@ def test_settings_open_and_save_preserve_selected_slide(qtbot, project): window.talk_action.trigger() assert window.settings[1].isVisible() assert window.project is original and window.transport.index == 1 - window.minutes.setValue(14) + window.settings[1].fields['audience'].editor.setText('New audience') qtbot.mouseClick(window.settings[1].buttons.button(QDialogButtonBox.StandardButton.Save), Qt.MouseButton.LeftButton) assert not window.settings[1].isVisible() assert window.project is original and window.transport.index == 1 from autotalk.project import Project - assert Project.load(project.manifest).target_minutes == 14 + assert Project.load(project.manifest).audience == 'New audience' def test_cancel_settings_restores_project_and_manifest_after_eager_save(qtbot, project, monkeypatch): from autotalk.project import Project - window = MainWindow() - qtbot.addWidget(window) - window.adopt(project) - window.show() + window=MainWindow();qtbot.addWidget(window);window.adopt(project);window.show() window.transport.select(1) - window.settings[1].show_section("Talk & preparation") - window.minutes.setValue(19) - window.settings[1].conference_scope.setPlainText('A different conference') - window.language.setCurrentText('German') - window.save() # Existing language/reference/worker handlers can save eagerly. + window.minutes.setValue(19);window.language.setCurrentText('German');window.save() + manifest=project.manifest.read_bytes();mtime=project.manifest.stat().st_mtime_ns + window.settings[1].show_section('Talk') + window.settings[1].fields['scope'].editor.setPlainText('A different conference') window.settings[1].reject() - restored = Project.load(project.manifest) - assert restored.target_minutes == 10 and restored.language == 'English' - assert restored.scope != 'A different conference' - assert len(restored.versions) == 1 - assert window.project.language == 'English' and window.transport.index == 1 - assert window.settings[1].options.project is window.project + restored=Project.load(project.manifest) + assert restored.target_minutes==19 and restored.language=='German' + assert restored.scope!='A different conference' and len(restored.versions)==2 + assert project.manifest.read_bytes()==manifest and project.manifest.stat().st_mtime_ns==mtime + assert window.project.language=='German' and window.transport.index==1 assert window.transport.project is window.project @@ -276,16 +273,16 @@ def test_preferences_cancel_restores_retention_and_sampling(qtbot, project): qtbot.addWidget(window) window.adopt(project) window.show() - original = window.settings[0].gpu_retention.checkedButton().property("value") - window.settings[0].show_section("AI & speech engine") - next(b for b in window.settings[0].gpu_retention.buttons() if b.property('value') == 'operation').click() - assert window.speech.retention == original # Draft policies do not act before Save. + original = window.preferences.gpu_retention.checkedButton().property("value") + window.settings[0].show_section("AI model") + next(b for b in window.preferences.gpu_retention.buttons() if b.property('value') == 'operation').click() + assert window.speech.retention == 'operation' # Preferences apply immediately. window.settings[0].reject() - assert window.settings[0].gpu_retention.checkedButton().property("value") == original - assert window.speech.retention == original - window.settings[1].show_section("AI & speech engine") - window.settings[1].options.override.setChecked(True) - window.settings[1].options.sampling['temperature'].setValue(.4) + assert window.preferences.gpu_retention.checkedButton().property("value") == "operation" + assert window.speech.retention == "operation" + window.settings[1].show_section("AI model") + window.settings[1].voice.override.setChecked(True) + window.settings[1].voice.sampling['temperature'].setValue(.4) window.settings[1].reject() assert window.project.delivery.sampling == {} @@ -302,13 +299,13 @@ def test_quick_keeps_basics_in_main_window_and_cancel_restores_settings(qtbot, p window.minutes.setValue(7) assert project.target_minutes == 7 parent = window.minutes.parentWidget() - window.settings[1].show_section("Talk & preparation") + window.settings[1].show_section("Talk") assert window.minutes.parentWidget() is parent window.minutes.setValue(9) window.settings[1].reject() qtbot.wait(30) assert window.minutes.parentWidget() is parent - assert window.minutes.value() == window.project.target_minutes == 7 + assert window.minutes.value() == window.project.target_minutes == 9 assert window.language.isVisible() from PySide6.QtCore import QPoint bottom = window.language.mapTo(window, QPoint(0, window.language.height())) @@ -322,20 +319,20 @@ def test_commands_and_dialogs_cannot_bypass_job_or_playback_lock(qtbot, project) make_audio(project) window.adopt(project) window.show() - window.settings[1].show_section("AI & speech engine") + window.settings[1].show_section("AI model") release = Event() window.start_job('Checking command availability', lambda task: release.wait(5)) try: assert not window.talk_action.isEnabled() assert window.narration.isReadOnly() - assert not window.settings[1].codex_model.isEnabled() and not window.settings[1].options.override.isEnabled() + assert not window.settings[1].models.codex_model.isEnabled() and not window.settings[1].voice.override.isEnabled() assert not window.more_presentation.isEnabled() assert not window.welcome.isEnabled() assert all(not a.isEnabled() for a in window.actions) for action, control in window.control_actions: assert action.isEnabled() == control.isEnabled() window.settings[1].reject() - assert window.settings[1].isVisible() + assert not window.settings[1].isVisible() finally: release.set() qtbot.waitUntil(lambda: window.job is None) @@ -357,10 +354,10 @@ def test_minimum_editor_layout_keeps_inspector_controls_separate(qtbot, project) window.show() qtbot.wait(30) from PySide6.QtCore import QPoint - window.settings[2].show_section("Presentation & recording") + window.settings[2].show_section("Audio & recording") qtbot.wait(30) - gain_bottom = window.settings[2].clip_gain.mapToGlobal(QPoint(0, window.settings[2].clip_gain.height())) - placement_top = window.settings[2].clip_placement.mapToGlobal(QPoint(0, 0)) + gain_bottom = window.settings[2].assets.clip_gain.mapToGlobal(QPoint(0, window.settings[2].assets.clip_gain.height())) + placement_top = window.settings[2].assets.clip_placement.mapToGlobal(QPoint(0, 0)) assert placement_top.y() >= gain_bottom.y() assert window.image.height() >= 100 assert window.narration.height() >= 100 @@ -372,10 +369,11 @@ def test_recording_checkbox_stays_in_main_window_and_dialog_cancel_restores_sett checkbox.click() assert window.project.record_presentation parent = checkbox.parentWidget() - window.settings[1].show_section("Presentation & recording") + window.settings[1].show_section("Audio & recording") assert checkbox.parentWidget() is parent and checkbox.isVisible() - window.settings[1].options.fields['recording_source'].setCurrentIndex(1) - assert window.project.recording_source == 'screen' + window.settings[1].fields['recording_source'].editor.setCurrentIndex(1) + assert window.settings[1].project.recording_source == 'screen' + assert window.project.recording_source == 'slides' window.settings[1].reject() assert window.project.record_presentation and window.project.recording_source == 'slides' assert checkbox.parentWidget() is parent and checkbox.isVisible() diff --git a/tests/test_ui_ownership.py b/tests/test_ui_ownership.py index 009c80f..72223ca 100644 --- a/tests/test_ui_ownership.py +++ b/tests/test_ui_ownership.py @@ -17,7 +17,7 @@ def test_component_controller_allowlist(): - for name in ('settings', 'options', 'inspector'): + for name in ('settings', 'settings_components', 'settings_fields', 'options', 'inspector', 'preferences'): tree = ast.parse(Path(f'src/autotalk/{name}.py').read_text()) for function in (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)): aliases = {'window'} if name != 'options' else set() @@ -65,11 +65,13 @@ def test_cancel_restores_clip_gain_and_placement(qtbot, project): original = copy.deepcopy(project.slides) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() dialog = w.settings[2] - dialog.show_section('Presentation & recording') - dialog.clip_gain.setValue(.5) - dialog.clip_placement.setCurrentIndex(dialog.clip_placement.findData('after')) - assert w.project.slides[0].clips[0].gain == .5 - assert w.project.slides[0].clips[0].placement == 'after' + dialog.show_section('Audio & recording') + dialog.assets.clip_gain.setValue(.5) + dialog.assets.clip_placement.setCurrentIndex(dialog.assets.clip_placement.findData('after')) + assert dialog.project.slides[0].clips[0].gain == .5 + assert w.project.slides[0].clips[0].gain == 1 + assert dialog.project.slides[0].clips[0].placement == 'after' + assert w.project.slides[0].clips[0].placement == 'before' dialog.reject() assert w.project.slides == original assert Project.load(project.manifest).slides == original @@ -77,17 +79,23 @@ def test_cancel_restores_clip_gain_and_placement(qtbot, project): def test_tab_order_matches_base(qtbot): import json - expected = json.loads(Path('docs/reviews/evidence/2026-09-30-ui-structure-perf/base-tab-order.json').read_text()) + expected = json.loads(Path('docs/reviews/evidence/2026-10-01-settings-refactor/settings-tab-order.json').read_text()) w = MainWindow(); qtbot.addWidget(w) for scope, dialog in w.settings.items(): - names = {v: k.replace('conference_scope', 'scope') for k, v in dialog.__dict__.items() - if isinstance(v, QWidget) and QWidget.window(v) is dialog} - names.update({v: 'field:' + k for k, v in dialog.options.fields.items()}) - names.update({v[1]: 'reset:' + k for k, v in dialog.options.inheritance.items()}) - actual = [] - child = dialog.nextInFocusChain() - while child is not dialog: - if child in names and child.focusPolicy() & Qt.FocusPolicy.TabFocus: - actual.append(names[child]) - child = child.nextInFocusChain() - assert actual == expected[str(scope)] + assert tab_order(dialog) == expected[str(scope)] + + +def tab_order(dialog): + names = {} + for prefix, owner in [('', dialog)] + [(name+'.', getattr(dialog,name)) for name in ('voice','models','assets','conference','presets') if hasattr(dialog,name)]: + names.update({value: prefix+key for key,value in owner.__dict__.items() + if isinstance(value,QWidget) and value is not dialog and QWidget.window(value) is dialog}) + names.update({field.editor:'field:'+key for key,field in dialog.fields.items()}) + names.update({field.reset:'reset:'+key for key,field in dialog.fields.items()}) + actual=[] + child=dialog.nextInFocusChain() + while child is not dialog: + if child in names and child.focusPolicy() & Qt.FocusPolicy.TabFocus: + actual.append(names[child]) + child=child.nextInFocusChain() + return actual diff --git a/tests/test_usability.py b/tests/test_usability.py index e9f6f98..ab6fa7c 100644 --- a/tests/test_usability.py +++ b/tests/test_usability.py @@ -11,7 +11,7 @@ from conftest import make_audio from autotalk import app, services -from autotalk import settings +from autotalk import settings_components as settings from autotalk.app import MainWindow from autotalk.media import Capture, RATE from autotalk.runtime import Cancelled, Task @@ -29,7 +29,7 @@ def test_open_pdf_has_one_dialog_and_non_destructive_default_location(qtbot, sam assert w.project.manifest.is_file() assert w.start_button.text() == 'Prepare and start' assert 'Start reuses current audio' in w.status.text() - assert w.settings[1].isVisible() and w.minutes.isVisible() and w.settings[1].audience.isVisible() + assert w.settings[1].isVisible() and w.minutes.isVisible() and w.settings[1].fields['audience'].editor.isVisible() w.settings[1].accept() assert (projects / 'slides-AutoTalk/talk.autotalk.json').is_file() @@ -57,13 +57,13 @@ def test_voice_source_shows_only_its_own_workflow(qtbot, project): w.settings[1].show_section("Voice & language") designed = next(b for b in w.settings[1].findChildren(QPushButton) if b.text() == 'Use this designed voice') for source in ('CustomVoice', 'Base', 'VoiceDesign', 'CustomVoice'): - w.settings[1].voice_source.setCurrentIndex(["CustomVoice", "Base", "VoiceDesign"].index(source)) - assert w.settings[1].speaker.isVisible() == (source == 'CustomVoice') - assert w.settings[1].record_button.isVisible() == (source == 'Base') - assert w.settings[1].transcript.isVisible() == (source == 'Base') - assert w.settings[1].voice_description.isVisible() == (source == 'VoiceDesign') + w.settings[1].voice.voice_source.setCurrentIndex(["CustomVoice", "Base", "VoiceDesign"].index(source)) + assert w.settings[1].voice.speaker.isVisible() == (source == 'CustomVoice') + assert w.settings[1].voice.record_button.isVisible() == (source == 'Base') + assert w.settings[1].voice.transcript.isVisible() == (source == 'Base') + assert w.settings[1].voice.voice_description.isVisible() == (source == 'VoiceDesign') assert designed.isVisible() == (source == 'VoiceDesign') - assert w.settings[1].voice_preview_button.isVisible() + assert w.settings[1].voice.voice_preview_button.isVisible() w.settings[1].reject() @@ -220,7 +220,9 @@ def test_previous_video_does_not_override_new_recording_intent(qtbot, project, t w.record.click() assert not w.record_status.isVisible() and w.record.isChecked() assert 'Saved ' in w.saved_output.text() and 'Open video' in w.saved_output.text() - w.settings[1].options.fields['recording_source'].setCurrentIndex(w.settings[1].options.fields['recording_source'].findData('screen')) + w.settings[1].show_section('Audio & recording') + w.settings[1].fields['recording_source'].editor.setCurrentIndex(w.settings[1].fields['recording_source'].editor.findData('screen')) + w.settings[1].accept() assert 'Screen + system audio' in w.record.toolTip() w.language.setCurrentText('German') assert not w.record_status.isVisible() and w.record.isChecked() @@ -234,27 +236,28 @@ def test_voice_actions_follow_reference_and_current_design(qtbot, project, tmp_p monkeypatch.setattr(services, 'data_dir', lambda: tmp_path) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.settings[1].show_section("Voice & language") - assert 'Energetic male' in w.settings[1].speaker.currentText() - w.settings[1].speaker.setCurrentIndex(w.settings[1].speaker.findData('Aiden')) - assert project.voice.speaker == 'Aiden' - w.settings[1].voice_source.setCurrentIndex(1) - assert not w.settings[1].voice_preview_button.isEnabled() and not w.settings[1].save_voice_button.isEnabled() - w.settings[1].voice_source.setCurrentIndex(2) - assert not w.settings[1].voice_preview_button.isEnabled() and not w.settings[1].accept_voice_button.isEnabled() - w.settings[1].voice_description.setText('Warm, calm and clear') - assert w.settings[1].voice_preview_button.isEnabled() and not w.settings[1].accept_voice_button.isEnabled() - path = services.preview_path(project); path.parent.mkdir(parents=True) + assert 'Energetic male' in w.settings[1].voice.speaker.currentText() + w.settings[1].voice.speaker.setCurrentIndex(w.settings[1].voice.speaker.findData('Aiden')) + assert w.settings[1].project.voice.speaker == 'Aiden' + assert project.voice.speaker == 'Ryan' + w.settings[1].voice.voice_source.setCurrentIndex(1) + assert not w.settings[1].voice.voice_preview_button.isEnabled() and not w.settings[1].voice.save_voice_button.isEnabled() + w.settings[1].voice.voice_source.setCurrentIndex(2) + assert not w.settings[1].voice.voice_preview_button.isEnabled() and not w.settings[1].voice.accept_voice_button.isEnabled() + w.settings[1].voice.voice_description.setText('Warm, calm and clear') + assert w.settings[1].voice.voice_preview_button.isEnabled() and not w.settings[1].voice.accept_voice_button.isEnabled() + path = services.preview_path(w.settings[1].voice.voice_context()); path.parent.mkdir(parents=True) with wave.open(str(path), 'wb') as wav: wav.setparams((1, 2, 24000, 0, 'NONE', 'not compressed')) wav.writeframes(b'\0\0' * 4 * 24000) w.refresh() - assert w.settings[1].accept_voice_button.isEnabled() - w.settings[1].voice_description.setText('An entirely different voice') - assert not w.settings[1].accept_voice_button.isEnabled() - w.settings[1].voice_description.setText('Warm, calm and clear') - w.settings[1].accept_voice_button.click() - assert project.voice.source == 'Base' and project.asset(project.voice_file).is_file() - assert w.settings[1].voice_preview_button.isEnabled() + assert w.settings[1].voice.accept_voice_button.isEnabled() + w.settings[1].voice.voice_description.setText('An entirely different voice') + assert not w.settings[1].voice.accept_voice_button.isEnabled() + w.settings[1].voice.voice_description.setText('Warm, calm and clear') + w.settings[1].voice.accept_voice_button.click() + assert w.settings[1].project.voice.source == 'Base' and w.settings[1].project.asset(w.settings[1].project.voice_file).is_file() + assert w.settings[1].voice.voice_preview_button.isEnabled() w.settings[1].accept() @@ -282,16 +285,16 @@ def test_inapplicable_options_and_recording_browser_priorities(qtbot, project, m from autotalk import options monkeypatch.setattr(options, 'data_dir', lambda: tmp_path) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - assert not w.settings[1].options.use_preset_button.isEnabled() - w.settings[1].show_section("Presentation & recording") - assert not w.settings[1].remove_background_button.isEnabled() and w.settings[1].background_gain.isEnabled() - assert w.settings[1].background_gain.value() == 15 and w.settings[1].background_gain.suffix() == ' %' + assert not w.settings[1].presets.use_preset_button.isEnabled() + w.settings[1].show_section("Audio & recording") + assert not w.settings[1].assets.remove_background_button.isEnabled() and w.settings[1].fields['background_gain'].editor.isEnabled() + assert w.settings[1].fields['background_gain'].editor.value() == 15 and w.settings[1].fields['background_gain'].editor.suffix() == ' %' w.settings[1].accept() w.export_dialog.show_section() assert w.output_button.property('primary') and not w.export_button.property('primary') assert w.export_button.y() > w.recordings.y() w.export_dialog.accept() - w.settings[1].show_section("Talk & preparation") + w.settings[1].show_section("Timing & playback") assert w.settings[1].fit_button.isVisible() w.settings[1].reject() assert project.slides[0].source_text.splitlines()[0] in w.slide_list.item(0).text() @@ -419,17 +422,17 @@ def test_saved_voice_preview_feedback_and_settings_have_one_owner(qtbot, project monkeypatch.setattr(voices, 'data_dir', lambda: tmp_path) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.settings[1].show_section("Voice & language") - w.settings[1].voice_source.setCurrentIndex(3) - assert w.settings[1].voice_library.rowCount() == 0 - assert not w.settings[1].voice_preview_button.isEnabled() and not w.settings[1].library_use.isEnabled() - voices.save_voice(project, 'Conference voice'); w.settings[1].refresh_library() - assert w.settings[1].voice_preview_button.isEnabled() and w.settings[1].library_use.isEnabled() + w.settings[1].voice.voice_source.setCurrentIndex(3) + assert w.settings[1].voice.voice_library.rowCount() == 0 + assert not w.settings[1].voice.voice_preview_button.isEnabled() and not w.settings[1].voice.library_use.isEnabled() + voices.save_voice(project, 'Conference voice'); w.settings[1].voice.refresh_library() + assert w.settings[1].voice.voice_preview_button.isEnabled() and w.settings[1].voice.library_use.isEnabled() release = threading.Event() monkeypatch.setattr(settings, 'synthesize', lambda *a, **kw: (release.wait(3), None)[1]) # Exercise the real button and worker path; no speech model needed to check ownership. monkeypatch.setattr(w.transport, 'preview', lambda path: None) try: - w.settings[1].voice_preview_button.click(); qtbot.wait(300) + w.settings[1].voice.voice_preview_button.click(); qtbot.wait(300) assert QApplication.activeModalWidget() is w.settings[1] assert w.progress.isVisible() and w.progress.parentWidget() is w.settings[1] assert 'Generating voice preview' in w.settings[1].status.text() @@ -438,11 +441,11 @@ def test_saved_voice_preview_feedback_and_settings_have_one_owner(qtbot, project release.set(); qtbot.waitUntil(lambda: w.job is None) assert w.settings[1].buttons.button(QDialogButtonBox.Save).text().replace('&', '') == 'Save' w.settings[1].reject() - w.settings[0].show_section("Application") - assert w.settings[0].screen.isVisible() and w.settings[0].audio_test_button.isVisible() - w.settings[0].reject() - w.settings[1].show_section("Presentation & recording") - assert w.record.isVisible() and not w.settings[0].screen.isVisible() and not w.settings[0].audio_test_button.isVisible() + w.preferences.show_section("Display & sound") + assert w.preferences.screen.isVisible() and w.preferences.audio_test_button.isVisible() + w.preferences.reject() + w.settings[1].show_section("Audio & recording") + assert w.record.isVisible() and not w.preferences.screen.isVisible() and not w.preferences.audio_test_button.isVisible() w.settings[1].reject() diff --git a/tests/test_ux_placement.py b/tests/test_ux_placement.py index b2a4128..9e014c9 100644 --- a/tests/test_ux_placement.py +++ b/tests/test_ux_placement.py @@ -8,7 +8,7 @@ from PySide6.QtWidgets import QPushButton, QStyle, QStyleOptionComboBox from autotalk import app, options, voices -from autotalk import settings +from autotalk import settings_components as settings from autotalk.app import MainWindow from autotalk.project import Project from conftest import make_audio @@ -60,26 +60,26 @@ def test_p1_controls_are_visible_without_scroll_and_do_not_overlap(qtbot, projec def test_dialog_destinations_and_direct_voice_sources(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() qtbot.mouseClick(w.voice_button, Qt.LeftButton) # depth 1 - assert w.settings[1].isVisible() and w.settings[1].speaker.isVisible() - for index, control in ((1, w.settings[1].record_button), (2, w.settings[1].voice_description), (3, w.settings[1].voice_library)): - qtbot.mouseClick(w.settings[1].voice_source, Qt.LeftButton, pos=w.settings[1].voice_source.tabRect(index).center()) # depth 2 + assert w.settings[1].isVisible() and w.settings[1].voice.speaker.isVisible() + for index, control in ((1, w.settings[1].voice.record_button), (2, w.settings[1].voice.voice_description), (3, w.settings[1].voice.voice_library)): + qtbot.mouseClick(w.settings[1].voice.voice_source, Qt.LeftButton, pos=w.settings[1].voice.voice_source.tabRect(index).center()) # depth 2 visible_inside(control, w.settings[1]) - visible_inside(w.settings[1].voice_preview_button, w.settings[1]) + visible_inside(w.settings[1].voice.voice_preview_button, w.settings[1]) w.settings[1].reject() qtbot.mouseClick(w.presentation_button, Qt.LeftButton) - w.settings[0].show_section("Application") - visible_inside(w.settings[0].screen, w.settings[0]) - visible_inside(w.settings[0].audio_test_button, w.settings[0]) - w.settings[0].reject() - w.settings[1].show_section("Presentation & recording") - visible_inside(w.settings[1].options.fields['recording_source'], w.settings[1]) + w.preferences.show_section("Display & sound") + visible_inside(w.preferences.screen, w.preferences) + visible_inside(w.preferences.audio_test_button, w.preferences) + w.preferences.reject() + w.settings[1].show_section("Audio & recording") + visible_inside(w.settings[1].fields['recording_source'].editor, w.settings[1]) assert w.record.parentWidget() is w.record_footer w.settings[1].reject() w.talk_action.trigger() - assert w.settings[1].audience.isVisible() and w.settings[1].objective.isVisible() and w.settings[1].conference_scope.isVisible() - w.settings[1].show_section("AI & speech engine") - assert w.settings[1].codex_model.isVisible() and w.settings[1].codex_effort.isVisible() - assert not w.settings[0].codex_signin.isVisible() and not w.settings[1].options.override.isVisible() + assert w.settings[1].fields['audience'].editor.isVisible() and w.settings[1].fields['objective'].editor.isVisible() and w.settings[1].fields['scope'].editor.isVisible() + w.settings[1].show_section("AI model") + assert w.settings[1].models.codex_model.isVisible() and w.settings[1].models.codex_effort.isVisible() + assert not w.preferences.codex_signin.isVisible() and w.settings[1].voice.override.isVisible() w.settings[1].reject() @@ -90,7 +90,7 @@ def test_language_arrangement_choices_fit_native_field(qtbot, project, scope, si dialog = w.settings[scope] dialog.show_section('Voice & language') dialog.resize(*size) - combo = dialog.options.fields['language_policy'] + combo = dialog.fields['language_policy'].editor scroll = dialog.pages.currentWidget() for index in range(combo.count()): scroll.ensureWidgetVisible(combo) @@ -149,13 +149,14 @@ def test_saved_voice_audition_does_not_select_or_invalidate_talk(qtbot, project, monkeypatch.setattr(settings, 'synthesize', lambda p, task, preview: heard.append(p.voice.speaker) or p.audio(p.slides[0])) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() monkeypatch.setattr(w.transport, 'preview', lambda path: None) - w.voice_button.click(); w.settings[1].voice_source.setCurrentIndex(3) - w.settings[1].voice_preview_button.click(); qtbot.waitUntil(lambda: w.job is None) + w.voice_button.click(); w.settings[1].voice.voice_source.setCurrentIndex(3) + w.settings[1].voice.voice_preview_button.click(); qtbot.waitUntil(lambda: w.job is None) assert heard == ['Aiden'] assert w.project.voice == original and w.project.prepared assert Project.load(project.manifest).voice == original - w.settings[1].library_use.click() - assert w.project.voice.speaker == 'Aiden' and not w.project.prepared + w.settings[1].voice.library_use.click() + assert w.settings[1].project.voice.speaker == 'Aiden' and not w.settings[1].project.prepared + assert w.project.voice.speaker != 'Aiden' and w.project.prepared w.settings[1].reject() assert w.project.voice == original and w.project.prepared assert Project.load(project.manifest).voice == original @@ -172,8 +173,8 @@ def test_language_selection_automatically_creates_and_preserves_original(qtbot, assert project.version.language == project.language == 'English' w.language.setCurrentIndex(w.language.findData('options')) assert w.settings[1].isVisible() - assert w.settings[1].options.fields['language_policy'].isVisible() - w.settings[1].options.fields['language_policy'].setCurrentIndex(2) + assert w.settings[1].fields['language_policy'].editor.isVisible() + w.settings[1].fields['language_policy'].editor.setCurrentIndex(2) w.settings[1].reject() assert w.project.language_policy == 'mixed' @@ -187,39 +188,38 @@ def test_delivery_preset_and_style_never_overwrite_sampling(qtbot, project, monk project.set_setting("delivery.sampling", {'temperature': .3}) w = MainWindow(); qtbot.addWidget(w); w.adopt(project) w.settings[1].show_section("Voice & language") - w.settings[1].options.use_preset_button.click() - assert project.delivery.style == 'Storytelling' and project.delivery.sampling == {'temperature': .3} - w.settings[1].options.fields['delivery.style'].setCurrentText('Professional') - assert project.delivery.attributes == {'texture': 'Warm'} + w.settings[1].presets.use_preset_button.click() + assert w.settings[1].project.delivery.style == 'Storytelling' and w.settings[1].project.delivery.sampling == {'temperature': .3} + assert project.delivery.style == 'Professional' + w.settings[1].fields['delivery.style'].editor.setCurrentText('Professional') + assert w.settings[1].project.delivery.attributes == {'texture': 'Warm'} w.settings[1].reject() assert w.project.delivery.sampling == {'temperature': .3} -def test_cancel_restores_clips_display_and_project_settings(qtbot, project): - w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() - w.settings[0].screen.addItem('Second test display', 1) - w.settings[0].show_section('Application'); w.settings[0].screen.setCurrentIndex(1) - w.settings[0].reject() - w.presentation_button.click(); w.settings[1].pause.setValue(2) +def test_cancel_restores_clips_display_and_project_settings(qtbot,project): + w=MainWindow();qtbot.addWidget(w);w.adopt(project);w.show() + w.preferences.screen.addItem('Second test display',1) + w.preferences.show_section();w.preferences.screen.setCurrentIndex(1);w.preferences.reject() + w.presentation_button.click();w.settings[1].fields['pause_seconds'].editor.setValue(2) w.settings[1].reject() - assert w.settings[0].screen.currentIndex() == 0 and w.project.pause_seconds == .6 - w.settings[2].show_section("Presentation & recording") - w.project.slides[0].notes = 'Pending edit' - w.save() # Even handlers that persist eagerly are rolled back by Cancel. - w.settings[2].reject() - assert w.project.slides[0].notes != 'Pending edit' - assert Project.load(project.manifest).slides[0].notes != 'Pending edit' + assert w.preferences.screen.currentIndex()==1 and w.project.pause_seconds==.6 + w.project.slides[0].notes='Pending edit';w.setWindowModified(True) + before=project.manifest.read_bytes() + w.settings[2].show_section('Audio & recording');w.settings[2].reject() + assert w.project.slides[0].notes=='Pending edit' and w.isWindowModified() + assert project.manifest.read_bytes()==before def test_engine_account_and_f5_use_their_single_authoritative_commands(qtbot, project, monkeypatch): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.engine_status.click() - assert w.settings[0].engine_state.isVisible() and w.settings[0].load_gpu_button.isVisible() - w.settings[0].reject() - w.settings[0].show_section("Application") - assert w.settings[0].codex_signin.isVisible() and w.settings[0].codex_signout.isVisible() - assert not w.settings[0].codex_model.isVisible() - w.settings[0].reject() + assert w.preferences.engine_state.isVisible() and w.preferences.load_gpu_button.isVisible() + w.preferences.reject() + w.preferences.show_section("Account") + assert w.preferences.codex_signin.isVisible() and w.preferences.codex_signout.isVisible() + assert not w.settings[0].models.codex_model.isVisible() + w.preferences.reject() starts = [] monkeypatch.setattr(w, 'prepare_and_present', lambda: starts.append(True)) with qtbot.waitActive(w): @@ -238,11 +238,11 @@ def test_quick_can_configure_its_inherited_voice_and_delivery(qtbot, project, tm project.mode = 'Quick' w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.voice_button.click() - assert w.settings[1].voice_source.isEnabled() + assert w.settings[1].voice.voice_source.isEnabled() w.settings[1].show_section("Voice & language") - assert w.settings[1].options.use_preset_button.isEnabled() - assert w.settings[1].options.fields['delivery.style'].isEnabled() - assert w.settings[1].voice_preview_button.isEnabled() + assert w.settings[1].presets.use_preset_button.isEnabled() + assert w.settings[1].fields['delivery.style'].editor.isEnabled() + assert w.settings[1].voice.voice_preview_button.isEnabled() w.settings[1].reject() @@ -250,11 +250,11 @@ def test_voice_workflows_and_audition_remain_visible_in_minimum_dialog(qtbot, pr w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() w.settings[1].resize(760, 580) w.voice_button.click() - for index, control in ((0, w.settings[1].speaker), (1, w.settings[1].record_button), (2, w.settings[1].voice_description), (3, w.settings[1].library_use)): - w.settings[1].voice_source.setCurrentIndex(index) + for index, control in ((0, w.settings[1].voice.speaker), (1, w.settings[1].voice.record_button), (2, w.settings[1].voice.voice_description), (3, w.settings[1].voice.library_use)): + w.settings[1].voice.voice_source.setCurrentIndex(index) qtbot.wait(10) visible_inside(control, w.settings[1]) - visible_inside(w.settings[1].voice_preview_button, w.settings[1]) + visible_inside(w.settings[1].voice.voice_preview_button, w.settings[1]) assert w.settings[1].size().toTuple() == (760, 580) w.settings[1].reject() diff --git a/tests/test_ux_redundancy.py b/tests/test_ux_redundancy.py index 9587801..403baac 100644 --- a/tests/test_ux_redundancy.py +++ b/tests/test_ux_redundancy.py @@ -27,22 +27,22 @@ def test_single_editor_ownership(qtbot, project): assert not hasattr(w, 'settings_minutes') assert not hasattr(w, 'slide_directions') and not hasattr(w, 'inherit_delivery') for name, main in [('mode', w.mode), ('language', w.language), ('record_presentation', w.record)]: - field = w.settings[1].options.fields[name] + field = w.settings[1].fields[name].editor assert isinstance(field, QLabel) and not isinstance(main, QLabel) - assert not isinstance(w.settings[0].options.fields[name], QLabel) - assert isinstance(w.settings[2].settings_language, QComboBox) - slide = w.settings[2]; slide.show_section('Presentation & recording'); qtbot.wait(10) - assert not slide.pause.isVisible() and not slide.settings_after.isVisible() + assert not isinstance(w.settings[0].fields[name].editor, QLabel) + assert isinstance(w.settings[2].fields['language'].editor, QComboBox) + slide = w.settings[2]; slide.show_section('Audio & recording'); qtbot.wait(10) + assert 'pause_seconds' not in slide.fields and 'after' not in slide.fields slide.reject() reveal_timing(w) - assert w.inspector.slide_pause.isVisible() and w.inspector.inherit_pause.isVisible() + assert w.inspector.slide_pause.isVisible() and w.inspector.pause_field.reset.isVisible() assert not any(b.text() == 'Delivery' for b in w.editor.findChildren(QToolButton)) @pytest.mark.parametrize('name,default,override,page', [ - ('mode', 'Prepared', 'Quick', 'Talk & preparation'), + ('mode', 'Prepared', 'Quick', 'Talk'), ('language', 'English', 'German', 'Voice & language'), - ('record_presentation', False, True, 'Presentation & recording')]) + ('record_presentation', False, True, 'Audio & recording')]) @pytest.mark.parametrize('save', [False, True]) def test_readonly_reset_save_cancel(qtbot, project, name, default, override, page, save): make_audio(project) @@ -53,19 +53,21 @@ def test_readonly_reset_save_cancel(qtbot, project, name, default, override, pag w.mode.setCurrentText(override) else: w.record.setChecked(override) + w.save() before = copy.deepcopy(w.project) dialog = w.settings[1]; dialog.show_section(page) - field = dialog.options.fields[name] - assert 'Talk setting' in field.text() + field = dialog.fields[name].editor + assert dialog.fields[name].source.text() == 'set here' assert (str(override) if not isinstance(override, bool) else 'On') in field.text() - reset = dialog.options.inheritance[name][1] - assert reset.text() == 'Use app' and reset.isEnabled() + reset = dialog.fields[name].reset + assert reset.text() == 'Reset' and reset.isEnabled() reset.click() - assert w.project.setting(name) == default - assert 'Application default' in field.text() - assert reset.text() == 'From app' and not reset.isEnabled() + assert dialog.project.setting(name) == default + assert w.project.setting(name) == override + assert dialog.fields[name].source.text() == 'built-in' + assert reset.text() == 'Reset' and not reset.isEnabled() if name == 'language': - assert w.project.active_version == 'main' and w.project.prepared + assert dialog.project.active_version == 'main' and dialog.project.prepared (dialog.accept if save else dialog.reject)() loaded = Project.load(project.manifest) assert loaded.setting(name) == (default if save else override) @@ -81,7 +83,7 @@ def test_language_options_focus_arrangement(qtbot, project, entrance): else: action(w, 'Talk', 'Language options…').trigger() qtbot.wait(30) - assert w.settings[1].options.fields['language_policy'].hasFocus() + assert w.settings[1].fields['language_policy'].editor.hasFocus() w.settings[1].reject() @@ -93,27 +95,29 @@ def test_inspector_source_pause_and_delivery_independence(qtbot, project, talk_o project.set_setting('delivery.instructions', 'Speak softly', project.slides[0]) w = MainWindow(); qtbot.addWidget(w); w.defaults['after'] = 'demo'; w.adopt(project); w.show(); reveal_timing(w) source = 'talk' if talk_override else 'app' - assert w.inspector.slide_after.itemText(0) == f'Use {source} setting' - assert f'From {source}: ' + ('Wait for presenter' if talk_override else 'Pause for live demo') in w.inspector.slide_after_source.text() - assert not w.inspector.slide_pause.isEnabled() - w.inspector.inherit_pause.setChecked(False); w.inspector.slide_pause.setValue(0) + assert w.inspector.after_field.source.text() == f'from {source}' + assert w.inspector.slide_after.currentText() == ('Wait for presenter' if talk_override else 'Pause for live demo') + assert w.inspector.slide_pause.isEnabled() + w.inspector.slide_pause.setValue(0) w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.findData('advance')) assert project.slides[0].overrides['delivery.instructions'] == 'Speak softly' w.save() loaded = Project.load(project.manifest); w.adopt(loaded) assert loaded.slides[0].overrides['pause_seconds'] == 0 and w.inspector.slide_pause.isEnabled() - w.inspector.inherit_pause.setChecked(True); w.save() + w.inspector.pause_field.reset.click(); w.save() assert 'pause_seconds' not in Project.load(project.manifest).slides[0].overrides assert w.inspector.slide_pause.value() == loaded.setting('pause_seconds') - assert not w.inspector.slide_pause.isEnabled() + assert w.inspector.slide_pause.isEnabled() assert loaded.slides[0].overrides['delivery.instructions'] == 'Speak softly' def test_readouts_recording_and_progress_position(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() assert w.summary.text() == 'Audio not ready' + w.settings[1].show_section('Timing & playback') target = clock(project.target_minutes*60) assert w.settings[1].timing_summary.text().count(target) == 1 + w.settings[1].reject() w.record.setChecked(True) assert not w.record_status.isVisible() capture = SimpleNamespace(frames=17*RATE, ready=True) @@ -139,17 +143,19 @@ def test_menu_focus_status_and_background(qtbot, project): w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() assert not w.statusBar().currentMessage() action(w, 'Settings', 'Account…').trigger(); qtbot.wait(30) - assert w.settings[0].codex_signin.hasFocus() - w.settings[0].reject() + assert w.preferences.codex_signin.hasFocus() + w.preferences.reject() menus = w.menuBar().actions() help_menu = next(a for a in menus if a.text() == 'Help').menu() assert 'Operation details' not in [a.text() for a in help_menu.actions()] assert action(w, 'View', 'Operation details') - parent = w.settings[1].background_button.parentWidget() - assert w.settings[1].remove_background_button.parentWidget() is parent - assert w.settings[1].background_gain.isAncestorOf(parent) is False - assert parent.isAncestorOf(w.settings[1].background_gain) - assert w.settings[0].voice_model.text().count(w.settings[0].engine_state.text()) == 0 + parent = w.settings[1].assets.background_button.parentWidget() + assert w.settings[1].assets.remove_background_button.parentWidget() is parent + assert w.settings[1].fields['background_gain'].editor.isAncestorOf(parent) is False + assert parent.parentWidget().isAncestorOf(w.settings[1].fields['background_gain'].editor) + w.settings[0].show_section('AI model') + assert w.settings[0].voice_model.text().count(w.preferences.engine_state.text()) == 0 + w.settings[0].reject() def test_unchanged_values_preserve_text_audio_keys_and_settings(qtbot, project): @@ -166,7 +172,7 @@ def test_unchanged_values_preserve_text_audio_keys_and_settings(qtbot, project): w.inspector.slide_after.setCurrentIndex(w.inspector.slide_after.currentIndex()) w.inspector.slide_pause.setValue(w.inspector.slide_pause.value()) dialog = w.settings[2]; dialog.show_section('Voice & language') - field = dialog.options.fields['delivery.instructions']; field.setText(field.text()) + field = dialog.fields['delivery.instructions'].editor; field.setText(field.text()) dialog.accept() assert project.versions == versions assert [(project.speech_key(s), project.text_ready(s), project.ready(s)) for s in project.slides] == baseline diff --git a/tests/test_verdict_acceptance.py b/tests/test_verdict_acceptance.py index 6f0dea0..f4890fe 100644 --- a/tests/test_verdict_acceptance.py +++ b/tests/test_verdict_acceptance.py @@ -21,35 +21,37 @@ @pytest.mark.parametrize('source', ['CustomVoice', 'Base']) def test_conference_completion_restores_activity_without_erasing_capabilities(qtbot, project, monkeypatch, source): - from autotalk import settings + from autotalk import settings_components as settings release = threading.Event() project.voice = Voice(source=source) project.conference_url = 'https://conference.example' - monkeypatch.setattr(settings, 'extract_scope', lambda *args: ( + monkeypatch.setattr(settings, 'extract_scope', lambda *args, **kwargs: ( release.wait(5), {'scope': 'Verified conference topics', 'sources': [project.conference_url]})[1]) w = MainWindow(); qtbot.addWidget(w); w.adopt(project); w.show() qtbot.mouseClick(next(b for b in w.overview.findChildren(QPushButton) if b.text() == 'Talk settings…'), Qt.MouseButton.LeftButton) dialog = w.settings[1] try: - qtbot.mouseClick(w.settings[1].conference_button, Qt.MouseButton.LeftButton) - assert w.job is not None and not w.settings[1].tolerance.isEnabled() + qtbot.mouseClick(w.settings[1].conference.read_button, Qt.MouseButton.LeftButton) + assert w.job is not None and not w.settings[1].fields['tolerance_seconds'].editor.isEnabled() release.set() qtbot.waitUntil(lambda: w.job is None) assert dialog.isVisible() - assert w.settings[1].conference_scope.toPlainText() == 'Verified conference topics' - assert w.settings[1].tolerance.isEnabled() and w.mode.isEnabled() - w.settings[1].tolerance.setFocus(); w.settings[1].tolerance.selectAll() - qtbot.keyClicks(w.settings[1].tolerance, '23') - qtbot.keyClick(w.settings[1].tolerance, Qt.Key.Key_Tab) - assert w.project.tolerance_seconds == 23 + assert w.settings[1].fields['scope'].editor.toPlainText() == 'Verified conference topics' + assert w.settings[1].fields['tolerance_seconds'].editor.isEnabled() and w.mode.isEnabled() + dialog.show_section('Timing & playback') + w.settings[1].fields['tolerance_seconds'].editor.setFocus(); w.settings[1].fields['tolerance_seconds'].editor.selectAll() + qtbot.keyClicks(w.settings[1].fields['tolerance_seconds'].editor, '23') + qtbot.keyClick(w.settings[1].fields['tolerance_seconds'].editor, Qt.Key.Key_Tab) + assert dialog.project.tolerance_seconds == 23 + assert w.project.tolerance_seconds != 23 if directory := os.environ.get('AUTOTALK_EVIDENCE_DIR'): target = Path(directory); target.mkdir(parents=True, exist_ok=True) dialog.grab().save(str(target / f'F1-recovered-{source}.png')) item = dialog.navigation.findItems('Voice & language', Qt.MatchFlag.MatchExactly)[0] qtbot.mouseClick(dialog.navigation.viewport(), Qt.MouseButton.LeftButton, pos=dialog.navigation.visualItemRect(item).center()) - assert w.settings[1].options.fields['writing_style'].isEnabled() - assert w.settings[1].options.fields['delivery.style'].isEnabled() == (source != 'Base') + assert w.settings[1].fields['writing_style'].editor.isEnabled() + assert w.settings[1].fields['delivery.style'].editor.isEnabled() == (source != 'Base') dialog.accept() finally: release.set() @@ -76,16 +78,17 @@ def test_disclosure_arrows_follow_actual_pointer_interaction(qtbot, project): assert w.notes_toggle.arrowType() == (Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow) qtbot.mouseClick(w.voice_button, Qt.MouseButton.LeftButton) dialog = w.settings[1] + dialog.show_section('Writing & delivery') toggle = next(b for b in dialog.findChildren(QToolButton) if b.text() == 'More vocal attributes') dialog.pages.currentWidget().ensureWidgetVisible(toggle) qtbot.mouseClick(toggle, Qt.MouseButton.LeftButton) assert toggle.arrowType() == Qt.ArrowType.DownArrow - assert w.settings[1].options.fields['delivery.attributes.pitch'].isVisible() + assert w.settings[1].fields['delivery.attributes.pitch'].editor.isVisible() if directory := os.environ.get('AUTOTALK_EVIDENCE_DIR'): target = Path(directory); target.mkdir(parents=True, exist_ok=True) dialog.grab().save(str(target / 'disclosure-expanded.png')) qtbot.mouseClick(toggle, Qt.MouseButton.LeftButton) - assert not w.settings[1].options.fields['delivery.attributes.pitch'].isVisible() + assert not w.settings[1].fields['delivery.attributes.pitch'].editor.isVisible() assert toggle.arrowType() == Qt.ArrowType.RightArrow dialog.reject() @@ -322,7 +325,7 @@ def snapshot(widget, name): snapshot(menu, 'menu-' + action.text()) assert all(a.menu() is None or not a.text().endswith('…') for a in menu.actions()) qtbot.keyClick(menu, Qt.Key.Key_Escape) - routes = [(0, ('Settings', 'Application settings…')), + routes = [(0, ('Settings', 'Talk defaults…')), (1, ('Talk', 'Talk settings…')), (2, ('Talk', 'Selected slide', 'Additional audio…'))] for scope, path in routes: @@ -347,7 +350,7 @@ def snapshot(widget, name): qtbot.mouseClick(dialog.navigation.viewport(), Qt.LeftButton, pos=dialog.navigation.visualItemRect(item).center()) assert dialog.pages.currentIndex() == index - assert dialog.voice_audition.isVisible() == (index == 0) + assert dialog.voice.voice_audition.isVisible() == (item.text() == 'Voice & language') snapshot(dialog, f'scope-{scope}-page-{index}') visible_inside(dialog.buttons, dialog) for toggle in dialog.pages.currentWidget().findChildren(QToolButton): diff --git a/tools/bench_ui.py b/tools/bench_ui.py index 85a14bc..295ce43 100644 --- a/tools/bench_ui.py +++ b/tools/bench_ui.py @@ -25,7 +25,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src')) from autotalk.app import MainWindow from autotalk.project import Project, Slide, file_hash -from autotalk.settings import SettingsDialog +from autotalk.settings import ScopedSettingsDialog def prepared_project(root, count): @@ -61,6 +61,8 @@ def benchmark(count, repeats): original = copy.deepcopy(project) def restore(): nonlocal project + for dialog in window.settings.values(): + if dialog.isVisible(): dialog.reject() project = copy.deepcopy(original) window.adopt(project) window.select_slide(count // 2) @@ -68,15 +70,17 @@ def restore(): codes = {inspect.unwrap(getattr(Project, name)).__code__: name for name in ('ready', 'speech_key', 'text_ready', 'asset')} codes.update({inspect.unwrap(method).__code__: name for name, method in - [('refresh', MainWindow.refresh), ('dialog_loads', SettingsDialog.load_settings), + [('refresh', MainWindow.refresh), ('dialog_loads', ScopedSettingsDialog.load_settings), ('configuration_changed', MainWindow.configuration_changed)]}) result = {'slides': count} actions = {'refresh': window.refresh, 'keystroke': lambda: window.narration.insertPlainText('x'), 'selection': lambda: window.slide_list.setCurrentRow((window.transport.index + 1) % count)} actions.update({ + 'dialog_edit': lambda: window.settings[1].fields['audience'].editor.setText('Changed audience'), + 'dialog_save': window.settings[1].accept, 'after': lambda: window.inspector.slide_after.setCurrentIndex(window.inspector.slide_after.findData('pause')), - 'pause': lambda: window.inspector.inherit_pause.setChecked(False), + 'pause': lambda: window.inspector.slide_pause.setValue(1.2), 'budget': lambda: window.inspector.slide_budget.setValue(75), 'include': lambda: window.inspector.slide_include.setChecked(False), 'duration': lambda: window.minutes.setValue(12), @@ -87,17 +91,21 @@ def restore(): }) if hasattr(window, 'flush_refresh'): actions['aggregates'] = window.flush_refresh + def prepare_action(name): + restore() + if name.startswith('dialog_'): + window.settings[1].show_section('Talk') + if name == 'dialog_save': + window.settings[1].fields['audience'].editor.setText('Changed audience') + if name == 'aggregates': + window.narration.insertPlainText('x') for name, action in actions.items(): samples = [] for _ in range(repeats): - restore() - if name == 'aggregates': - window.narration.insertPlainText('x') + prepare_action(name) start = time.perf_counter(); action() samples.append((time.perf_counter() - start) * 1000) - restore() - if name == 'aggregates': - window.narration.insertPlainText('x') + prepare_action(name) counts = Counter() def trace(frame, event, arg): if event == 'call' and frame.f_code in codes: