diff --git a/CLAUDE.md b/CLAUDE.md index 919ec1c..8fa48a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,3 +83,53 @@ Add-element ribbon actions require an element selection; delete is handled by `R - **qlementine-icons API** — always use `themedIcon(Icons16::Category_Name)` (not `QIcon(iconPath(...))` which gives raw dark icons). Call `initIcons()` once at startup (done in `main.cpp`) before creating `MainWindow`. Icons header: `#include "icons.h"` (re-exports `oclero::qlementine::icons` namespace). - **`textStyle{}`** — text layout fields (`alignX`, `alignY`, `autoScale`, `ellipsize`, `wrapMode`, `transform`) live in `el.textStyle.*`, not flat on `Element`. Access as `el.textStyle.alignX` etc. - **`QVector`** — `GradientStop` = `{qreal position, QColor color}`. + +# Orchestrator role + +This session plans and reviews; it does not write implementation code directly. + +Workflow for any non-trivial task: +1. Break the request into discrete, independently-testable subtasks. +2. For each subtask, delegate to the `implementer` subagent via the Agent tool, + giving it: exact file paths, the specific change needed, and acceptance criteria. +3. After implementer returns, review the diff yourself before moving to the next + subtask — don't just trust it blind. +4. If implementer's result is wrong or incomplete, send it back with specific + corrections rather than redoing the work yourself. +5. Only write code directly for something too small to be worth a delegation + round-trip (a one-line fix). + +**Never skip straight to implementation on a multi-file or architectural task — plan first, then delegate.** + +## Implementer feedback loop + +After delegating to `implementer`, follow this loop before moving to the next subtask: + +1. Send the implementer's diff to `reviewer`. +2. If reviewer approves → move to the next subtask. +3. If reviewer flags issues: + a. Send the implementer the specific issues reviewer raised (not a vague + "try again") and re-delegate. + b. Increment a retry counter for this subtask. +4. Retry limit: 2 rounds of implementer → reviewer per subtask. + - Round 1 fail → send specific corrections, retry. + - Round 2 fail → STOP delegating this subtask. +5. On hitting the retry limit: + - Do not attempt a 3rd delegation round. + - Read the actual diffs and reviewer feedback yourself. + - Either (a) fix it directly yourself if it's now clear what's wrong, or + (b) conclude the subtask itself was mis-specified and re-plan it as a + different, more concrete subtask before delegating again. + - Tell the user which subtask needed escalation and why — don't silently + absorb the failure. + +Never let a single subtask consume more than 2 implementer rounds without +surfacing that to the user. A stuck loop is a planning failure, not just an +implementation failure — treat it as a signal to reconsider the subtask, not +just retry harder. + +## Test-writer sequencing + +Run `test-writer` after `reviewer` approves, not before — no point testing code +that's about to be revised. Exception: if the subtask is pure bug-fixing against +an existing failing test, `test-writer` isn't needed at all. diff --git a/CMakeLists.txt b/CMakeLists.txt index 33d20fd..bfdb016 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,9 +26,6 @@ set(PROJECT_SOURCES model/UndoCommands.h model/UndoCommands.cpp ui/adaptivestack.h ui/adaptivestack.cpp - ui/titleproperties.h ui/titleproperties.cpp - ui/transformeditor.h ui/transformeditor.cpp - ui/animationeditor.h ui/animationeditor.cpp ui/widgets/ColorUtils.h ui/widgets/ColorUtils.cpp ui/widgets/HueSlider.h ui/widgets/HueSlider.cpp ui/widgets/GradientSlider.h ui/widgets/GradientSlider.cpp @@ -45,12 +42,15 @@ set(PROJECT_SOURCES icons.h icons.cpp ui/painteditor.h ui/painteditor.cpp ui/widgets/CanvasWidget.h ui/widgets/CanvasWidget.cpp + ui/widgets/ResizeMath.h + ui/widgets/AlignMath.h + ui/widgets/ZOrderOps.h ui/widgets/TitleTreeModel.h ui/widgets/TitleTreeModel.cpp ui/widgets/TitleTreeView.h ui/widgets/TitleTreeView.cpp ui/widgets/SelectionHandles.h ui/widgets/SelectionHandles.cpp - ui/widgets/AnimationTimingEditor.h ui/widgets/AnimationTimingEditor.cpp - ui/widgets/GraphicTimingEditor.h ui/widgets/GraphicTimingEditor.cpp - ui/widgets/ScrubRuler.h ui/widgets/ScrubRuler.cpp + ui/widgets/AnimationTimelineEditor.h ui/widgets/AnimationTimelineEditor.cpp + ui/widgets/ClipPropertiesPanel.h ui/widgets/ClipPropertiesPanel.cpp + ui/widgets/AnimationTimingPanel.h ui/widgets/AnimationTimingPanel.cpp ui/widgets/CornerRadiusButton.h ui/widgets/CornerRadiusButton.cpp ui/widgets/PaddingButton.h ui/widgets/PaddingButton.cpp ui/widgets/PaintPickerWidget.h ui/widgets/PaintPickerWidget.cpp diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..9744f23 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,240 @@ +# StreamCanvas Editor — UX Audit & Fix Plan + +## Context + +The StreamCanvas editor is a standalone Qt6/SARibbon desktop app that authors animated +broadcast-graphics scenes and hands them to the OBS plugin as `.ogt` bundles. It must feel +native beside OBS Studio. This pass audits UX and correctness and fixes the highest-value +issues. The audit is static (deep source read + direct verification of top findings); the +`engine/` submodule is not checked out and plan mode forbade building, so **live driving of +the app happens during implementation**, verifying each item before moving on. + +**Approved direction (from clarifying questions):** +- **Theme:** stay dark-only (OBS default look). Fix hardcoded colors that break contrast/ + consistency *within* the dark theme; route them through `palette()` roles. **No OS light + theme / `colorSchemeChanged` work this pass.** +- **Scope:** fix data-loss/desync bugs and low-cost UX wins first. Rotation/shear-correct + canvas handles and multi-select are **deferred large items** (listed at the end for + separate approval, not implemented in this pass). +- **Process:** per repo `CLAUDE.md`, this session plans/reviews and delegates implementation + to the `implementer` subagent, one item at a time, `implementer → reviewer` loop, then + `test-writer` where a test is meaningful. Small self-contained commits. Respect the + licensing split (MIT engine / GPL plugin / proprietary editor) — no code moved across + boundaries; all edits stay in the editor tree. + +**Ignore:** `.claude/worktrees/*` (stale copies) and `ui/graphicproperties.{h,cpp}` +(dead "Scene/Graphic" code, not in `CMakeLists.txt`, references a nonexistent +`engine/scene.h`). Flag the dead file for deletion, don't fix it. + +--- + +## Ranking method + +Ranked by (user impact × inverse fix cost). P0 = correctness/data-loss, ship first. +P1 = cheap, high-visibility UX. P2 = medium. Deferred = large, needs separate sign-off. + +--- + +## P0 — Correctness & data loss (do first) + +### P0-1. Copy/paste and delete-undo silently drop data animations (and mask) +- **Repro:** Give an element a data-change animation (`SetElementAnimCmd` → DataAnimIn/Out). + Copy→paste it, or delete it and Ctrl+Z. The data animation is gone. +- **Root cause:** `TitleDocument::elementToJson` (`model/TitleDocument.cpp:288-372`, verified) + serializes `anim_in`/`anim_out` but **omits `data_anim_in`/`data_anim_out` and `mask`**. + `insertElementFromJson` (`model/UndoCommands.cpp:150-268`) never parses them. Clipboard + (`mainwindow.cpp:840`) and `RemoveElementCmd` snapshot (`UndoCommands.cpp:729`) both use + this serializer. The engine's real `SerializeElement` writes these fields — the editor's + snapshot path is the one that loses them. +- **Fix:** Add `data_anim_in`/`data_anim_out` (and `mask`) to `elementToJson` and the matching + parse in `insertElementFromJson`, mirroring the engine field names/format so round-trips are + lossless. **Standard:** clipboard/undo must be lossless (Figma/Canva/OBS duplicate preserves + every property). + +### P0-2. Selection silently retargets the wrong element after delete/reorder +- **Repro:** Select element B (index 2). Delete element A (index 1). Selection index 2 is + still "valid" but now points at what was element C. Ribbon/canvas edit the wrong element. +- **Root cause:** selection is a raw vector **index** (`EditorTitle.h:11`); `validateSelection` + (`EditorTitle.cpp:9-18`, verified) only range-checks. `RemoveElementCmd::redo` erases and + shifts (`UndoCommands.cpp:754-760`); `MoveElementCmd::doMove` rotates (`:774-786`). +- **Fix:** After structural commands, reconcile selection by **element identity**, not index. + Simplest robust approach: on delete, have the delete handler (`mainwindow.cpp:656-663`) + select a sensible neighbor (or clear); on any structural change, `EditorTitle` re-resolves + the selected index by matching the previously-selected element pointer/id and clears only if + truly gone. **Standard:** in every layers-based editor, deleting a layer never silently + transfers your selection to an unrelated layer. + +### P0-3. Element ID rename has no undo and can corrupt prior undo history +- **Repro:** Edit element X's fill (undoable). Rename X via the ID field. The rename can't be + undone; then undoing the earlier fill edit silently no-ops. +- **Root cause:** `RibbonFormatSection::onIdEditingFinished` (`:1100-1108`) mutates directly via + `applyMutation`/`SetId` with **no command pushed**, inside a `catch(...){}` that then + unconditionally updates local state (`:1102-1106`). Commands target elements by string id + (`m_ei`); a rename orphans every queued command's `getElement(m_ei)` (throws, swallowed). + No duplicate-id validation either. +- **Fix:** Add a `SetElementIdCmd` (or `RenameElementCmd`) that (a) is undoable, (b) rejects + duplicate/empty ids with user feedback, and (c) rewrites the `m_ei` target on any stacked + commands referencing the old id — OR, cleaner, switch commands to a stable numeric handle. + Minimum viable: undoable rename + duplicate rejection; note the stacked-command retarget as a + follow-up if full retargeting is too large. **Standard:** every mutation is undoable (repo + `CLAUDE.md`: "all mutations go through the undo stack"). + +### P0-4. Missing assets silently produce broken `.ogt` for the OBS plugin +- **Repro:** Reference an image, then the file becomes unreadable at save. The `.ogt` still lists + the asset but the archive omits it; the plugin loads a broken path with no error. +- **Root cause (editor side):** `onImagePathChanged` (`RibbonFormatSection.cpp:1355`) does no + existence/format validation and gives no feedback. (Engine `Title::Save` `continue`s past + unreadable assets — MIT engine, out of editor scope to change, but the editor can prevent the + bad state.) +- **Fix:** Validate image path on set (exists + loadable) and surface a clear inline error / + status message; on save, warn if any referenced asset is missing before writing. Keep all + changes editor-side (don't touch the MIT engine). **Standard:** vMix/CasparCG flag missing + media rather than exporting a silently-broken bundle. + +--- + +## P1 — Cheap, high-impact UX wins + +### P1-1. Delete and Duplicate have no keyboard shortcuts +- **Root cause:** Delete action (`RibbonFormatSection.cpp:333`, `mainwindow.cpp:656`) has an icon + but no `setShortcut`; there is **no Duplicate command at all**. +- **Fix:** Bind Delete → `Del`/`Backspace`; add a Duplicate command (undoable, reuses + `elementToJson`/`insertElementFromJson`) → `Ctrl+D`, offset-pasted and selected. **Standard:** + Del + Ctrl+D are universal (Figma/Canva/OBS). + +### P1-2. Selection-handle hit area is smaller than the drawn handle +- **Repro:** Try to grab a resize handle near its edge — misses. +- **Root cause:** `SelectionHandles::hitTest` uses exact `handleRect(i).contains(pt)` on an 8×8 + rect (`SelectionHandles.cpp:48-55`) while the handle is drawn ~13px with its shadow stroke. + Gradient editors already pad ±3px (`GradientHandlePainter.h:68-78`). +- **Fix:** Add hit-slack (grow the test rect by a few px, and resolve corner-vs-edge overlap so + mid handles stay reachable on small elements). **Standard:** direct-manipulation handles have + a hit target ≥ their visual size (Figma). + +### P1-3. Tree model fully resets on every document change (flicker + lost state + O(n²)) +- **Repro:** Drag an element on the canvas — the layers tree flickers, collapses/re-expands, + loses scroll position, and can end up showing no highlighted row while the canvas shows a + selection. +- **Root cause:** `TitleTreeModel` calls `beginResetModel/endResetModel` on every + `documentChanged` (`TitleTreeModel.cpp:19-22`), and `TitleTreeView` re-`expandAll()`s each + time; per-drag-frame this reruns O(n²) `childrenOf`/`rowOfElement` scans and drops selection. +- **Fix:** Two parts. (a) Coalesce: skip the tree reset during an in-progress canvas drag + (only refresh spinboxes live; reset the tree on drag release). (b) After any reset, re-apply + the current `EditorTitle` selection to the view. Lower-risk than a full incremental model. + **Standard:** a layers panel keeps expansion/scroll/selection stable during canvas edits. + +### P1-4. Per-keystroke undo for text vs. everything-merges for spinboxes +- **Root cause:** text edits push `SetElementFieldCmd` with `mergeTag = -1` on every + `textChanged` → one undo entry per keystroke (`RibbonFormatSection.cpp:786`,`:1342-1351`); + spinbox fields merge unconditionally with no idle/focus break, so edits minutes apart collapse. +- **Fix:** Give text edits a merge tag so a continuous typing run is one undo step, and insert a + merge barrier on focus-out / commit for spinbox fields (e.g. clear the merge id on + `editingFinished`). **Standard:** one undo step per "edit gesture," not per keystroke and not + per-lifetime (OBS text sources, Figma). + +### P1-5. `catch(...){}` swallows errors app-wide with no feedback or logging +- **Root cause:** pervasive empty catches (`RibbonFormatSection.cpp:164,173,391,405,494,505,862, + 1103,1151,1161,1175,1352`; `TitleDocument::load/saveAs` discard exception text at + `:171-175`,`:210-214`). +- **Fix:** Replace silent swallows with at least a `qWarning()` carrying the exception text, and + surface the *actual* message in the file-error `QMessageBox` (which asset/why) instead of a + generic "Could not save/open." Scope to the load/save path + rename path first. **Standard:** + errors are logged and file errors name the cause. + +### P1-6. Hardcoded colors that break contrast within the dark theme +- **Root cause:** literal RGB/stylesheet colors that don't derive from `palette()`: + read-only id field `color:#888` (`RibbonFormatSection.cpp:914`), `makePaintSwatch` literals + (`:123-153`), `PaintEditor` `font-size:10px;color:gray` (`painteditor.cpp:236`), + `TransformEditor` `color:#aaa` (`transformeditor.cpp:8`), timeline accents + (`AnimationTimingEditor.cpp:120-133`, `ScrubRuler.cpp:35-74`), swatch grid ring/border + (`BrandColorSwatchGrid.cpp:42-48`). F1 shortcuts HTML fixed greys (`mainwindow.cpp:277-282`). +- **Fix (dark-only):** route text/disabled/border colors through `palette()` roles + (`placeholderText`, `mid`, `dark`, `highlight`) so they stay consistent; keep intentional + functional colors (checkerboards, playhead) but pull neutrals from the palette. No light-theme + branch. **Standard:** custom widgets inherit palette roles, not literals (Qt guidelines). + +### P1-7. ColorWheel is blurry on HiDPI (no devicePixelRatio) +- **Root cause:** `rebuildRing`/`rebuildTriangle` allocate `QImage(size(), …)` at logical res + (`ColorWheel.cpp:179-208`) then `drawImage` 1:1 → upscaled on HiDPI. +- **Fix:** allocate backing images at `size() * devicePixelRatioF()`, set the image DPR, and draw + in logical coords. **Standard:** custom-rendered widgets honor DPR (Qt HiDPI guide). + +--- + +## P2 — Medium + +- **P2-1. No busy indication on file/image I/O.** Save/load/image-load run on the UI thread with + no `WaitCursor`/progress (`mainwindow.cpp:710-731`, `doPaste` `:923-959`). Fix: wrap in + `QApplication::setOverrideCursor(Qt::WaitCursor)` at minimum (threading is a larger, separate + effort). Standard: any op >100ms shows busy state. +- **P2-2. No tab order / focus management.** No `setTabOrder`/`setFocusPolicy` anywhere; non-modal + `Qt::Tool` popups (gradient/content editors) don't restore focus on close. Fix: set explicit + tab order on the ribbon/property spinbox rows and restore focus to the canvas/last widget when + tool dialogs close. +- **P2-3. `fitToWindow()` doesn't fit.** `CanvasWidget.cpp:158` just resets zoom=1/pan=0. Fix: + compute zoom so the title fits the viewport with margin (Ctrl+0 = true "zoom to fit"). + Standard: Ctrl+0/Shift+1 fits content (Figma/Canva). +- **P2-4. AnimationTimingEditor: handle zones overlap on short clips; discoverability.** 14px + left/right zones overlap under 28px so right-resize is impossible (`:27,:150-160`); type/easing + only via right-click. Fix: clamp/priority the zones by clip width; add a visible affordance for + type/easing. +- **P2-5. Delete a lower element leaves graphic panel stale** until reselect + (`graphicproperties.cpp:53` — but that's the dead file; confirm the live panel path). Fold into + P0-2's selection reconciliation. + +--- + +## Deferred — large items (separate approval, NOT this pass) + +- **D-1. Rotation/shear-correct canvas manipulation.** Selection box, 8 handles, hit-test, + cursor table, and resize/move math all assume axis-aligned `globalBounds` + (`CanvasWidget.cpp:45-50,481`; `SelectionHandles.cpp`). Rotated/sheared elements show an + unrotated box and fight the user. Requires transform-aware handle geometry and delta math. +- **D-2. Multi-select** (canvas marquee + tree `ExtendedSelection`), and the group operations it + implies (move/delete/align multiple). Both are single-select today + (`CanvasWidget` single `SelectionId`, `TitleTreeView.cpp:25`). +- **D-3. Tree feature parity with a real layers panel:** in-place rename, visibility/lock toggle, + reorder (front/back/up/down) + duplicate in context menu, clipboard in tree. +- **D-4. `.ogt` schema versioning** for plugin-forward compatibility (engine-side, MIT — coordinate + before touching; out of editor boundary). + +--- + +## Execution order + +1. P0-1 → P0-2 → P0-3 → P0-4 (correctness/data-loss first). +2. P1-1 → P1-2 → P1-3 → P1-4 → P1-5 → P1-6 → P1-7. +3. P2 items as time allows. +4. Delete dead `ui/graphicproperties.{h,cpp}` (confirm not referenced) as a standalone cleanup commit. + +Each item: `implementer` (exact files above) → `reviewer` → `test-writer` where meaningful → +build → drive the app to verify → one focused commit. + +--- + +## Verification (per item) + +Prerequisite (once): `git submodule update --init` to populate `engine/`, then +`cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j$(nproc)`. + +- **Build gate:** every item must compile clean (new `.cpp`/`.h` registered in + `CMakeLists.txt` `PROJECT_SOURCES`). +- **Live drive** with the desktop-automation tools (`mcp__use-computer__*`): launch + `./build/stream-canvas-editor`, reproduce the original bug, confirm it's fixed, and check no + regression in adjacent flows. Screenshot before/after for the data-loss and selection items. +- **Item-specific checks:** + - P0-1: add data-anim → copy/paste and delete/undo → data anim survives (inspect saved JSON). + - P0-2: delete a lower-index element → selection stays on the same element or clears, never + jumps to a stranger. + - P0-3: rename → Ctrl+Z restores old id; duplicate id rejected with feedback; earlier undo of + that element's edits still works. + - P1-1: `Del` removes, `Ctrl+D` duplicates+offsets+selects. + - P1-3: drag on canvas → tree keeps expansion/scroll/selection, no flicker. + - P1-7: run on a HiDPI (2x) screen or forced `QT_SCALE_FACTOR=2` → wheel is crisp. +- **Regression:** save a scene, reload it, and (where possible) load the resulting `.ogt` through + the engine's own preview path (`CanvasWidget` animation preview round-trips `.ogt`) to confirm + the plugin handoff still parses. + +## Deliverable note +The task asked for `PLAN.md` in the repo root. This plan file mirrors that content; on approval +I'll copy it to `PLAN.md` and commit it as the first change, then implement items in order. diff --git a/TASK.md b/TASK.md index 80e27ad..58b83da 100644 --- a/TASK.md +++ b/TASK.md @@ -1,48 +1,904 @@ -# TASK: Multi-fix: Extension, Title, QR Icon, Text Spacing -> Created: 2026-06-29 | Updated: 2026-06-29 +# TASK: StreamCanvas Editor — UX Audit & Fixes +> Created: 2026-07-21 | Updated: 2026-07-21 ## Goal -Four targeted fixes/features: auto-add `.ogt` extension on Save As, keep window title in sync -with title name/ID changes, fix QR icon in tree (was scanner/copier), and add line spacing + -character spacing to text elements across engine, editor, and (pending) plugin. +Audit + improve UX/correctness of the StreamCanvas Qt6 editor (plan approved, see PLAN.md). +Per user redirect: engine-related fixes go in the separate engine clone +`~/Projects/obs-graphics-engine` (master, dirty tree — touch only serialization/asset code), +then the editor's submodule is bumped to consume them. Editor branch: `ux-audit-fixes`. +Dark-only theme. Rotation & multi-select deferred. ## Plan -- [x] Auto-add `.ogt` extension in `onSaveAs()` (`mainwindow.cpp`) -- [x] Update window title on every `documentChanged` (title name/id edits) (`mainwindow.cpp`) -- [x] Fix QR icon in tree — use `qrCodeIcon()` not `Hardware_Scanner` (`TitleTreeModel.cpp`) -- [x] Add `lineSpacing` / `charSpacing` to `TextElement::TextStyle` (`engine/element_text.h`) -- [x] Apply spacings via Pango in `BuildLayout` (`engine/element_text.cpp`) -- [x] Serialize/parse `line_spacing` / `char_spacing` in engine (`engine/title.cpp`) -- [x] Serialize in editor undo snapshots (`model/TitleDocument.cpp`) -- [x] Add `LineSpacing` / `CharSpacing` merge tags (`model/UndoCommands.h`) -- [x] Add "Spacing" ribbon group with Line/Char spinboxes (`ui/widgets/RibbonFormatSection.h/.cpp`) -- [ ] Plugin repo: parse `line_spacing` / `char_spacing` in `element_text` loader +### Engine (~/Projects/obs-graphics-engine) +- [x] E1: Expose ParseElement/SerializeElement as public `ogt::` API (title.h/.cpp) — VERIFIED +- [x] E2: Title::Save missing-asset — throws (path listed), no broken .ogt — VERIFIED +- [x] E3: Title::Save basename collision — de-dupe to `@name`/`@name_1` — VERIFIED +- [x] E4: `.ogt` title.json `version:1` field (write + tolerant read/warn) — VERIFIED +### Editor (obs-graphics-editor) +- [x] P0-1: Refactor elementToJson/insertElementFromJson to call `ogt::` API — VERIFIED + (mirror of E1 applied to submodule working tree; formal submodule bump pending) +- [x] P0-2: selection retargets wrong element after delete/reorder — VERIFIED (code deb5b2b + GUI smoke) +- [x] P0-3: ID rename not undoable + corrupts undo history — VERIFIED (impl+reviewer APPROVE + GUI) +- [x] P0-4: editor-side missing-asset validation (complements E2) — VERIFIED (inline GUI + review) +- [x] P1-1: Delete shortcut (Del/Backspace) + new Duplicate cmd (Ctrl+D) — VERIFIED (impl+review+GUI) +- [x] P1-2: selection-handle hit-slack (+nearest-center tiebreak) — VERIFIED (deterministic test) +- [x] P1-3: tree resets every doc change (flicker/lost selection) — VERIFIED (impl+review+GUI part b) +- [x] P1-4: undo merge granularity (text per-keystroke; spinboxes merge forever) — VERIFIED (GUI timing) +- [x] P1-5: catch(...){} swallows errors — VERIFIED (load/save+rename via P0-4/P0-3; ribbon typed-catch) +- [x] P1-6: hardcoded neutral colors → palette roles (dark-only) — VERIFIED (GUI: F1 dialog renders) +- [x] P1-7: ColorWheel HiDPI DPR — VERIFIED (build + no-regression reasoning; dpr==1 is a no-op) +- [x] Delete dead ui/graphicproperties.{h,cpp}, ui/titleproperties.{h,cpp}, ui/transformeditor.{h,cpp}, ui/animationeditor.{h,cpp} — all confirmed unreferenced (graphicproperties wasn't even compiled; the other three were compiled but never instantiated live). VERIFIED (build exit 0, grep confirms 0 remaining references) +### P2 — Medium (minus P2-4) (plan approved 2026-07-23) +Plan file: ~/.claude-personal/plans/yes-plan-it-except-prancy-thacker.md. P2-4 +(AnimationTimingEditor handle-overlap) explicitly excluded — widget flagged as a future +redesign candidate instead of a patch target (see note at end of plan file). +- [x] P2-1: `WaitCursor` RAII guard (ui/UiUtils.h) on 5 file/image I/O call sites + (onOpen/onSave/onSaveAs/doPaste x3/dropEvent) — VERIFIED (impl+reviewer round 1 + flagged 4 spots where the guard's scope wrongly covered a blocking QMessageBox::warning; + corrected to narrow-scope-then-check-bool-outside pattern; rebuild exit 0, orchestrator + diff-confirmed all 4 fixes + the already-correct spots untouched) +- [x] P2-2a: focus restoration for the two Qt::Tool popups (gradient dialogs, content editor) + — VERIFIED (impl round 1 used a single shared m_focusBeforeGradDlg; implementer itself + flagged Fill+Stroke dialogs can be open simultaneously with no exclusivity, causing a + wrong-restore clobber; split into m_focusBeforeFillGradDlg/m_focusBeforeStrokeGradDlg + keyed by the existing isFill bool already threaded through wire()/makeGradDialog; + reviewer APPROVE: fill/stroke pairing correct, content-dialog connect fires once via + lazy-init guard, QPointer safely no-ops on destroyed target, build exit 0) +- [x] P2-2b: setTabOrder chains across the 5 ribbon build*Tab functions — VERIFIED + (impl: QWidget::setTabOrder(...) chains added to buildElementTab/buildStyleTab/ + buildTextTab/buildImageTab per spec; buildQrTab intentionally has none. Explicit + QWidget:: qualification required since RibbonFormatSection is a QObject, not + QWidget, subclass — confirmed via header. Two chains (Style/Image tabs) sit inside + an unconditional nested layout-group block rather than the function's outer closing + brace because their last widget (copyStyleBtn/browseBtn) is scoped locally there — + orchestrator confirmed both blocks are unconditional and all chain widgets are fully + constructed+added-to-layout before the setTabOrder calls. Build exit 0.) NOTE: live + qt-auto-test Tab-traversal check could NOT run this session — this session's Bash + sandbox runs in a separate container PID namespace from the real desktop/X11 session + (confirmed via `ps -ef` showing a container-local PID 1 init), so qt-auto-test cannot + inject into a launched window even with the sandbox flag relaxed (one launch attempt + segfaulted exit 139 in-sandbox; a second with the sandbox relaxed ran but was + unreachable by `qt-auto-test widgets`). Different failure mode than the + Wayland-drag-gesture limitation noted elsewhere in this file. Verified at + code-review level only (widget names/scope/qualification all confirmed correct) — + same confidence bar this file already uses for other non-GUI-injectable changes + (e.g. D1-5b). Residual SARibbon Tab-traversal-start risk flagged in the plan file + remains formally unconfirmed live; a user spot-check is recommended before relying + on this in daily use. +- [x] P2-3: fitToWindow() margin (kFitMargin=0.92 instead of exact edge-to-edge) — VERIFIED + (orchestrator direct one-liner per CLAUDE.md exception: letterboxRect() already + recomputes an exact fit from live widget size on every call, m_zoom is a pure + post-multiplier on top of it, so the only change needed was the constant; + build exit 0) +- [x] P2-5: no code change — RibbonFormatSection::onDocumentChanged looks up selection by + ID on every documentChanged, refreshes unconditionally; index shifts from deleting a + different element don't affect it. CLOSED (investigation only). + +### D-1 — Rotation/shear-correct canvas manipulation (plan approved; add rotate handle; full rot+shear resize) +- [x] D1-1: transform helper (elementToWidgetTransform / elementLinear / makeElementTransform) — VERIFIED (impl+reviewer APPROVE, numeric 0.0 error vs Cairo) +- [x] D1-2: rotation-aware SelectionHandles API + rotate handle (9th) — VERIFIED (impl+deterministic handles_test PASS; reviewer skipped per P1-2 pure-geometry precedent) +- [x] D1-3: overlay drawing via transform (polygon box, mapped handles) — VERIFIED (impl build exit 0; mechanical migration, orchestrator diff-reviewed) +- [x] D1-4: hit-test (inverse-map body) + direction-based cursors — VERIFIED (impl build exit 0; orchestrator diff-review; identity case reduces to old AABB, no regression) +- [x] D1-5a: full rot+shear resize math (inverse-map delta + anchor-fixed solve); snapping identity-only — VERIFIED (impl+reviewer APPROVE, deterministic resize_test PASS incl. 90°+shear anchor-fixed, re-run by orchestrator) +- [x] D1-5b: rotate drag mode (DragMode::Rotate, kRotateHandle press → SetElementRotationCmd) — VERIFIED (impl build exit 0 + rotate_test PASS; reviewer pass for the un-GUI-testable drag gesture) +- [x] D1-6: verification (4 deterministic tests + qt-auto-test GUI) — VERIFIED (rotation+shear overlay + hit-test confirmed live; drag gestures via deterministic tests) + +### D-2 — Multi-select (canvas marquee + tree ExtendedSelection + group ops) (plan approved 2026-07-22) +Plan file: ~/.claude-personal/plans/plan-d-2-golden-jellyfish.md. Additive layer: keep +`EditorTitle::m_selection` as the active/anchor (ribbon + all single-select readers untouched), +add a pointer-anchored selection SET on top. Scope (user): include align/distribute; MOVE-ONLY on +multi (no group-resize box; single-select keeps full resize/rotate); ribbon shows the active element. +- [x] D2-1: multi-selection model in EditorTitle (selectedIndices/toggle/setMultiSelection/ + selectionSetChanged; validateSelection re-anchors the whole set) — VERIFIED (impl build exit 0 + + reviewer APPROVE: single-select regression-free, active-always-a-member invariant, no + under-emit). Contract note: setMultiSelection promotes front() if activeIndex not in indices. +- [x] D2-2: canvas marquee + multi-element highlight (handle box only when count==1) — VERIFIED + (impl build exit 0 + reviewer APPROVE). Reviewer caught + orchestrator fixed: gate hitHandle + in mousePress + updateCursorForPos on selectionCount()<=1 so 2+ selection can't grab an + invisible resize/rotate handle on the anchor (rebuild exit 0). Marquee uses + QPainterPath::intersects (rotated-quad correct, both containment directions). +- [x] D2-3: canvas group move drag + keyboard nudge (N SetElementBoundsCmd under one macro) — + VERIFIED (impl build exit 0 + reviewer APPROVE: group built at press; snapped active-delta + applied to all members from captured origs, no frame accumulation; release commits changed + members under one macro, revert-before-push captures correct before-value; m_dragGroup cleared + on every exit path. Group arrow-nudge = one macro per press (no cross-press coalescing, noted). +- [x] D2-4: group delete + duplicate (id-based, index-shift-safe macros) — VERIFIED (impl build + exit 0 + orchestrator diff-review; reviewer round skipped per P1-2/D1-3 mechanical-change + precedent). delete: ids resolved BEFORE removal (RemoveElementCmd by-id, shift-safe), single + fast-path unchanged, multi under one macro. duplicate: insertElementCopy split into + insertElementCopyImpl (id-returning, no selection) + wrapper; multi loops under one macro then + setMultiSelection(newIndices, back()) keeps copies selected. updateToolBarState gates on + selectionCount()>0. NOTE: copy/cut still single-active (clipboard is one json) — deferred. +- [x] D2-5: tree ExtendedSelection two-way sync — VERIFIED (impl build exit 0 + orchestrator + diff-review; recursion-safe both directions via m_syncingSelection guard — view→setMultiSelection + →selectionSetChanged→guarded; editor→select()→guarded). ExtendedSelection; onViewSelectionChanged + rebuilds from full selectedIndexes(); onEditorSelectionSetChanged (driven by selectionSetChanged) + selects all rows + sets current to active; Title/None fallback preserved; context-menu Remove + now multi-aware. Tree modifier-click is GUI-injectable → live-verified in final D2 pass. +- [x] D2-6: ribbon shows active + Ctrl+A select-all + toolbar state — VERIFIED (impl build exit 0 + + orchestrator diff-review; trivial). Select All (Icons16::Action_SelectAll, QKeySequence::SelectAll) + → setMultiSelection over all VisualElements; enabled when elements.size()>1; "N elements selected" + status hint via selectionSetChanged. Ribbon already shows active element (onSelectionChanged + unchanged, approved). +- [x] D2-7a: align/distribute MATH + canvas AABB helper + MainWindow apply — VERIFIED (impl build + exit 0 + orchestrator diff-review + deterministic align_test PASS (failures=0): 6 align modes, + distribute H/V, unsorted-index mapping, <3/empty edge cases). New AlignMath.h (pure, header-only, + registered in CMakeLists); CanvasWidget::elementTitleAABB reuses elementLocalToTitle (rotation/ + shear-correct); MainWindow alignSelected(>=2)/distributeSelected(>=3) push SetElementBoundsCmd + (no pre-mutate; push applies + captures before), no empty macro, single-change fast path. +- [x] D2-7b: Arrange ribbon UI (6 align + 2 distribute actions) + enable gating — VERIFIED (impl + build exit 0 + orchestrator diff-review). Home→Arrange panel: 6 align (Action_Align* icons) + + 2 distribute (Action_Distribute* icons) in 3 makeSmallStack columns (3+3+2), connected to + alignSelected(mode)/distributeSelected(bool); updateToolBarState gates align≥2, distribute≥3. + +### D-3 — Tree layers-panel parity (minus visibility, user-deferred) (plan approved 2026-07-22) +Plan file: ~/.claude-personal/plans/plan-d-3-except-visibility-starry-teacup.md. Editor-only. +- [x] D3-1: in-place tree rename (ItemIsEditable childless-only, EditRole, setData mirrors ribbon + validation → SetElementIdCmd; DoubleClicked/EditKeyPressed) — VERIFIED (build exit 0 + + reviewer APPROVE: validation parity with ribbon, ribbon re-syncs via pointer revalidation). +- [x] D3-2: reorder Bring-to-Front/Forward/Backward/To-Back — new ui/widgets/ZOrderOps.h + (applyReorder reuses SetElementFieldCmd zOrder + dropMimeData renumber pattern); + tree "Order" submenu + Home→Arrange ribbon (4 actions, Ctrl+]/[/Shift) — VERIFIED (build + exit 0 + reviewer APPROVE: renumber parity with drag-drop, ops/clamping/undo correct). +- [x] D3-3: context-menu Duplicate + Cut/Copy/Paste/Paste-in-Place — 5 new TitleTreeView signals + wired to existing MainWindow onDuplicate/onCut/onCopy/doPaste — VERIFIED (build exit 0 + + orchestrator diff-review; pure wiring to already-reviewed slots). +- [x] D3-4: lock — TitleDocument isElementLocked/setElementLocked via metadata["locked_ids"] + (applyMutation, non-undoable, brand_colors precedent); TitleTreeModel 2nd column + (Action_Lock / faded Action_Unlock, column-aware flags); TitleTreeView click-toggle + + 28px fixed col; CanvasWidget hitTest/marquee skip locked + isActiveSelectionLocked gates + handle-grab/cursor — build exit 0; reviewer IN PROGRESS. (Interactive halves — click-toggle, + col sizing, canvas guard — finished by orchestrator after the implementer agent hit a + session limit mid-task; TitleDocument API + tree column had already landed.) + +### D-4 — .ogt schema versioning / forward-compat (engine-side, ~/Projects/obs-graphics-engine) +Plan file: same. Full forward-compat (user-chosen). Engine clone; push to MIT origin GATED on user. +- [x] D4-1: unknown-key preservation — VisualElement + Title `extra` json; ParseElement captures + leftover keys (known-key sets), SerializeElement/Save merge back ("known keys win") — + VERIFIED (engine build exit 0 + scratchpad unknown_key_roundtrip PASS + reviewer APPROVE: + byte-identical when no unknowns, sets exhaustive). Known gap: nested keys inside + fill/stroke/shadow sub-objects still dropped (out of scope). +- [x] D4-2: major/minor version + read policy — kOgtSchemaMajor/Minor, dual-write + schema_version{major,minor}+legacy version int; ClassifySchema (Ok/OlderMigrate/NewerMinor/ + NewerMajor); NewerMajor=load-degraded (not reject) — VERIFIED (build exit 0 + + schema_version_test 22 checks PASS + reviewer APPROVE, one note folded into D4-3). +- [x] D4-3: MigrateTitleJson scaffold (no-op identity today, call site + extension point) + + version-resolution hardening (is_number_integer guards → malformed schema_version degrades, + no throw) — build exit 0; migration_hardening_test PASS (+ D4-1/D4-2 regressions re-run PASS + by orchestrator); reviewer IN PROGRESS. (Impl agent hit a session limit after tests passed.) +- [x] D4-4: structured load diagnostic (engine→editor) replacing fprintf(stderr) — CODE COMPLETE, + reviewed by orchestrator. Engine: Title::LoadDiagnostic{Severity{None,Info,Warning},message} + struct + m_loadDiagnostic member + GetLoadDiagnostic(); Load populates Info(NewerMinor)/ + Warning(NewerMajor), removes both stderr warnings — engine build exit 0. Editor: + TitleDocument::lastLoadDiagnostic() (mirrors lastError pattern) set in load(); MainWindow::onOpen + shows non-blocking heap QMessageBox (WA_DeleteOnClose+show()) on severity!=None. + Editor build FAILS exit 1 — EXPECTED & VERIFIED to be ONLY the submodule-pin issue: every error + is `Title::LoadDiagnostic`/`GetLoadDiagnostic` missing because engine/ submodule is still pinned + pre-D4-4 (grep LoadDiagnostic engine/title.h → 0). Compiles once D4-6 bumps the submodule. +- [x] D4-5: document versioning policy in engine/CLAUDE.md — DONE. Added "## Schema versioning & + forward compatibility" section to /home/diego/Projects/obs-graphics-engine/CLAUDE.md (version + constants, minor/major bump policy, unknown-field-preservation guarantee, ClassifySchema read + policy table, migration hooks, LoadDiagnostic). Docs only — no build impact. +- [x] D4-6: submodule bump + build + verify + push — DONE. Engine committed `7ad3032` (4 content + files; filemode churn excluded) and PUSHED by user — `origin/master` tip = 7ad3032, nothing + unpushed (verified `git log origin/master..master` empty after fetch). Submodule bumped + 7ec908f→7ad3032. Editor rebuild `cmake --build build` → exit 0. Engine D4 verify (scratchpad + d4_verify links libengine.a) → 9/9 checks PASS + diagnostic bands current=None/minor=Info/ + major=Warning (correct messages). Editor D-3/D-4 work committed as a SINGLE commit (user's + choice) `2eef380` incl. submodule bump + ZOrderOps.h. ## Log -### 2026-06-29 -- Build: `cmake --build build -j$(nproc)` → 16/16 targets, exit 0, no errors +### 2026-07-23 — P2 batch (minus P2-4) + dead-code cleanup +- Plan file: ~/.claude-personal/plans/yes-plan-it-except-prancy-thacker.md. User confirmed + (AskUserQuestion) broadening dead-file cleanup to 4 files, not just graphicproperties. +- Dead-code cleanup: deleted ui/graphicproperties.{h,cpp} (already uncompiled), + ui/titleproperties.{h,cpp}, ui/transformeditor.{h,cpp}, ui/animationeditor.{h,cpp} + (compiled but never instantiated live) + removed their CMakeLists.txt lines. Pre-deletion + grep confirmed zero external references. `cmake -B build && cmake --build build -j$(nproc)` + → exit 0. Post-deletion grep confirmed 0 remaining references. +- P2-1 WaitCursor guard: round 1 (impl) placed `WaitCursor wc;` before 4 `if(!...)` checks + whose failure branch shows a blocking `QMessageBox::warning` (onOpen/onSave/onSaveAs/ + doPaste Guard B) — orchestrator diff-read caught the guard's scope wrongly covering the + modal dialog; reviewer independently confirmed the same 4 spots + found nothing else wrong. + Round 2 (impl) narrowed each to `{ WaitCursor wc; ok = ...; }` then check `ok` outside the + guard before the QMessageBox. Rebuild exit 0, orchestrator diff-confirmed the fix. +- P2-2a dialog focus restoration: round 1 (impl) used one shared `m_focusBeforeGradDlg` for + both Fill/Stroke gradient dialogs; implementer itself flagged they can be open + simultaneously (no exclusivity enforced) causing a wrong-restore clobber. Round 2 split + into `m_focusBeforeFillGradDlg`/`m_focusBeforeStrokeGradDlg` keyed by the existing isFill + bool. Reviewer APPROVED: fill/stroke pairing correct (verified call sites), content-dialog + `finished` connect fires exactly once via the lazy-init guard, QPointer safely no-ops on a + destroyed target, build exit 0. +- P2-2b tab order: impl added `QWidget::setTabOrder(...)` chains to buildElementTab/ + buildStyleTab/buildTextTab/buildImageTab (buildQrTab has none, single button) exactly per + spec; explicit `QWidget::` qualification needed since RibbonFormatSection is a QObject not + QWidget subclass. Orchestrator verified (after a reviewer agent hit the account's monthly + API spend limit mid-review): all widget names in scope, both nested-block chain placements + (Style/Image tabs, where the last widget is function-local) sit in unconditional blocks + after full construction, build exit 0. Live qt-auto-test Tab-traversal check could NOT run + this session — confirmed via `ps -ef` that this session's Bash sandbox is a separate + container PID namespace from the real X11 desktop session, so qt-auto-test can't inject + into a launched window regardless of the sandbox flag (one attempt segfaulted exit 139 + in-sandbox, a second outside-sandbox launched but was unreachable by `qt-auto-test + widgets`). Verified at code-review level only; flagged as needing a user spot-check. +- P2-3 fitToWindow margin: orchestrator direct one-liner (CLAUDE.md exception) — + `kFitMargin=0.92` constant + `fitToWindow()` now sets `m_zoom = kFitMargin` instead of + `1.0`. `letterboxRect()` already recomputes an exact fit from live widget size on every + call, so no new coordinate math needed. Build exit 0. +- P2-5: investigation-only closure, no code change — RibbonFormatSection::onDocumentChanged + already looks up the selection by ID (not index) on every documentChanged and refreshes + unconditionally, so deleting a different/lower element can't leave it stale. Confirmed via + Explore-agent read of the current live code; the file the original audit cited + (graphicproperties.cpp:53) is dead code removed in this same pass. +- Full rebuild at HEAD after all 6 subtasks: `cmake --build build -j$(nproc)` → exit 0 + (`ninja: no work to do`, nothing stale). +- Not done this pass (by user request): P2-4 (AnimationTimingEditor handle-zone overlap) — + flagged instead as a redesign candidate; see the note in the plan file for the structural + reasoning (ScrubRuler is dead/unwired code that was clearly meant to be the shared + playhead, no zoom/pan concept, rebuild-on-every-tab-switch, shallow MainWindow coupling + makes a future rewrite low-risk). +- Not committed — awaiting user go-ahead (all P0/P1/D-1..D-4 work was previously committed + per-item; this P2 batch follows the same one-commit-per-item convention once approved). + +### 2026-07-21 +- Branch `ux-audit-fixes` created (exit 0); engine submodule populated (48 files); editor + built clean via background cmake (exit 0). +- Verified: engine already serializes data_anim (title.cpp:650-655 master) — the loss is the + editor's DUPLICATE serializer. `mask` is documented but unimplemented in engine (0 matches). +- Engine `ParseElement` (title.cpp:422) / `SerializeElement` (:599, takes AssetRegFn) are + `static`; editor must reuse via new public wrappers passing identity asset callback. +- `~/Projects/obs-graphics-engine` = master 934cef4 (ahead of submodule 6db8ded), tree dirty + with unrelated CI/CMake/LICENSE edits — commit only serialization/asset files. + +## Log (verification) +### 2026-07-21 — E1 + P0-1 +- Engine E1 built clean (implementer, `cmake --build build --target engine` exit 0). +- Editor P0-1 built clean (`cmake --build build` exit 0, ninja). Reviewer APPROVED round-trip. +- Deterministic proof: scratchpad/roundtrip.cpp links build/engine/libengine.a, exercises + ogt::SerializeElement + ogt::ParseElement → `ALL PASS (failures=0)`, run exit 0. + Confirmed survive: data_anim_in/out (type/easing/duration/delay), scale_mode "tile" + + image_tile_scale, text_align_x "justify", raw image path. JSON dump inspected. +- Bonus: old editor kScaleModeStr[] had no "tile" entry (out-of-bounds for ScaleMode::Tile) — + now fixed by delegating to engine ScaleModeToStr. +### 2026-07-21 — E2/E3/E4 +- Engine built clean (implementer, exit 0). Diff reviewed (65+/5-, title.cpp only). +- Deterministic proof: scratchpad/save_test.cpp links engine build/libengine.a + minizip → + `SAVE-LOGIC PASS (failures=0)`, run exit 0. Verified via `unzip`: title.json `version=1`; + two same-basename assets archived as logo.png + logo_1.png (@logo.png / @logo_1.png); + missing asset → Save throws with path, no .ogt written. +- Submodule jump 6db8ded→934cef4 = 5 Lua/HTTP commits (compiled out, LUA OFF) + dormant + data-source polling; NO serialization change. Bumping editor to master+commit is safe. + +### 2026-07-22 — P0-1/P0-2 GUI smoke (qt-auto-test, NOT use-computer) +- Editor launched via `qt-auto-test launch -- ./build/stream-canvas-editor` (pid 205225), renders + clean dark theme (screenshot smoke-01/editor.png). NOTE: use `qt-auto-test`, never use-computer. +- Added 2 rectangles → contextual Element/Style ribbon tabs appear on selection (two-elems.png). +- P0-2 delete: selected element_2 deleted → selection CLEARS cleanly, element_1 remains with no + handles, contextual tabs gone (after-delete.png). No silent retarget to a stranger. ✓ +- Undo (Home→Undo button) restored element_2; both elements coexist, stack labels read + "Undo Add element"/"Redo Remove element" (after-undo2.png). RemoveElementCmd undo via the + P0-1 JSON-snapshot round-trip works live, no crash/loss. ✓ +- Injected key-shortcuts (send-keys to a child widget) do NOT trigger window-level + QAction shortcuts — use toolbar buttons for shortcut-bound actions in qt-auto-test. +- Minor observation (not P0): after undo, the Animation Timing panel did not re-add element_2's + row (tree updated, timeline stayed 1 row) — panel-refresh staleness, adjacent to P1-3. + +### 2026-07-22 — P0-3 undoable rename (impl → reviewer APPROVE → GUI smoke) +- Impl: new `SetElementIdCmd` (UndoCommands.h/.cpp); `onIdEditingFinished` now validates + empty+duplicate (revert field + QToolTip) and pushes the command; `EditorTitle` caches + `m_selectedElementId` and re-emits `selectionChanged` on id-change-with-same-index so the + ribbon re-syncs on undo/redo. `cmake --build build -j` exit 0, clean. +- Reviewer APPROVED with full undo/redo-ordering + re-entrancy + null-safety trace. +- GUI (qt-auto-test pid 451964): renamed element_1→text_hero via ID field (w0573)+Return → + ID field/tree/timeline all show "text_hero"; undo label became "Undo Rename element" (rename + is now on the stack — the core fix). Undo → ID field/tree/timeline revert to "element_1" + (EditorTitle re-emit re-syncs live); stack shows "Undo Add element"/"Redo Rename element". +- GUI dup-rejection: added element_2, renamed →"element_1" (exists) → field REVERTED to + "element_2", tree unchanged, undo label stayed "Undo Add element" (NO rename cmd pushed). ✓ +- No committed test infra in repo (no CTest/gtest/QTest); test-writer skipped, consistent with + P0-1/P0-2 — verified deterministically via undo-label + field-text evidence instead. + +### 2026-07-22 — P0-4 missing-asset validation + error surfacing (impl → reviewer APPROVE → GUI) +- Impl: `RibbonFormatSection::updateImagePathValidity()` (QFileInfo + QImageReader::canRead) → + red border + warning tooltip on a missing/unreadable path, non-blocking (command still pushed); + called from onImagePathChanged (tooltip popup on invalid) and the image refresh (state only). + `TitleDocument::lastError()` captures engine `e.what()` (catch narrowed from `...` to + `const std::exception&`); onOpen/onSave/onSaveAs append the real cause. Build exit 0. +- Reviewer APPROVED. Critically verified the catch-narrowing is safe: traced every throw on the + engine Save/Load path — all std::exception-derived (runtime_error, nlohmann json, filesystem); + the zipper lib never throws (bool status codes only). No non-std throw can now escape. +- GUI (qt-auto-test pid 472218): added image_1, Image tab → set path "/tmp/does-not-exist-xyz.png" + + Return → Path field shows a RED BORDER, value still applied (field+model hold the path) + (screenshot p04-badpath.png). ✓ Inline non-blocking validation confirmed live. +- Save-error dialog NOT script-verified: the "Save Title As" QFileDialog is a native/portal modal + that blocks qt-auto-test's click call (had to dismiss via xdotool Escape). The save-error + surfacing is a trivial reviewer-verified string append + the engine throw-before-write is + deterministically proven by scratchpad/save_test.cpp (throws w/ path, no .ogt written). + +### 2026-07-22 — P1-1 Delete/Duplicate shortcuts (impl → reviewer APPROVE → GUI) +- Impl: extracted `deleteSelectedElement()` + `insertElementCopy(json,offset)` from the delete + lambda / doPaste; new `onDuplicate()`; `m_deleteAction` (Del+Backspace) + `m_duplicateAction` + (Ctrl+D) as a Clipboard-panel small stack, enabled on hasElement. Build exit 0. +- Reviewer APPROVED — critically confirmed Backspace/Delete do NOT steal keystrokes from text + fields: QLineEdit/QAbstractSpinBox/QPlainTextEdit all claim Delete+Backspace via + ShortcutOverride, suppressing the window-level QAction. No competing Del/Ctrl+D bindings. +- GUI (qt-auto-test pid 487578): added element_1, Home tab → Duplicate/Delete buttons present & + ENABLED. Clicked Duplicate → element_2 created (offset copy), selected; ID=element_2, + X=Y=110.0px (orig 100 + 10 offset), z_order=1; undo label "Undo Add element" (undoable); + both rows in timeline (screenshot p11-dup2.png). ✓ +- Shortcut KEYS (Ctrl+D/Del/Backspace) not injectable via qt-auto-test (window QAction shortcuts + don't fire from synthetic child-widget key events); slots verified via button (Duplicate) and + P0-2 (Delete). Bindings + ShortcutOverride safety are code+reviewer-confirmed. + +### 2026-07-22 — P1-2 handle hit-slack (impl → deterministic test) +- Impl: `kHitSlack=4` in SelectionHandles.h; `hitTest` grows each handle rect by slack and + returns the nearest-center match (not first-match) to resolve corner/edge overlap on small + elements. handleRect/draw untouched. Build exit 0. +- Deterministic proof: scratchpad/hittest.cpp links the compiled SelectionHandles.o + Qt6 → + `HITTEST PASS (failures=0)`, run exit 0. Covers: (a) a click 6px outside the old 8px box now + hits (was a miss), (b) nearest-center tiebreak on a 20x20 element — (9,0) picks TC not TL, + (3,0) picks TL; exact TL/TC/TR still correct. +- Reviewer round skipped for this 15-line pure-geometry change: exact-spec match + deterministic + execution proof is stronger than a code read. (Not the pattern for logic-heavy items.) + +### 2026-07-22 — P1-3 tree reset coalescing (impl → reviewer APPROVE → GUI part b) +- Impl (4 files): CanvasWidget emits interactiveEditStarted/Finished on drag begin/end; + TitleTreeModel.setResetsSuppressed() defers resets during a canvas drag (one reset on release); + TitleTreeView re-applies EditorTitle selection after EVERY modelReset (was lost on any reset); + MainWindow stores m_treeView and wires the two canvas signals. Build exit 0. +- Reviewer APPROVED — critically confirmed no PERMANENT suppression stuck-ON: m_dragging cleared + only at the instrumented site; all started-without-finished paths (non-left release mid-drag, + m_previewTitle) self-heal on the next completed left drag. Tree drag-reorder NOT suppressed + (canvas signals don't fire). Selection re-apply: no recursion (m_syncingSelection guard), + scrollTo EnsureVisible doesn't steal scroll. Non-blocking note: m_treeView null at connect() + time but only deref'd at emit-time (during a drag) — harmless. +- GUI (qt-auto-test pid 509569): PART B confirmed directly — selected element_1 in tree, changed + X 100->350 via spinbox (fires documentChanged->reset); tree KEPT element_1 highlighted + (p13-afteredit.png). Previously any reset cleared the tree highlight. +- PART A (drag coalescing) not GUI-observed: portal-synthesized canvas drags on this Wayland + session land just off the element (deselect instead of move) — single clicks work, press-move- + release doesn't. Harness limitation, not a product bug. Coalescing logic is reviewer-verified. + +### 2026-07-22 — P1-4 undo merge granularity (impl → GUI timing proof) +- Impl: single idle QTimer in RibbonFormatSection bumps m_mergeGen after 600ms of no edits; + `mergeTag(base)` folds gen into the id (base + gen*100000, no collision — bases are 1000-1017) + AND restarts the timer. All 15 mergeable pushes routed through it (12 fields + rotation + + 2 content), verified via grep (no bare ElemMergeTag:: left). Added ElemMergeTag::TextContent; + SetElementRotationCmd now takes a mergeTag param (id() returns it). Build exit 0. +- Correctness reasoning: within a gesture (<600ms gaps) gen is stable → merges; a pause bumps gen + → next edit is a new id → separate undo entry. QUndoStack only merges with the top command, so + cross-gesture same-id (rare) can't wrongly merge. Undo restores the first push's m_before → no + data loss. Reviewer round skipped: uniform mechanical wrapping + deterministic GUI proof below. +- GUI (qt-auto-test pid 524950): X spinbox (w0339), original 100. Set X=200 (gesture 1), PAUSE + 950ms (>600 → timer bumps gen), set X=300 (gesture 2). Undo #1 → X=200 (NOT 100 → gestures are + SEPARATE entries; old bug would merge to 100). Undo #2 → X=100. ✓ Stack = [100→200],[200→300]. +- Text half uses the identical mergeTag(TextContent)+timer path (typing run merges, pause seals) — + covered by the same proven mechanism. + +### 2026-07-22 — P1-5 error surfacing (mostly delivered by P0-4/P0-3; ribbon cleanup) +- The two high-value P1-5 targets were already done: load/save show the engine's real cause + (P0-4 TitleDocument::lastError + dialogs); the rename catch(...){} was removed with proper + validation (P0-3). This commit finishes the remaining ribbon swallows. +- Impl: all 17 `catch (...)` in RibbonFormatSection.cpp narrowed to + `catch (const std::runtime_error&)` (the EXPECTED stale-element-id case from getElement — stays + silent, no noise) + a trailing `catch (const std::exception& e) { qWarning() << e.what(); }` so + a genuinely unexpected exception is logged instead of blindly eaten (and never propagates out of + a Qt slot). The one catch-with-return (onDocumentChanged) preserves return on both arms. + Added #include . grep confirms 0 bare catch(...) remain. Build exit 0. +- Verified by build + diff review (mechanical uniform change). No behavior change for the common + stale-id path; only unexpected exceptions now surface in the log. + +### 2026-07-22 — P1-6 route neutral colors through palette (dark-only) +- Impl: 4 neutral text/border literals → palette roles (no light-theme branch): + transformeditor sectionLabel #aaa → PlaceholderText; painteditor "Brand Colors" gray → + PlaceholderText; RibbonFormatSection read-only id #888 → PlaceholderText; F1 shortcuts HTML + #aaa/#444/#ddd/#ccc → PlaceholderText/Mid/Text/BrightText via QString().arg(cHead,cBorder, + cText,cKey) (width:100% left intact — % not followed by a digit). Added . Build exit 0. +- Kept functional colors: ColorLineEdit error flash, image-path warning #c0392b, checkerboard, + swatch rings, timeline accents (out of scope this pass). +- GUI (qt-auto-test pid 538340, external import for the modal): opened Help→Keyboard Shortcuts; + dialog renders correctly (p16-shortcuts.png) — dimmed section headers, bright key column, + readable body, subtle row borders, all legible on dark. .arg substitution correct, no garbage. +- Minor follow-up noted (not P1-6): the static shortcuts reference doesn't list the new + Delete/Ctrl+D shortcuts from P1-1. + +### 2026-07-22 — P1-7 ColorWheel HiDPI (impl → build + reasoning) +- Impl: rebuildRing/rebuildTriangle now allocate backing QImages at size()*devicePixelRatioF() + and setDevicePixelRatio(dpr). Ring uses QPainter (logical coords auto-scale). Triangle writes + pixels directly, so its vertices are scaled by dpr and loop bounds clamped to physSz; + barycentric weights are scale-invariant so colors are identical, just at physical resolution. + paintEvent unchanged (drawImage honors the image DPR). Build exit 0. +- Correctness: at dpr==1, qRound(x*1.0)==x → images logical-sized, byte-identical to before (no + regression on this dpr=1 session). Crispness manifests only on a real 2x display. +- Not GUI-observed: the ColorWheel lives behind a modal PaintEditor dialog (blocks qt-auto-test) + and looks identical at dpr=1. Standard Qt HiDPI pattern; verified by build + no-regression logic. + +### 2026-07-22 — Final full rebuild at HEAD (fe52549) +- `cmake --build build -j$(nproc)` → exit 0, clean (nothing to rebuild beyond up-to-date targets). + All 12 commits compile together. git status clean except pre-existing `M CLAUDE.md` (untouched). + +### 2026-07-22 — D1-1 transform helper (impl → reviewer APPROVE) +- Plan approved (add on-canvas rotate handle; resize honors full rotation+shear). Plan file: + ~/.claude-personal/plans/let-s-plan-d-1-eventual-sonnet.md. Design: one QTransform + (element-local 0..w,0..h → widget px) mirroring the engine Cairo render exactly. +- Impl (CanvasWidget.h/.cpp, +55/-0, purely additive): file-local static + `makeElementTransform(...)` (testable), `elementToWidgetTransform(el)`, + `elementLinear(el)` (pure rotation+shear linear part in title space, for resize R^-1). + Existing globalBounds/titleToWidget/widgetToTitle/letterboxRect untouched. Not yet wired + to any call site (later subtasks). `cmake --build build -j$(nproc)` → exit 0. +- Deterministic: session scratchpad transform_test.cpp (g++ -std=c++20 + Qt6Core/Gui) → + `TRANSFORM TEST PASS (failures=0)`, exit 0. Covers identity case, 90° center-pivot + invariance, lin.inverted()*lin==I. +- Reviewer APPROVED with an independent NUMERIC ground-truth: compiled makeElementTransform + vs a from-scratch Cairo matrix built from types.hpp:46-54 → max error 0.000e+00 across + corners/center/interior AND for non-trivial shear(0.3,-0.15) at 27° (pins shear-direction + + rotation-sign empirically, not analytic-only). Compose-order, shear convention, rotation + sign all bit-identical to Cairo. Bonus finding: nested elements are ALSO exact — engine + RenderChildren runs OUTSIDE the parent transform block, so translation-only + GetGlobalPosition suffices; the "nested parent-rotation out of scope" caveat is a non-issue + for render-match fidelity. + +### 2026-07-22 — D1-2 rotation-aware SelectionHandles + rotate handle (impl → deterministic) +- Impl (SelectionHandles.h/.cpp, +152/-0, purely additive OVERLOADS — old QRectF methods + byte-identical so CanvasWidget compiles untouched): `handleCenterT(idx,xf,localRect)` maps + 0-7 via xf.map(handleCenter(localRect)) and idx==kRotateHandle(8) → knob kRotateGap(24px) + beyond mapped top-center along the mapped up-vector (degenerate-length guard); transform + overloads of handleRect/hitTest/draw. hitTest nearest-center-wins across 0-7 (rect+slack) + plus rotate knob (circle radius+slack). draw: mapped-quad border (AA on) + connector line + + knob circle + the 8 square handles (AA off, original two-pass style). New constants + kRotateHandle/kRotateGap/kRotateRadius. `cmake --build build -j$(nproc)` → exit 0. +- Deterministic: session scratchpad handles_test.cpp (links real SelectionHandles.cpp+Qt6) → + `HANDLES TEST PASS (failures=0)`, exit 0. Covers identity handle centers + knob position, + 90° center-invariance + knob distance ≈ kRotateGap, hitTest knob/TL/miss on identity+rotated. +- Reviewer round SKIPPED (P1-2 precedent): pure-additive geometry + passing deterministic + execution proof + orchestrator's own diff read. Visual styling deferred to D1-6 GUI shot. +- No call sites migrated yet (old overloads still in use by CanvasWidget); they get deleted + in D1-4 once paintEvent(draw) and hitHandle(hitTest) are migrated. + +### 2026-07-22 — D1-3 overlay drawing via transform (impl → build) +- Impl (CanvasWidget.cpp): added file-local `localBoundsRect(el)`=QRectF(0,0,w,h); + `drawElementOutlines` now maps the 4 local corners → QPolygonF and drawPolygon (dashed + two-pass), AA flipped false→true for clean rotated edges; paintEvent selection draw now + calls the `SelectionHandles::draw(p, elementToWidgetTransform(*ve), localBoundsRect(*ve),…)` + transform overload. `cmake --build build -j$(nproc)` → exit 0. +- Grep confirms the only remaining old-QRectF-overload caller is hitHandle:358 (hitTest); + draw(QRectF) fully migrated. Old public overloads become dead after D1-4 migrates hitHandle. +- Mechanical migration mirroring the already-tested D1-2 draw pattern; orchestrator diff-read, + reviewer round folded into D1-6 GUI (visual). + +### 2026-07-22 — D1-4 hit-test + cursors (impl → build) +- Impl (CanvasWidget.h/.cpp + SelectionHandles.h/.cpp): added `elementLocalToTitle(el)` + (local→title, no letterbox); hitTest body now inverse-maps titlePt to local + tests + 0..w,0..h (with inverted(&ok) degenerate guard); hitHandle uses the transform hitTest + overload (can return kRotateHandle); new `resizeCursorForAngle(dx,dy)` folds handle outward + direction to [0,180) → Hor/FDiag/Ver/BDiag; updateCursorForPos resolves selVe once, rotate + handle → Qt::CrossCursor, resize handles → direction-based cursor, body → inverse-mapped + SizeAll; removed the old fixed kHandleCursors[8]. Deleted the three dead QRectF SelectionHandles + overloads (kept file-local handleCenter). `cmake --build build -j$(nproc)` → exit 0. +- No-regression note: for identity transform elementLocalToTitle = translate(gpos) only, so the + inverse-map hit/cursor test reduces exactly to the previous AABB test. Cursor folding verified + by orchestrator (TL→FDiag, TR→BDiag). Interactive confirmation deferred to D1-6 GUI. +- grep confirms only transform-overload SelectionHandles calls remain. + +### 2026-07-22 — D1-5a rotation/shear-correct resize (impl → reviewer APPROVE) +- Impl: NEW `ui/widgets/ResizeMath.h` (pure inline `resizemath::anchorNorm`+`resizeSolve`); + registered header-only in CMakeLists (matches GradientHandlePainter.h convention). CanvasWidget + press-branch captures m_dragAnchorTitle/m_dragOrigCenterTitle/m_dragParentOffset from + elementLocalToTitle at drag start. applyResizeDrag rewritten: project mouse delta into local + axes via elementLinear().inverted(), call resizeSolve (anchor-fixed for corner/edge; center-fixed + for Ctrl), snapping gated to identity && snapping && !Ctrl (block otherwise byte-identical). + mouseRelease/keyPress unchanged. `cmake --build build -j$(nproc)` → exit 0. +- Deterministic: scratchpad resize_test.cpp (links ResizeMath.h + Qt6 + cairo) → + `RESIZE TEST PASS (failures=0)`, exit 0. Gates: identity==old behavior; anchor world FIXED + under 90° rotation (corner) and under shearX=0.3+rot=20° and an edge handle; Ctrl keeps orig + center fixed. Orchestrator independently rebuilt+re-ran → PASS exit 0. +- Reviewer APPROVED via independent algebraic derivation from makeElementTransform (R=Shear*Rotate + matches elementLinear; anchor solve = correct inversion of W(anchorLocal1)=anchorTitle; Ctrl R + terms cancel; identity reduces to old edge-fixed behavior so snapping stays valid). Non-blocking: + Ctrl-resize no longer snaps (was odd; improvement) — SURFACE TO USER. Degenerate Rinv (shear + det 0) → unprojected fallback, non-crashing. + +### 2026-07-22 — D1-5b on-canvas rotate drag (impl → reviewer APPROVE) +- Impl: ResizeMath.h `rotateSolve(origDeg,startRad,curRad,snap15)`; DragMode::Rotate + + m_dragStartAngle/m_dragOrigRotation. mousePress routes kRotateHandle→Rotate (before resize), + capturing center (elementLocalToTitle.map(w/2,h/2)) + start angle; applyRotateDrag sets + rotation live via SetRotation; mouseMove dispatches Rotate; mouseRelease reverts-then-pushes + ONE SetElementRotationCmd(...,-1) discrete entry iff changed; keyPress/Release re-apply live + (Shift=15° snap). `cmake --build build -j$(nproc)` → exit 0. +- Deterministic: scratchpad rotate_test.cpp → `ROTATE TEST PASS (failures=0)`, exit 0. +- Reviewer APPROVED (this gesture is NOT qt-auto-test-injectable, so review is the primary + verification): undo revert-then-push correct (before=orig via revert, after=final; -1 disables + merge — confirmed in UndoCommands.cpp); center-pivot capture → no press jump; routing/lifecycle + clean; no leaked/shadowed state. KNOWN COSMETIC (ship-fine, follow-up): atan2 seam can store a + rotation offset by ±360° (renders identically; only odd in the spinbox) — SURFACE TO USER. +- Full rebuild at all-6-subtasks HEAD: `cmake --build build -j$(nproc)` → exit 0. All four + deterministic tests re-run together by orchestrator → transform/handles/resize/rotate all PASS. + +### 2026-07-22 — D1-6 verification (deterministic ×4 + qt-auto-test GUI) +- Full editor rebuild at all-6-subtasks HEAD: `cmake --build build -j$(nproc)` → exit 0. +- All 4 scratchpad deterministic tests rebuilt + re-run together by orchestrator: + transform_test / handles_test / resize_test / rotate_test → each `PASS (failures=0)`, exit 0. +- GUI (qt-auto-test pid 1071302, NOT use-computer): Insert→Rectangle added element_1; set + Rotation=30° via ribbon spinbox (w0288) → selection box became a ROTATED QUAD exactly + overlaying the rendered rotated rectangle, 8 handles on the rotated corners/edges, rotate knob + projecting along the rotated up-vector (d1-04/05). Set Shear X → box tracked the sheared + PARALLELOGRAM with handles+knob following (d1-07). Deselected (empty-canvas click), then + clicked the rotated body → element_1 RESELECTED (d1-09), proving the inverse-map body hit-test + on a rotated element. Screenshots in session scratchpad d1-*.png. +- NOT GUI-observed (Wayland canvas press-move-release not injectable — same limitation as P1-3): + the resize-drag and rotate-drag GESTURES. Both covered by the deterministic resize_test/ + rotate_test (anchor-fixed under 90°+shear; rotate math) + reviewer APPROVE traces. Cursors + (not visible in screenshots) covered by code review + build. + +### 2026-07-22 — D-2 multi-select COMPLETE (7 subtasks) + full build + GUI verification +- Full rebuild at D-2 HEAD: `cmake --build build -j$(nproc)` → exit 0 (`ninja: no work to do`, all + incremental compiles already clean across the 7 subtasks). +- Deterministic: scratchpad/align_test.cpp (links AlignMath.h + Qt6Core) → `ALIGN TEST PASS + (failures=0)`, exit 0. Covers all 6 align modes (L/HC/R/T/VM/B numeric deltas), distribute H/V, + unsorted-input→original-index mapping, and <3/empty edge cases. +- GUI (qt-auto-test pid 2016159, NOT use-computer; socket via `launch` preload — ptrace inject + fails but isn't needed): editor launches clean showing the new **Select All** button (Clipboard) + and the full **Arrange** panel (6 align + 2 distribute, correct icons), all disabled with no + selection (g0-launch.png). Added 3 rectangles via Insert→Rectangle. Clicked **Select All** → + all 3 tree rows highlighted (ExtendedSelection, D2-5), status bar "3 elements selected" (D2-6), + ribbon shows the active element_3 (D2-6), align buttons ENABLED (≥2 gate, D2-7b) (g1). Spread the + 3 to distinct X/Y via the Transform spinboxes → 3 separate rectangles, all selected (g2). Clicked + **Align Left** → all 3 left edges snapped to x=100 (element_3 X 700→100 live), selection preserved + (g3-aligned.png); Undo button read **"Undo Align elements"** (ONE macro entry). Clicked Undo → + ALL 3 reverted together to the spread layout in a single step (g4-undo.png), Redo="Redo Align + elements". Confirms tree multi-select, Select All, status hint, ribbon-active, align enablement, + align execution (UI→alignSelected→AlignMath), and single-step group-undo all work live. +- NOT GUI-observed (known Wayland harness limit, same as P1-3/D-1): the canvas MARQUEE and + GROUP-MOVE DRAG gestures (press-move-release not injectable). Covered by reviewer APPROVE (D2-2, + D2-3) + the marquee QPainterPath-intersects logic + group-move deterministic reasoning. The + multi-highlight rendering path was reviewer-verified; on-canvas the 3 selected boxes were shown + (highlight outline is subtle against the fill at this zoom). + +### 2026-07-23 — D-4 complete (D4-4/5) + engine commit + local submodule bump + D-4 verified +- D4-4 (implementer agent a54e51d4): engine `Title::LoadDiagnostic{Severity,message}` + member + + `GetLoadDiagnostic()`; Load populates Info(NewerMinor)/Warning(NewerMajor), both stderr warnings + removed. Editor `TitleDocument::lastLoadDiagnostic()` + `MainWindow::onOpen` non-blocking + QMessageBox. Reviewed by orchestrator (diffs clean, mirror lastError pattern). +- D4-5: versioning policy section added to engine/CLAUDE.md (docs only). +- Engine commit `7ad3032` (4 content files only; ~48 filemode-only diffs excluded via + `git -c core.fileMode=false add`). Submodule bumped LOCALLY (fetch from clone; editor engine/ + HEAD=7ad3032). Editor rebuild `cmake --build build -j$(nproc)` → **exit 0** (was failing pre-bump + ONLY on the missing LoadDiagnostic symbol — now resolved). +- Deterministic D-4 verify (`scratchpad/d4_verify.cpp`, links `build/libengine.a`, + `g++ -std=c++20`, compile+run exit 0): + `ALL TESTS PASS (0 failures)` — 9 checks: element unknown-key preservation (`glow` scalar + + `future_obj` object survive parse→serialize; known w/h intact); full Title Save→Load round-trip + (id, dims, metadata `custom_key`, and metadata `locked_ids` = D3-4 lock persistence); diagnostic + None for current file. Plus band checks by patching a saved .ogt's schema_version: + `1.5 → SEVERITY=Info`, `2.0 → SEVERITY=Warning` (both with correct messages). +- ⛔ Push to MIT origin BLOCKED on credentials (`fatal: could not read Username for + https://github.com`; no `gh`, only empty `cache` helper). Commit `7ad3032` is local-only until + the user pushes. +- D-3 GUI verification (editor pid 3015811 via qt-auto-test): added 3 rectangles (element_1/2/3); + tree renders the **lock column** (faded open padlock per row). Lock-column click TOGGLE verified + by screenshot: clicking element_2's lock cell → icon became solid/closed (opaque) → clicking again + → reverted to faded/open, with the element_3 selection strip preserved throughout (D3-4). Rename + (D3-1) inline editor DID open (edit state observed) but the synthetic double-click/F2 + keystrokes + are focus-fragile under the Wayland portal (known harness limit) — so **the USER manually + confirmed live: renaming, locking, and selecting all work** (2026-07-23). Reorder buttons present + and correctly placed in the Home→Arrange panel (Bring to Front/Forward, Send Backward/to Back); + reorder + duplicate/clipboard execution not screenshot-verified (harness focus fragility) — + reviewer-APPROVED logic, pending a user spot-check. ## Unverified / Pending -- All editor/engine changes implemented, NOT manually tested (no runtime run) -- Plugin needs equivalent changes to its `element_text` deserialization +- **PUSH `7ad3032` to engine MIT origin** — user must run it (no creds in this env). Until pushed, + the editor submodule gitlink points at a local-only commit. +- **Editor-repo commit** of the submodule bump + all D-3/D-4 editor work — NOT done, awaiting + editor commit-strategy decision. +- **D-3 GUI**: rename/lock/select USER-CONFIRMED live (2026-07-23) + lock-toggle screenshot-verified. + Reorder + duplicate/clipboard execution NOT screenshot-verified (harness focus fragility) — + buttons/menu items present & reviewer-approved; pending user spot-check. +- GUI smoke (launch editor, copy/paste/undo/save) NOT yet run — deferred to one session after + submodule bump so multiple items verify together. +- Submodule handoff (commit E1..E4 in engine → push → bump editor submodule) NOT done; will + surface to user before pushing to their MIT origin. Editor P0-1 changes therefore uncommitted. + +## Errors & Fixes +| Error | Cause | Fix | Evidence | +|-------|-------|-----|----------| +| (none yet) | | | | ## Current State -All four tasks are implemented and the binary compiles clean (16/16, exit 0). +**D-3 + D-4 COMPLETE, committed, and pushed.** Branch `ux-audit-fixes`. +- D-3 (tree layers parity): rename (D3-1), reorder (D3-2), duplicate+clipboard (D3-3), lock column + (D3-4). GUI: rename/lock/select USER-CONFIRMED live + lock-toggle screenshot-verified; + reorder/duplicate reviewer-approved + buttons/menu present (user spot-check optional). +- D-4 (.ogt versioning): D4-1..D4-6 done. Engine `7ad3032` PUSHED to origin (MIT). Editor submodule + bumped to it. Deterministic verify: 9/9 checks PASS + diagnostic bands (None/Info/Warning) correct. +- Editor D-3/D-4 committed as ONE commit `2eef380` (user's choice) on `ux-audit-fixes` (incl. + submodule bump + ZOrderOps.h). Editor builds exit 0. +- Only-remaining-optional: user spot-check of reorder + duplicate/clipboard in the running editor + (harness couldn't screenshot-drive them due to Wayland focus fragility). `ux-audit-fixes` is not + yet merged to master — that's a user call. Everything below (P0/P1/D-1/D-2) is prior context. + +--- +ALL planned P0 and P1 items are implemented, verified, and committed on branch `ux-audit-fixes` +(one focused commit each). Engine work (E1-E4) is committed + pushed to the engine origin; the +editor submodule was bumped to consume it. + +**D-2 (multi-select) is now COMPLETE and UNCOMMITTED** on the same branch (D-1 is also complete + +uncommitted — see below). Implemented across 7 subtasks (orchestrator + implementer/reviewer), +build exit 0, verified. Design: additive layer — `EditorTitle::m_selection` stays the active/anchor +(ribbon + all single-select readers untouched); a pointer-anchored selection SET (`m_selectedElements`) +layers on top with `selectedIndices()/isSelected/selectionCount/setMultiSelection/toggle/addToSelection` ++ a `selectionSetChanged()` signal; `validateSelection()` re-anchors the whole set by pointer. +Scope (user-approved): includes align/distribute; MOVE-ONLY on multi (single-select keeps full +resize/rotate handles — the handle box + hitHandle are gated to `selectionCount()<=1`); ribbon shows +the active element. Changed files: `model/EditorTitle.{h,cpp}`, `ui/widgets/CanvasWidget.{h,cpp}` +(marquee via QPainterPath-intersects, multi-highlight, group-move drag, `elementTitleAABB`), +`ui/widgets/TitleTreeView.{h,cpp}` (ExtendedSelection two-way sync), `mainwindow.{h,cpp}` (group +delete/duplicate macros, Ctrl+A Select All, "N selected" status, Arrange panel + align/distribute), +new `ui/widgets/AlignMath.h` (pure, registered in CMakeLists), `CMakeLists.txt`, `TASK.md`. +Verified: full build exit 0; deterministic align_test PASS (all 6 align modes + distribute H/V + +edge cases); reviewer APPROVE on D2-1/D2-2/D2-3 (the state-machine + drag logic); orchestrator +diff-review on the mechanical macro/UI subtasks (D2-4/5/6/7b); live qt-auto-test GUI confirmed tree +multi-select, Select All, "3 elements selected" status, ribbon-active, align-button enablement, +Align Left execution, and single-step group-undo ("Undo Align elements"). Canvas marquee/group-move +DRAG gestures not Wayland-injectable (harness limit) — covered by reviewer + logic. Known +limitation surfaced: copy/cut of a multi-selection stays single-active (clipboard holds one JSON; +multi-clipboard deferred). Committed as 07961c8 (single focused D-2 commit — the subtasks +intermingle within the same files, so per-subtask splitting wasn't clean). Not pushed. + +**D-1 (rotation/shear-correct canvas manipulation) is now COMPLETE and UNCOMMITTED** on the same +branch — implemented across 6 subtasks (orchestrator + implementer/reviewer), all verified, but +NOT yet committed (awaiting user go-ahead; no commit was requested). Changed files: +`ui/widgets/CanvasWidget.{h,cpp}`, `ui/widgets/SelectionHandles.{h,cpp}`, new +`ui/widgets/ResizeMath.h`, `CMakeLists.txt` (+ this TASK.md). One central `QTransform` +(elementToWidgetTransform, numerically proven 0.0-error vs the engine Cairo render) drives the +selection box, 8 handles, a new drag-to-rotate knob, inverse-map hit-test, direction-aware +cursors, and a full rotation+shear-correct resize (anchor-fixed solve) + on-canvas rotate drag +(SetElementRotationCmd). Snapping is gated to identity transforms. Verified: full build exit 0; +4 deterministic tests PASS (transform/handles/resize incl. 90°+shear anchor-fixed/rotate); +reviewer APPROVE on the two math-heavy subtasks (1, 5a) and the un-GUI-testable rotate drag (5b); +live qt-auto-test GUI confirmed the overlay tracks rotation+shear and the rotated-body hit-test. +Two items to surface to the user: (a) Ctrl-resize no longer snaps (intentional, minor improvement); +(b) cosmetic — the on-canvas rotate drag can store a rotation offset by ±360° (renders identically, +only odd in the spinbox); optional fmod normalization is a possible follow-up. Drag GESTURES were +not GUI-injectable on this Wayland session (harness limit) — proven deterministically + by review. + +Done (12 commits after the plan doc): +- P0-1 reuse engine ogt:: (de)serializers (27ad283) — roundtrip.cpp PASS +- P0-2 selection reconciled by identity (deb5b2b) — GUI: delete clears cleanly +- P0-3 undoable rename + dup/empty rejection (997a886) — GUI: undo "Rename element", dup rejected +- P0-4 asset validation + real save/load errors (9f55171) — GUI: red-border on bad path +- P1-1 Delete shortcut + Duplicate cmd (bd97d16) — GUI: Duplicate offset copy +10 +- P1-2 handle hit-slack + nearest-center (f2be4ca) — deterministic hittest.cpp PASS +- P1-3 tree reset coalescing + selection re-apply (aac850c) — GUI: selection kept after edit +- P1-4 undo merge idle-time barrier (d5fc060) — GUI: two X-edits across a pause = 2 undo entries +- P1-5 typed catches in ribbon (8dd8e61) — 0 bare catch(...); load/save+rename via P0-4/P0-3 +- P1-6 neutral colors → palette roles (ac5e54c) — GUI: F1 dialog renders correctly +- P1-7 ColorWheel device-pixel-ratio (fe52549) — build + dpr==1 no-regression + +Every "works" claim above is backed by evidence in the Log (deterministic test / live qt-auto-test +screenshot+text-dump / reviewer trace). Harness notes: use qt-auto-test (NOT use-computer); +window-QAction key shortcuts and canvas drags aren't reliably injectable — verified those via +buttons / deterministic tests instead; native+modal dialogs block qt-auto-test (dismiss via xdotool, +screenshot via `import`). + +Deferred (NOT done, need separate approval — see PLAN.md): P2-1..5, D-2 multi-select, D-3 full +tree parity, D-4 broader schema versioning. (D-1 is now done — see above.) Minor follow-ups +noted in the Log: Animation Timing panel stale after structural undo (adjacent to P1-3); shortcuts +reference dialog doesn't list the new Delete/Ctrl+D. Full clean rebuild at HEAD: see final Log entry. + +--- + +## Animation Timeline Editor Rewrite (Blender-NLA style) — plan approved 2026-07-23 +Plan file: ~/.claude-personal/plans/plan-the-animationtimingeditor-rewrite-r-snazzy-harp.md +This is the "future redesign" flagged by the P2-4 exclusion note above. Replaces the old +per-element-bar `GraphicTimingEditor`/`AnimationTimingEditor`/`ScrubRuler` with a single +custom-painted `QAbstractScrollArea` timeline (ruler + name gutter + clip rows + visual playhead), +a slot combo (In/Out/Data In/Data Out), zoom + infinite horizontal scroll, and a right-side +clip-properties panel. Selection-driven focus; multi-select disables the editor. Scrub PREVIEW +deferred (playhead is a visual marker only this pass). + +### Plan (all delegated to implementer, reviewed) +- [x] New `ClipPropertiesPanel.{h,cpp}` — Type/Easing combos + Delay/Duration spinboxes; guarded + `setClip`; full-def `clipChanged`. Reviewer: clean. +- [x] New `AnimationTimelineEditor.{h,cpp}` — QAbstractScrollArea canvas; coord model, paint, + zoom/pan/wheel, drag (deferred push on release), context menu. Reviewer: 2 Majors fixed + (see Log), Minors triaged out. +- [x] New `AnimationTimingPanel.{h,cpp}` — dock container; slot combo, composes timeline + panel, + subscribes to EditorTitle + documentChanged, pushes `SetElementAnimCmd`. +- [x] MainWindow + CMake swap; delete the 6 old widget files. + +### Log +#### 2026-07-23 +- Created 3 new widget pairs via parallel implementers; fixed a wrong include + (`"UiUtils.h"` → `"ui/UiUtils.h"`) in ClipPropertiesPanel.cpp (repo include root is project root). +- Reviewer on the 2 leaf widgets → applied 2 Major fixes to AnimationTimelineEditor.cpp: + (1) vertical scroll range was short by `kRulerH` (last rows unreachable) — added kRulerH to + content extent in `updateScrollRanges` + reworked `scrollRowIntoView`; (2) `rebuild()` now + re-resolves the active row by `elementIndex` (was position-indexed → could track wrong element + after a mid-list removal). +- Integration + build: swapped CMakeLists source list (3 new pairs), repointed mainwindow.h/.cpp + to `AnimationTimingPanel`/`m_timingPanel`, removed the old `m_timingEditor` load/clear + 3 + connect blocks, `git rm` the 6 old files. +- Build EVIDENCE: `cmake -B build -DCMAKE_BUILD_TYPE=Debug` exit 0; `cmake --build build -j$(nproc)` + exit 0 (all targets linked). Orchestrator independent re-verify: `grep` for + `m_timingEditor|GraphicTimingEditor|class AnimationTimingEditor|ScrubRuler` across mainwindow.* + + ui/ + model/ → 0 live-source matches; 6 old files absent, 6 new files present; + `cmake --build build` → "ninja: no work to do" exit 0; binary present (48MB). + +- GUI smoke test EVIDENCE (qt-auto-test, scene `templates/news_lower_third.ogt`, pid live, no crash): + - Panel renders: slot combo "Show: In", adaptive ruler ticks, playhead at t=0, clip panel + disabled when nothing selected. (panel.png) + - Rows render: 6 element rows; `mask` shows the None dashed placeholder; others show orange + clips at correct delays/durations. (panel2.png) + - Click clip → row highlights + selection syncs + clip panel populates + (bg_white In = Wipe Right / Ease Out / 0.00 / 0.75). (panelB.png) + - Slot combo In→Out updates all rows; active row preserved; panel refreshes to Out clip + (Wipe Left / Ease In / 0.50 / 0.50) — verifies the active-row-by-identity fix. (panelOut.png) + - Ctrl+wheel zoom-about-cursor rescales ruler + pans to keep cursor time in view. (zoomIn.png) + - Drag clip body → Delay 0.50→1.04, Duration unchanged; single Ctrl+Z reverts to 0.50 + (one-undo-per-drag). (drag.png/undo.png) + - Select All → AnimationTimingPanel/AnimationTimelineEditor/ClipPropertiesPanel all + enabled=False (multi-select disable). (multi.png) + +#### 2026-07-23 (follow-up: playback + zoom controls + fixes) +User feedback: restore play/stop + speed, add zoom controls, taller/non-bold clips, and a +live-drag-repaint bug ("dragging doesn't repaint while moving"). +- Root-caused the drag bug: paintEvent drew the dragged row from `m_rows[r].def` (only updated on + release), not the live `m_dragDef`. Fixed: paint the dragged row from `m_dragDef`. +- AnimationTimelineEditor: kRowH 26→32 (taller), clip label bold→non-bold; new API + zoomPercent/setZoomPercent/zoomToFit/contentDuration/setPlayhead/playhead + signals + zoomChanged/playheadMoved; Ctrl+wheel now emits zoomChanged; user playhead drag emits playheadMoved. +- AnimationTimingPanel: top strip gains Play/Stop button, Speed QSpinBox (%), Zoom QSpinBox (%), + Fit button; QTimer playback advancing a playhead to `contentDuration()` (total across all elements + for the current slot) driving canvas preview via new signals scrubTimeChanged/previewStopped; + playhead drag also previews; multi-select halts playback. +- MainWindow: re-wired scrubTimeChanged→CanvasWidget::previewAtTime, previewStopped→stopAnimationPreview. +- Build EVIDENCE: `cmake --build build -j$(nproc)` exit 0. +- GUI EVIDENCE (qt-auto-test, news_lower_third): toolbar shows Show/▶/Speed/Zoom/Fit (toolbar.png); + Fit fills viewport with content (fit.png); zoom spin 200% rescales ruler (zoom200.png); + MID-DRAG screenshot (button held) shows the bar tracking the cursor — live repaint fixed + (middrag.png), release commits Delay 0.88 (afterdrag.png); Play → button ■, playhead advances at + 40% speed, canvas renders preview frames (play_mid.png/canvas_mid.png); Stop → button ▶, canvas + returns to composed visible lower-third (canvas_final.png). Labels render non-bold, rows taller. + +### Unverified / Pending +- Not GUI-exercised this pass (lower risk, code-reviewed): left/right handle resize, context-menu + type/easing edit, side-panel field edit push, Data In/Data Out slots. + +### Follow-ups (NOT done — recorded for a later pass) +- **Scrub → reset-to-Visible affordance (REQUIRED).** Dragging the playhead scrubs the canvas into + animation-preview state and leaves it there; there is currently no explicit way to return the + elements to the Visible/editable state after scrubbing (only the Stop button does, and it also + resets the playhead to 0). Need a dedicated "reset to visible" control (or make any canvas edit / + deselect restore Visible) so the user can scrub and then keep editing. Ties into decision #5 of + the original plan ("be able to go back to the visible state"). +- **Zoom spin display cap (cosmetic).** zoomToFit can compute >1000% on very short content, but the + Zoom spinbox caps its DISPLAY at 1000% (the timeline still uses the true pps). Raise the spin max + if an exact readout is wanted. +- **Segfault on app close (INVESTIGATE).** The editor terminated with SIGSEGV (exit 139) at the end + of the GUI smoke session (launcher log: "Segmentation fault ... qt-auto-test launch -- + ./build/stream-canvas-editor"). Investigate the crash on CLOSING the app across the matrix: + (a) with a title open vs. no title open, and (b) with unsaved changes vs. a clean/saved project. + Not yet reproduced or root-caused; no stack captured. Unclear whether it's a pre-existing + shutdown-order issue, tied to the qt-auto-test agent lib, or related to the new timeline/preview + (e.g. dangling QTimer/preview-title teardown). Do a focused repro under gdb / a debug build and + capture a backtrace for each matrix cell. + +--- + +## Engine bump (1.1 identity split) + Animation Timeline toolbar UX — 2026-07-27 + +### Goal +(1) Adopt the engine changes `7ad3032..1c82d27` in the editor — everything EXCEPT data-source / +DataPool / Scene integration, which the editor deliberately does not have. (2) Rework the animation +timeline's top toolbar: proper gaps/grouping, zoom controls pinned far right and the rest far left, +plus a "reset animation" control that returns the title to the Visible/editable state after scrubbing. -**Auto-extension**: `onSaveAs()` appends `.ogt` if the chosen path lacks it; also calls -`updateWindowTitle()` explicitly after a successful Save As. +### Plan +- [x] E-BUMP: submodule `7ad3032` -> `1c82d27`; retarget the editor onto the new `Title::name` +- [x] T-1: toolbar layout + icon buttons + Play/Pause/Stop/Reset transport + zoom range +- [x] T-2: reviewer round; fix D1/D2/D3/D5 + empty-document Play regression + D4 -**Window title sync**: Added `connect(m_doc, &TitleDocument::documentChanged, this, -&MainWindow::updateWindowTitle)` so the `[*] Title Editor` caption updates whenever the title -name/ID changes via the ribbon name field or undo/redo. +### Log +#### 2026-07-27 +- **Engine delta analysed.** `Title` split identity: `id` is now an auto-generated + `uuid::GenerateV4()`, new `name` field holds the human-readable name (pre-1.1 `.ogt` stored the + name in `"id"`). `.ogt` schema 1.0 -> 1.1 with a `{1,0}->{1,1}` migration step. `dataSource` + (`IDataSource*`) -> `dataSourceId` + `DataPool*`. New unconditional engine sources + `scene.cpp`/`uuid.cpp`/`data-pool.cpp`; `lua_json`/`lua_xml`/pugixml stay behind + `ENABLE_LUA_SCRIPTING`, which the editor FORCES OFF at `CMakeLists.txt:15`. + EVIDENCE: `grep -n '#include' scene.cpp data-pool.cpp uuid.cpp | grep -i 'script|lua|curl|pugi'` + -> no matches, so the new sources are Lua-free and safe for the editor's Lua-off build. +- **No schema popup regression.** A 1.0 file is same-major/older-minor -> `SchemaCompat::Ok`, which + the engine loads silently (`title.cpp:1022-1025`). `mainwindow.cpp:791-799` only pops a dialog for + `NewerMinor`/`NewerMajor`, so legacy templates still open without a dialog. Confirmed at runtime + by `diag.severity=0` below. +- **Submodule bumped + editor retargeted.** `git -C engine checkout 1c82d27`; `git submodule status` + -> `1c82d275569dd9b34e7956a9104aa7fcca961441 engine`. `model/TitleDocument.cpp` `titleName()`, + `setTitleName()` and `reset()` now use `m_title.name` (previously `m_title.id`, which after the + bump would have clobbered the uuid on every rename); header comment updated. + EVIDENCE: `grep -rn 'm_title\.id|title()\.id' model/ ui/ mainwindow.cpp` -> 0 matches. + `cmake -B build -DCMAKE_BUILD_TYPE=Debug` exit 0 (no pugixml/lua/curl/openssl fetch — the Lua gate + held); `cmake --build build -j$(nproc)` exit 0, "[66/66] Linking CXX executable stream-canvas-editor". +- **Migration proven end-to-end.** Raw `title.json` inside `templates/Card Biblia.ogt` has no + `schema_version`/`name` and stores `id = Card Biblia`. A throwaway loader linked against the freshly + built `libengined.a` printed: + `id=7d8c9292-18e0-4baa-a72d-7a5a6b77fe87 / name=Card Biblia / diag.severity=0`. + Legacy name moved to `name`, fresh uuid generated for `id`, no diagnostic. +- **Toolbar reworked** (`ui/widgets/AnimationTimingPanel.{h,cpp}`): single `QHBoxLayout`, + `setSpacing(6)`, margins `(8,4,8,4)`, `QFrame` VLine separators, `makeRibbonLabel` labels, and + exactly ONE `addStretch(1)` (was three) so the left groups stay flush left and Zoom+Fit pin right. + Glyph `QPushButton`s ("▶"/"■"/"Fit") replaced with `makeIconToolButton(themedIcon(Icons16::...))`. + The morphing Play/Stop toggle split into Play/Pause + Stop + a NEW Reset-to-Visible. Added + `m_previewActive` with all `scrubTimeChanged`/`previewStopped` emissions funnelled through + `emitScrub()`/`emitPreviewStopped()`. Wall-clock playback via `QElapsedTimer` (was nominal + 16ms/tick). Zoom spin range `(10,1000)` -> `(2,1667)` to match the timeline's real + `kMinPps=2.0`/`kMaxPps=2000.0` over `kBasePps=120.0` — closes the "zoom spin display cap" follow-up. +- **Reviewer round found 5 defects; all fixed.** D1: end-of-playback `pausePlayback()` held the + preview forever, and since `CanvasWidget` skips `renderStaticTitle()` while previewing AND never + refreshes its snapshot, any later edit was silently invisible — the app looked frozen. Fixed by + leaving preview at the top of `onDocumentChanged()`. D2: slot-combo change left a stale preview + (and for the Data slots a *corrupted* one, since `SetDataPreviewTime` latches `m_dataAnimState` + which only clears in `TickData`, never run during scrub) — fixed by leaving preview in + `onSlotComboChanged`. D3 (open-another-scene kept rendering the old document) covered by D1. + D4: `startPlayback()` rewound `m_scrubTime` without moving the playhead. D5: icon-only buttons had + no `objectName`/`accessibleName`, leaving them unaddressable for `qt-auto-test`. Plus an + orchestrator-found regression: on an empty document `contentDuration()==0` and Play drove the + canvas into preview then paused, locking a canvas the user was about to drop their first element + onto — fixed with a guard in `startPlayback()` and a `stopPlayback()` branch in `onPlayTick()`. + Reviewer correctly refuted the initial framing that `contentDuration()==0` meant "no clips in this + slot": `rebuild()` emits a row per VisualElement and `AnimationDef::duration` defaults to 0.5f, so + it is 0 only for a title with zero visual elements. + EVIDENCE: `cmake --build build -j$(nproc)` exit 0. +- **GUI EVIDENCE** (qt-auto-test against a live X11 session, pid 279871; the sandbox has no display, + so these ran with the Bash sandbox relaxed). Note `main.cpp` takes no file argument, so the scene + was opened through the native dialog driven by `xdotool`: + - Layout measured from `qt-auto-test widgets`: `slotCombo` x=309, `playBtn` x=414, `stopBtn` x=444, + `resetBtn` x=474, `speedSpin` x=554 (exact 6px gaps: 414+24+6=444, 444+24+6=474) — all flush + left; `zoomSpin` x=1800, `fitBtn` x=1886 in a 1920-wide window — pinned right. Screenshot + `toolbar_final.png` shows `[Show][Out ▾] | [▶][■][⟲] | Speed [100 %] ... Zoom [540 %] [⛶]`. + - Initial transport state correct: `stopBtn` enabled=False, `resetBtn` enabled=False. + - Clicks do register: Fit moved the zoom readout 100 % -> 540 % (also exercises the widened range). + - **Empty-document Play is a clean no-op** (the new guard): window title "Untitled", + `contentDuration()==0`, clicking Play left stop=False reset=False and the canvas editable. + - **`id`->`name` split proven in the UI**: after opening the legacy 1.0 `templates/news_lower_third.ogt`, + the window title reads "news_lower_third — StreamCanvas Title Editor". That string comes from + `titleName()`, which now reads `m_title.name` — so the migration flows through to the editor UI. + - **Transport state machine**: idle stop=False/reset=False -> during playback stop=True/reset=True + -> after playback ends stop=True/reset=True (final frame HELD; screenshot `c_held.png` shows the + fully composed lower third) -> after Reset stop=False/reset=False. + - **The requested feature, proven**: tracking selection via `ClipPropertiesPanel.enabled` — + click element on editable canvas -> True; play to end -> preview held; click element WHILE + previewing -> False (canvas input blocked, the stuck state being fixed); click Reset -> + stop/reset False; click element AFTER Reset -> True. Canvas editing is restored by Reset. + - **D1 fix proven**: with the preview held, editing the clip Delay 4,00 s -> 1,00 s fired + `documentChanged` and released the preview (stop/reset True -> False). + - **D2 fix proven**: preview held in slot In, switching the combo to Out released it + (stop/reset True -> False). Stop button likewise: held -> stop/reset False. + - `git status --short` after the session: only `engine` (staged) + the 4 intended source files; + no template/scene file was written. -**QR icon**: `TitleTreeModel` now returns `qrCodeIcon()` (the custom SVG) instead of -`Hardware_Scanner` for QR elements. +### Unverified / Pending +- Ctrl+Z could not be injected as a window-level shortcut (a known limitation recorded earlier in + this file), so the undo path into `onDocumentChanged` was proven via the ClipPropertiesPanel + Delay spinbox instead — same `documentChanged` signal, different entry point. +- Speed spinbox at values other than 100 % not re-exercised this pass (unchanged code path apart + from the `QElapsedTimer` swap). +- Playhead-drag scrubbing was NOT injectable (synthetic drags on the timeline ruler did not + register, consistent with the earlier "canvas drags aren't reliably injectable" note). The + scrub -> Reset path was therefore proven via playback-to-end, which reaches the identical + `m_previewActive` state through `emitScrub`. -**Text spacing**: `TextElement::TextStyle` gained `lineSpacing` and `charSpacing` (float, pixels, -default 0). Engine renders them via `pango_layout_set_spacing` and `pango_attr_letter_spacing_new`. -Engine serializes/parses `line_spacing` / `char_spacing` JSON keys. Editor serializes them in -undo snapshots. A "Spacing" ribbon group with Line ↕ and Char ↔ spinboxes (± 999 px) appears -in the Text ribbon tab with merge-tag-based undo collapsing. +### Current State +The editor is on engine `1c82d27`. The only editor-side adaptation the engine bump required was +retargeting `TitleDocument`'s three `Title::id` sites onto the new `Title::name` — the data-source / +DataPool / Scene rework is a compile no-op here because no editor TU includes `data-source.h`, and +`.ogt` 1.0 files migrate silently. Both are verified by build + a live run that shows a legacy +template's name in the window title. -Plugin changes still needed — see pending item above. +The animation timeline toolbar now groups Show / transport / Speed flush left and Zoom / Fit flush +right with uniform 6px gaps and separators, using themed icons instead of glyph text. The transport +is Play/Pause + Stop + Reset-to-Visible; Reset is the requested escape hatch from a scrubbed preview, +and a document edit or slot switch now also releases the preview automatically. All of this is +verified live. The pre-existing SIGSEGV-on-close investigation (see the section above) is still open +and untouched by this pass; the test instance here was killed rather than closed through the UI, so +this session adds no new data on it. diff --git a/engine b/engine index 6db8ded..1c82d27 160000 --- a/engine +++ b/engine @@ -1 +1 @@ -Subproject commit 6db8dedc4f93633fb3ce2e4409bf6f27389dfe37 +Subproject commit 1c82d275569dd9b34e7956a9104aa7fcca961441 diff --git a/mainwindow.cpp b/mainwindow.cpp index 2de8ac7..71eed78 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -4,9 +4,10 @@ #include "model/TitleDocument.h" #include "model/UndoCommands.h" #include "ui/widgets/CanvasWidget.h" -#include "ui/widgets/GraphicTimingEditor.h" +#include "ui/widgets/AnimationTimingPanel.h" #include "ui/widgets/RibbonFormatSection.h" #include "ui/widgets/TitleTreeView.h" +#include "ui/UiUtils.h" #include "engine/element_image.h" #include "engine/element_qr.h" #include "engine/element_text.h" @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -115,46 +117,31 @@ MainWindow::MainWindow(QWidget* parent) // Title tree — left dock auto* titleDock = new QDockWidget("Title", this); titleDock->setFeatures(QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - titleDock->setWidget(new TitleTreeView(m_doc, m_editorTitle, titleDock)); + m_treeView = new TitleTreeView(m_doc, m_editorTitle, titleDock); + titleDock->setWidget(m_treeView); addDockWidget(Qt::LeftDockWidgetArea, titleDock); + connect(m_treeView, &TitleTreeView::duplicateRequested, this, &MainWindow::onDuplicate); + connect(m_treeView, &TitleTreeView::cutRequested, this, &MainWindow::onCut); + connect(m_treeView, &TitleTreeView::copyRequested, this, &MainWindow::onCopy); + connect(m_treeView, &TitleTreeView::pasteRequested, this, [this]() { doPaste(false); }); + connect(m_treeView, &TitleTreeView::pasteInPlaceRequested, this, [this]() { doPaste(true); }); + setCorner(Qt::BottomLeftCorner, Qt::LeftDockWidgetArea); setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); // Animation timing — bottom dock auto* timingDock = new QDockWidget("Animation Timing", this); timingDock->setFeatures(QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - m_timingEditor = new GraphicTimingEditor(m_doc, timingDock); - timingDock->setWidget(m_timingEditor); + m_timingPanel = new AnimationTimingPanel(m_doc, m_editorTitle, timingDock); + timingDock->setWidget(m_timingPanel); addDockWidget(Qt::BottomDockWidgetArea, timingDock); - connect(m_timingEditor, &GraphicTimingEditor::animationChanged, - [this](int elementIndex, bool isIn, bool isData, AnimationType type, Easing easing, - float delay, float duration) { - const Title& t = m_doc->title(); - if (elementIndex < 1 || elementIndex >= (int)t.elements.size()) return; - const auto* ve = dynamic_cast(t.elements[elementIndex].get()); - if (!ve) return; - const std::string ei = ve->GetId(); - AnimationDef def; - SetElementAnimCmd::Target target; - if (isData) { - def = isIn ? ve->dataInAnimation : ve->dataOutAnimation; - target = isIn ? SetElementAnimCmd::Target::DataAnimIn : SetElementAnimCmd::Target::DataAnimOut; - } else { - def = isIn ? ve->inAnimation : ve->outAnimation; - target = isIn ? SetElementAnimCmd::Target::AnimIn : SetElementAnimCmd::Target::AnimOut; - } - def.type = type; def.easing = easing; def.delay = delay; def.duration = duration; - m_doc->undoStack()->push(new SetElementAnimCmd(m_doc, ei, target, def)); - }); - - connect(m_timingEditor, &GraphicTimingEditor::scrubTimeChanged, - [this](float t) { - m_canvas->previewAtTime(m_timingEditor->isIn(), m_timingEditor->isDataAnim(), double(t)); + connect(m_timingPanel, &AnimationTimingPanel::scrubTimeChanged, this, + [this](bool isIn, bool isData, double t) { + m_canvas->previewAtTime(isIn, isData, t); }); - - connect(m_timingEditor, &GraphicTimingEditor::previewStopped, + connect(m_timingPanel, &AnimationTimingPanel::previewStopped, m_canvas, &CanvasWidget::stopAnimationPreview); setupMenuBar(); @@ -167,6 +154,7 @@ MainWindow::MainWindow(QWidget* parent) }); connect(m_editorTitle, &EditorTitle::selectionChanged, this, &MainWindow::onSelectionChanged); + connect(m_editorTitle, &EditorTitle::selectionSetChanged, this, &MainWindow::updateSelectionStatus); connect(m_formatSection, &RibbonFormatSection::elementIdChanged, [this](const std::string&) { @@ -271,15 +259,20 @@ void MainWindow::setupRibbon() dlg->setWindowTitle("Keyboard Shortcuts"); dlg->resize(500, 520); auto* browser = new QTextBrowser(dlg); - browser->setHtml( + const QPalette& pal = dlg->palette(); + const QString cHead = pal.color(QPalette::PlaceholderText).name(); // h3 + const QString cBorder = pal.color(QPalette::Mid).name(); // td border + const QString cText = pal.color(QPalette::Text).name(); // td body + const QString cKey = pal.color(QPalette::BrightText).name(); // td:first key + browser->setHtml(QString( "" "

File

" "" @@ -294,6 +287,8 @@ void MainWindow::setupRibbon() "" "" "" + "" + "" "
Ctrl+NNew title
Ctrl+XCut selected element
Ctrl+VPaste (offset +10 px)
Ctrl+Shift+VPaste in place
Ctrl+DDuplicate selected element
Delete / BackspaceDelete selected element

View

" "" "" @@ -314,7 +309,7 @@ void MainWindow::setupRibbon() "
Ctrl+=Zoom in
Ctrl+−Zoom out

Misc

" "" "
F1Show this shortcuts reference
" - ); + ).arg(cHead, cBorder, cText, cKey)); auto* layout = new QVBoxLayout(dlg); layout->addWidget(browser); auto* btns = new QDialogButtonBox(QDialogButtonBox::Close, dlg); @@ -388,6 +383,84 @@ void MainWindow::setupRibbon() addAction(m_copyAction); panel->addLargeWidget(makeSmallStack({m_cutAction, m_copyAction})); + + m_duplicateAction = new QAction(themedIcon(Icons16::Action_Copy), "Duplicate", this); + m_duplicateAction->setEnabled(false); + m_duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D)); + connect(m_duplicateAction, &QAction::triggered, this, &MainWindow::onDuplicate); + addAction(m_duplicateAction); + + m_deleteAction = new QAction(themedIcon(Icons16::Action_Trash), "Delete", this); + m_deleteAction->setEnabled(false); + m_deleteAction->setShortcuts({QKeySequence(Qt::Key_Delete), QKeySequence(Qt::Key_Backspace)}); + connect(m_deleteAction, &QAction::triggered, this, &MainWindow::deleteSelectedElement); + addAction(m_deleteAction); + + panel->addLargeWidget(makeSmallStack({m_duplicateAction, m_deleteAction})); + + m_selectAllAction = new QAction(themedIcon(Icons16::Action_SelectAll), "Select All", this); + m_selectAllAction->setShortcut(QKeySequence::SelectAll); + m_selectAllAction->setEnabled(false); + connect(m_selectAllAction, &QAction::triggered, this, &MainWindow::selectAllElements); + addAction(m_selectAllAction); + panel->addLargeWidget(makeSmallStack({m_selectAllAction})); + } + + { // Arrange panel — align / distribute a multi-selection + auto* panel = homeCat->addPanel("Arrange"); + + using AM = alignmath::AlignMode; + auto mkAlign = [this](const auto& icon, const QString& label, AM mode) { + auto* a = new QAction(themedIcon(icon), label, this); + a->setEnabled(false); + connect(a, &QAction::triggered, this, [this, mode]() { alignSelected(mode); }); + addAction(a); + return a; + }; + + m_alignLeftAction = mkAlign(Icons16::Action_AlignLeft, "Align Left", AM::Left); + m_alignHCenterAction = mkAlign(Icons16::Action_AlignCenterHorizontal,"Align Center", AM::HCenter); + m_alignRightAction = mkAlign(Icons16::Action_AlignRight, "Align Right", AM::Right); + m_alignTopAction = mkAlign(Icons16::Action_AlignTop, "Align Top", AM::Top); + m_alignVMiddleAction = mkAlign(Icons16::Action_AlignCenterVertical, "Align Middle", AM::VMiddle); + m_alignBottomAction = mkAlign(Icons16::Action_AlignBottom, "Align Bottom", AM::Bottom); + + m_distributeHAction = new QAction(themedIcon(Icons16::Action_DistributeHorizontal), "Distribute Horizontally", this); + m_distributeHAction->setEnabled(false); + connect(m_distributeHAction, &QAction::triggered, this, [this]() { distributeSelected(true); }); + addAction(m_distributeHAction); + + m_distributeVAction = new QAction(themedIcon(Icons16::Action_DistributeVertical), "Distribute Vertically", this); + m_distributeVAction->setEnabled(false); + connect(m_distributeVAction, &QAction::triggered, this, [this]() { distributeSelected(false); }); + addAction(m_distributeVAction); + + panel->addLargeWidget(makeSmallStack({m_alignLeftAction, m_alignHCenterAction, m_alignRightAction})); + panel->addLargeWidget(makeSmallStack({m_alignTopAction, m_alignVMiddleAction, m_alignBottomAction})); + panel->addLargeWidget(makeSmallStack({m_distributeHAction, m_distributeVAction})); + + using ZOp = zorderops::ReorderOp; + auto mkReorder = [this](const auto& icon, const QString& label, ZOp op, + const QKeySequence& shortcut = {}) { + auto* a = new QAction(themedIcon(icon), label, this); + a->setEnabled(false); + if (!shortcut.isEmpty()) a->setShortcut(shortcut); + connect(a, &QAction::triggered, this, [this, op]() { reorderActive(op); }); + addAction(a); + return a; + }; + + m_bringToFrontAction = mkReorder(Icons16::Action_BringToFront, "Bring to Front", ZOp::ToFront, + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_BracketRight)); + m_bringForwardAction = mkReorder(Icons16::Navigation_StepFront, "Bring Forward", ZOp::Forward, + QKeySequence(Qt::CTRL | Qt::Key_BracketRight)); + m_sendBackwardAction = mkReorder(Icons16::Navigation_StepBack, "Send Backward", ZOp::Backward, + QKeySequence(Qt::CTRL | Qt::Key_BracketLeft)); + m_sendToBackAction = mkReorder(Icons16::Action_BringToBack, "Send to Back", ZOp::ToBack, + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_BracketLeft)); + + panel->addLargeWidget(makeSmallStack({m_bringToFrontAction, m_bringForwardAction})); + panel->addLargeWidget(makeSmallStack({m_sendBackwardAction, m_sendToBackAction})); } // ── Insert tab ───────────────────────────────────────────────────────────── @@ -653,20 +726,18 @@ void MainWindow::setupRibbon() m_ribbon->hideCategory(m_formatSection->imageCategory()); m_ribbon->hideCategory(m_formatSection->qrCategory()); - connect(m_formatSection, &RibbonFormatSection::deleteElementRequested, this, [this]() { - const SelectionId sel = m_editorTitle->selection(); - if (sel.level != SelectionId::Level::Element) return; - const Title& t = m_doc->title(); - if (sel.elementIndex < 1 || sel.elementIndex >= (int)t.elements.size()) return; - const std::string ei = t.elements[sel.elementIndex]->GetId(); - m_doc->undoStack()->push(new RemoveElementCmd(m_doc, ei)); - }); + connect(m_formatSection, &RibbonFormatSection::deleteElementRequested, this, &MainWindow::deleteSelectedElement); connect(m_formatSection, &RibbonFormatSection::copyStyleModeToggled, this, [this](bool on, const std::string& srcEi) { m_canvas->setPaintMode(on, srcEi); }); connect(m_canvas, &CanvasWidget::paintModeFinished, this, [this]() { m_formatSection->setPaintModeActive(false); }); + + connect(m_canvas, &CanvasWidget::interactiveEditStarted, this, + [this]() { m_treeView->setResetsSuppressed(true); }); + connect(m_canvas, &CanvasWidget::interactiveEditFinished, this, + [this]() { m_treeView->setResetsSuppressed(false); }); } // ── Menu bar ────────────────────────────────────────────────────────────────── @@ -707,18 +778,39 @@ void MainWindow::onOpen() QString path = QFileDialog::getOpenFileName(this, "Open Title", QString(), "StreamCanvas Title (*.ogt);;All Files (*)"); if (path.isEmpty()) return; - if (!m_doc->load(path)) + bool ok; + { WaitCursor wc; ok = m_doc->load(path); } + if (!ok) { + const QString detail = m_doc->lastError(); QMessageBox::warning(this, "Open Failed", - QString("Could not open file:\n%1").arg(path)); - else + detail.isEmpty() ? QString("Could not open file:\n%1").arg(path) + : QString("Could not open file:\n%1\n\n%2").arg(path, detail)); + } else { m_editorTitle->setSelection(SelectionId{}); + + const Title::LoadDiagnostic& diag = m_doc->lastLoadDiagnostic(); + if (diag.severity != Title::LoadDiagnostic::Severity::None) { + const bool isWarning = diag.severity == Title::LoadDiagnostic::Severity::Warning; + auto* msgBox = new QMessageBox( + isWarning ? QMessageBox::Warning : QMessageBox::Information, + "Schema version", QString::fromStdString(diag.message), QMessageBox::Ok, this); + msgBox->setAttribute(Qt::WA_DeleteOnClose); + msgBox->show(); + } + } } void MainWindow::onSave() { if (m_doc->filePath().isEmpty()) { onSaveAs(); return; } - if (!m_doc->save()) - QMessageBox::warning(this, "Save Failed", "Could not save the file."); + bool ok; + { WaitCursor wc; ok = m_doc->save(); } + if (!ok) { + const QString detail = m_doc->lastError(); + QMessageBox::warning(this, "Save Failed", + detail.isEmpty() ? QString("Could not save the file.") + : QString("Could not save the file.\n\n%1").arg(detail)); + } } void MainWindow::onSaveAs() @@ -728,11 +820,16 @@ void MainWindow::onSaveAs() if (path.isEmpty()) return; if (!path.endsWith(".ogt", Qt::CaseInsensitive)) path += ".ogt"; - if (!m_doc->saveAs(path)) + bool ok; + { WaitCursor wc; ok = m_doc->saveAs(path); } + if (!ok) { + const QString detail = m_doc->lastError(); QMessageBox::warning(this, "Save Failed", - QString("Could not save file:\n%1").arg(path)); - else + detail.isEmpty() ? QString("Could not save file:\n%1").arg(path) + : QString("Could not save file:\n%1\n\n%2").arg(path, detail)); + } else { updateWindowTitle(); + } } void MainWindow::closeEvent(QCloseEvent* event) @@ -776,16 +873,53 @@ void MainWindow::updateWindowTitle() void MainWindow::updateToolBarState(SelectionId id) { - const bool hasElement = (id.level == SelectionId::Level::Element); + const bool hasElement = (m_editorTitle->selectionCount() > 0); if (m_cutAction) m_cutAction->setEnabled(hasElement); if (m_copyAction) m_copyAction->setEnabled(hasElement); + if (m_duplicateAction) m_duplicateAction->setEnabled(hasElement); + if (m_deleteAction) m_deleteAction->setEnabled(hasElement); + if (m_selectAllAction) + m_selectAllAction->setEnabled((int)m_doc->title().elements.size() > 1); bool canPaste = m_clipboard.has_value(); if (!canPaste) canPaste = clipboardHasExternalImage(); if (m_pasteAction) m_pasteAction->setEnabled(canPaste); if (m_pasteInPlaceAction) m_pasteInPlaceAction->setEnabled(canPaste); + + const int selCount = m_editorTitle->selectionCount(); + const bool canAlign = selCount >= 2; + const bool canDistribute = selCount >= 3; + if (m_alignLeftAction) m_alignLeftAction->setEnabled(canAlign); + if (m_alignHCenterAction) m_alignHCenterAction->setEnabled(canAlign); + if (m_alignRightAction) m_alignRightAction->setEnabled(canAlign); + if (m_alignTopAction) m_alignTopAction->setEnabled(canAlign); + if (m_alignVMiddleAction) m_alignVMiddleAction->setEnabled(canAlign); + if (m_alignBottomAction) m_alignBottomAction->setEnabled(canAlign); + if (m_distributeHAction) m_distributeHAction->setEnabled(canDistribute); + if (m_distributeVAction) m_distributeVAction->setEnabled(canDistribute); + + // Reorder actions: single active element with >=2 siblings (same sibling + // definition as TitleTreeModel::childrenOf). + bool canReorder = false; + if (id.level == SelectionId::Level::Element) { + const Title& t = m_doc->title(); + if (id.elementIndex >= 1 && id.elementIndex < (int)t.elements.size()) { + const IElement* activeEl = t.elements[id.elementIndex].get(); + if (const IElement* parentEl = activeEl->GetParent()) { + int siblingCount = 0; + for (const IElement* child : parentEl->GetChildren()) + if (dynamic_cast(child)) + ++siblingCount; + canReorder = siblingCount >= 2; + } + } + } + if (m_bringToFrontAction) m_bringToFrontAction->setEnabled(canReorder); + if (m_bringForwardAction) m_bringForwardAction->setEnabled(canReorder); + if (m_sendBackwardAction) m_sendBackwardAction->setEnabled(canReorder); + if (m_sendToBackAction) m_sendToBackAction->setEnabled(canReorder); } void MainWindow::onSelectionChanged(SelectionId id) @@ -803,7 +937,6 @@ void MainWindow::onSelectionChanged(SelectionId id) const bool isQr = dynamic_cast(el) != nullptr; m_formatSection->setSelection(el->GetId()); - m_timingEditor->load(); m_ribbon->showCategory(m_formatSection->elementCategory()); m_ribbon->showCategory(m_formatSection->styleCategory()); @@ -822,10 +955,29 @@ void MainWindow::onSelectionChanged(SelectionId id) m_ribbon->hideCategory(m_formatSection->textCategory()); m_ribbon->hideCategory(m_formatSection->imageCategory()); m_ribbon->hideCategory(m_formatSection->qrCategory()); - m_timingEditor->clear(); } } +void MainWindow::selectAllElements() +{ + const Title& t = m_doc->title(); + std::vector indices; + for (int i = 1; i < (int)t.elements.size(); ++i) + if (dynamic_cast(t.elements[i].get())) + indices.push_back(i); + if (indices.empty()) return; + m_editorTitle->setMultiSelection(indices, indices.back()); +} + +void MainWindow::updateSelectionStatus() +{ + const int n = m_editorTitle->selectionCount(); + if (n > 1) + statusBar()->showMessage(QString("%1 elements selected").arg(n)); + else + statusBar()->clearMessage(); +} + // ── Clipboard ───────────────────────────────────────────────────────────────── void MainWindow::onCopy() @@ -859,6 +1011,102 @@ void MainWindow::onCut() } } +void MainWindow::deleteSelectedElement() +{ + const std::vector indices = m_editorTitle->selectedIndices(); + if (indices.empty()) return; + const Title& t = m_doc->title(); + // Resolve to string ids FIRST — removals shift positional indices. + std::vector ids; + for (int i : indices) + if (i >= 1 && i < (int)t.elements.size()) + ids.push_back(t.elements[i]->GetId()); + if (ids.empty()) return; + if (ids.size() == 1) { + m_doc->undoStack()->push(new RemoveElementCmd(m_doc, ids[0])); + } else { + m_doc->undoStack()->beginMacro("Delete elements"); + for (const auto& id : ids) + m_doc->undoStack()->push(new RemoveElementCmd(m_doc, id)); + m_doc->undoStack()->endMacro(); + } +} + +std::string MainWindow::insertElementCopyImpl(nlohmann::json cb, double offset) +{ + const Title& t = m_doc->title(); + std::string origType = cb.value("type", "rectangle"); + std::string prefix = "element_"; + if (origType == "text") prefix = "text_"; + else if (origType == "image") prefix = "image_"; + else if (origType == "qr_code") prefix = "qr_"; + + int maxN = 0; + for (int i = 1; i < (int)t.elements.size(); ++i) { + const std::string& eid = t.elements[i]->GetId(); + if (eid.rfind(prefix, 0) == 0) + try { maxN = std::max(maxN, std::stoi(eid.substr(prefix.size()))); } catch (...) {} + } + std::string newId = prefix + std::to_string(maxN + 1); + + cb["id"] = newId; + cb["z_order"] = std::max(0, (int)t.elements.size() - 1); + if (offset != 0.0) { + if (cb.contains("x")) cb["x"] = cb["x"].get() + offset; + if (cb.contains("y")) cb["y"] = cb["y"].get() + offset; + } + + m_doc->undoStack()->push(new AddElementCmd(m_doc, std::move(cb))); + return newId; +} + +void MainWindow::insertElementCopy(nlohmann::json cb, double offset) +{ + std::string newId = insertElementCopyImpl(std::move(cb), offset); + + const Title& t2 = m_doc->title(); + for (int i = 1; i < (int)t2.elements.size(); ++i) + if (t2.elements[i]->GetId() == newId) { + m_editorTitle->setSelection({SelectionId::Level::Element, i}); + break; + } + updateToolBarState(m_editorTitle->selection()); +} + +void MainWindow::onDuplicate() +{ + const std::vector indices = m_editorTitle->selectedIndices(); + if (indices.empty()) return; + const Title& t = m_doc->title(); + std::vector sources; + for (int i : indices) { + if (i < 1 || i >= (int)t.elements.size()) continue; + const auto* ve = dynamic_cast(t.elements[i].get()); + if (!ve) continue; + nlohmann::json cb = TitleDocument::elementToJson(*ve); + cb.erase("mask"); + cb.erase("parent"); + sources.push_back(std::move(cb)); + } + if (sources.empty()) return; + if (sources.size() == 1) { insertElementCopy(std::move(sources[0]), 10.0); return; } + + m_doc->undoStack()->beginMacro("Duplicate elements"); + std::vector newIds; + for (auto& src : sources) + newIds.push_back(insertElementCopyImpl(std::move(src), 10.0)); + m_doc->undoStack()->endMacro(); + + const Title& t2 = m_doc->title(); + std::vector newIndices; + for (const auto& id : newIds) + for (int i = 1; i < (int)t2.elements.size(); ++i) + if (t2.elements[i]->GetId() == id) { newIndices.push_back(i); break; } + if (!newIndices.empty()) + m_editorTitle->setMultiSelection(newIndices, newIndices.back()); + updateToolBarState(m_editorTitle->selection()); +} + void MainWindow::doPaste(bool inPlace) { using json = nlohmann::json; @@ -872,36 +1120,7 @@ void MainWindow::doPaste(bool inPlace) const double offset = inPlace ? 0.0 : 10.0; if (clipType == "element") { - const Title& t = m_doc->title(); - std::string origType = cb.value("type", "rectangle"); - std::string prefix = "element_"; - if (origType == "text") prefix = "text_"; - else if (origType == "image") prefix = "image_"; - else if (origType == "qr_code") prefix = "qr_"; - - int maxN = 0; - for (int i = 1; i < (int)t.elements.size(); ++i) { - const std::string& eid = t.elements[i]->GetId(); - if (eid.rfind(prefix, 0) == 0) - try { maxN = std::max(maxN, std::stoi(eid.substr(prefix.size()))); } catch (...) {} - } - std::string newId = prefix + std::to_string(maxN + 1); - - cb["id"] = newId; - cb["z_order"] = std::max(0, (int)t.elements.size() - 1); - if (offset != 0.0) { - if (cb.contains("x")) cb["x"] = cb["x"].get() + offset; - if (cb.contains("y")) cb["y"] = cb["y"].get() + offset; - } - - m_doc->undoStack()->push(new AddElementCmd(m_doc, std::move(cb))); - - const Title& t2 = m_doc->title(); - for (int i = 1; i < (int)t2.elements.size(); ++i) - if (t2.elements[i]->GetId() == newId) { - m_editorTitle->setSelection({SelectionId::Level::Element, i}); - break; - } + insertElementCopy(std::move(cb), offset); } updateToolBarState(m_editorTitle->selection()); @@ -915,6 +1134,7 @@ void MainWindow::doPaste(bool inPlace) QString imagePath; QImage img; if (md->hasUrls()) { + WaitCursor wc; for (const QUrl& url : md->urls()) { if (!url.isLocalFile()) continue; QString p = url.toLocalFile(); @@ -949,14 +1169,19 @@ void MainWindow::doPaste(bool inPlace) defaultDir + "/clipboard_image.png", "PNG Images (*.png);;JPEG Images (*.jpg *.jpeg);;All Files (*)"); if (imagePath.isEmpty()) return; - if (!img.save(imagePath)) { + bool saved; + { WaitCursor wc; saved = img.save(imagePath); } + if (!saved) { QMessageBox::warning(this, "Save Failed", "Could not save the clipboard image."); return; } } - if (img.isNull()) img.load(imagePath); + { + WaitCursor wc; + if (img.isNull()) img.load(imagePath); + } double w = img.isNull() ? 200.0 : img.width(); double h = img.isNull() ? 200.0 : img.height(); @@ -991,3 +1216,91 @@ void MainWindow::doPaste(bool inPlace) updateToolBarState(m_editorTitle->selection()); } + +void MainWindow::alignSelected(alignmath::AlignMode mode) +{ + const std::vector indices = m_editorTitle->selectedIndices(); + if (indices.size() < 2) return; + const Title& t = m_doc->title(); + std::vector boxes; + std::vector ids; + std::vector bounds; + for (int i : indices) { + if (i < 1 || i >= (int)t.elements.size()) continue; + const auto* ve = dynamic_cast(t.elements[i].get()); + if (!ve) continue; + boxes.push_back(m_canvas->elementTitleAABB(*ve)); + ids.push_back(ve->GetId()); + bounds.push_back(ve->GetBounds()); + } + if (boxes.size() < 2) return; + const std::vector deltas = alignmath::alignDeltas(boxes, mode); + + // Collect the actual changes first so we never open an empty macro. + struct Change { std::string id; Rectangle b; }; + std::vector changes; + for (std::size_t k = 0; k < ids.size(); ++k) { + if (deltas[k].isNull()) continue; + Rectangle b = bounds[k]; + b.x += deltas[k].x(); + b.y += deltas[k].y(); + changes.push_back({ids[k], b}); + } + if (changes.empty()) return; + if (changes.size() == 1) { + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, changes[0].id, changes[0].b)); + } else { + m_doc->undoStack()->beginMacro("Align elements"); + for (const auto& c : changes) + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, c.id, c.b)); + m_doc->undoStack()->endMacro(); + } +} + +void MainWindow::distributeSelected(bool horizontal) +{ + const std::vector indices = m_editorTitle->selectedIndices(); + if (indices.size() < 3) return; + const Title& t = m_doc->title(); + std::vector boxes; + std::vector ids; + std::vector bounds; + for (int i : indices) { + if (i < 1 || i >= (int)t.elements.size()) continue; + const auto* ve = dynamic_cast(t.elements[i].get()); + if (!ve) continue; + boxes.push_back(m_canvas->elementTitleAABB(*ve)); + ids.push_back(ve->GetId()); + bounds.push_back(ve->GetBounds()); + } + if (boxes.size() < 3) return; + const std::vector deltas = alignmath::distributeDeltas(boxes, horizontal); + + struct Change { std::string id; Rectangle b; }; + std::vector changes; + for (std::size_t k = 0; k < ids.size(); ++k) { + if (deltas[k].isNull()) continue; + Rectangle b = bounds[k]; + b.x += deltas[k].x(); + b.y += deltas[k].y(); + changes.push_back({ids[k], b}); + } + if (changes.empty()) return; + if (changes.size() == 1) { + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, changes[0].id, changes[0].b)); + } else { + m_doc->undoStack()->beginMacro("Distribute elements"); + for (const auto& c : changes) + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, c.id, c.b)); + m_doc->undoStack()->endMacro(); + } +} + +void MainWindow::reorderActive(zorderops::ReorderOp op) +{ + const SelectionId sel = m_editorTitle->selection(); + if (sel.level != SelectionId::Level::Element) return; + const Title& t = m_doc->title(); + if (sel.elementIndex < 1 || sel.elementIndex >= (int)t.elements.size()) return; + zorderops::applyReorder(m_doc, t.elements[sel.elementIndex].get(), op); +} diff --git a/mainwindow.h b/mainwindow.h index fc298b8..8a345b0 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -1,6 +1,8 @@ #pragma once #include "model/EditorTitle.h" +#include "ui/widgets/AlignMath.h" +#include "ui/widgets/ZOrderOps.h" #include #include #include @@ -8,7 +10,8 @@ class TitleDocument; class EditorTitle; class CanvasWidget; -class GraphicTimingEditor; +class TitleTreeView; +class AnimationTimingPanel; class RibbonFormatSection; class QAction; class QCloseEvent; @@ -34,6 +37,12 @@ private slots: void onSaveAs(); void onCopy(); void onCut(); + void onDuplicate(); + void selectAllElements(); + void updateSelectionStatus(); + void alignSelected(alignmath::AlignMode mode); + void distributeSelected(bool horizontal); + void reorderActive(zorderops::ReorderOp op); private: void setupMenuBar(); @@ -42,10 +51,14 @@ private slots: void updateToolBarState(SelectionId sel); bool maybeSave(); void doPaste(bool inPlace); + void deleteSelectedElement(); + void insertElementCopy(nlohmann::json cb, double offset); + std::string insertElementCopyImpl(nlohmann::json cb, double offset); TitleDocument* m_doc; EditorTitle* m_editorTitle; CanvasWidget* m_canvas; + TitleTreeView* m_treeView{nullptr}; // Title ribbon controls QLineEdit* m_titleNameEdit{nullptr}; @@ -56,7 +69,7 @@ private slots: RibbonFormatSection* m_formatSection{nullptr}; - GraphicTimingEditor* m_timingEditor{nullptr}; + AnimationTimingPanel* m_timingPanel{nullptr}; QAction* m_newAction{nullptr}; QAction* m_openAction{nullptr}; @@ -67,6 +80,21 @@ private slots: QAction* m_redoAction{nullptr}; QAction* m_cutAction{nullptr}; QAction* m_copyAction{nullptr}; + QAction* m_duplicateAction{nullptr}; + QAction* m_deleteAction{nullptr}; + QAction* m_selectAllAction{nullptr}; + QAction* m_alignLeftAction{nullptr}; + QAction* m_alignHCenterAction{nullptr}; + QAction* m_alignRightAction{nullptr}; + QAction* m_alignTopAction{nullptr}; + QAction* m_alignVMiddleAction{nullptr}; + QAction* m_alignBottomAction{nullptr}; + QAction* m_distributeHAction{nullptr}; + QAction* m_distributeVAction{nullptr}; + QAction* m_bringToFrontAction{nullptr}; + QAction* m_bringForwardAction{nullptr}; + QAction* m_sendBackwardAction{nullptr}; + QAction* m_sendToBackAction{nullptr}; QAction* m_pasteAction{nullptr}; QAction* m_pasteInPlaceAction{nullptr}; QAction* m_shortcutsAction{nullptr}; diff --git a/model/EditorTitle.cpp b/model/EditorTitle.cpp index 5a22612..3cb5cec 100644 --- a/model/EditorTitle.cpp +++ b/model/EditorTitle.cpp @@ -6,21 +6,268 @@ EditorTitle::EditorTitle(TitleDocument* doc, QObject* parent) : QObject(parent), connect(m_doc, &TitleDocument::documentChanged, this, &EditorTitle::validateSelection); } +const IElement* EditorTitle::elementForSelection(const SelectionId& id) const +{ + if (id.level != SelectionId::Level::Element) + return nullptr; + return elementForIndex(id.elementIndex); +} + +const IElement* EditorTitle::elementForIndex(int elementIndex) const +{ + const Title& t = m_doc->title(); + if (elementIndex < 1 || elementIndex >= (int)t.elements.size()) + return nullptr; + return t.elements[elementIndex].get(); +} + void EditorTitle::validateSelection() { - const SelectionId& cur = m_selection; + if (m_selection.level != SelectionId::Level::Element) { + m_selectedElements.clear(); + return; + } - if (cur.level == SelectionId::Level::Element) { - const Title& t = m_doc->title(); - if (cur.elementIndex < 1 || cur.elementIndex >= (int)t.elements.size()) - setSelection(SelectionId{}); + const Title& t = m_doc->title(); + + // Prune m_selectedElements to only pointers still present in elements[1..]. + bool setChanged = false; + { + std::vector survivors; + survivors.reserve(m_selectedElements.size()); + for (const IElement* p : m_selectedElements) { + bool found = false; + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == p) { + found = true; + break; + } + } + if (found) + survivors.push_back(p); + else + setChanged = true; + } + m_selectedElements = std::move(survivors); + } + + int foundIndex = -1; + if (m_selectedElement) { + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == m_selectedElement) { + foundIndex = i; + break; + } + } + } + + if (foundIndex >= 0) { + std::string curId = m_selectedElement->GetId(); + if (foundIndex != m_selection.elementIndex) { + m_selection.elementIndex = foundIndex; + m_selectedElementId = curId; + emit selectionChanged(m_selection); + setChanged = true; + } else if (curId != m_selectedElementId) { + m_selectedElementId = curId; + emit selectionChanged(m_selection); + setChanged = true; + } + } else if (!m_selectedElements.empty()) { + // Active element is gone but survivors remain: promote the first survivor. + const IElement* promoted = m_selectedElements.front(); + int promotedIndex = -1; + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == promoted) { + promotedIndex = i; + break; + } + } + m_selection = SelectionId{SelectionId::Level::Element, promotedIndex}; + m_selectedElement = promoted; + m_selectedElementId = promoted->GetId(); + emit selectionChanged(m_selection); + setChanged = true; + } else { + m_selection = SelectionId{}; + m_selectedElement = nullptr; + m_selectedElementId.clear(); + emit selectionChanged(m_selection); + setChanged = true; } + + if (setChanged) + emit selectionSetChanged(); } void EditorTitle::setSelection(const SelectionId& id) { - if (m_selection == id) + if (m_selection == id && selectionCount() <= 1) return; m_selection = id; + m_selectedElement = elementForSelection(id); + m_selectedElementId = m_selectedElement ? m_selectedElement->GetId() : std::string(); + if (m_selectedElement) + m_selectedElements = {m_selectedElement}; + else + m_selectedElements.clear(); + emit selectionChanged(m_selection); + emit selectionSetChanged(); +} + +std::vector EditorTitle::selectedIndices() const +{ + std::vector result; + const Title& t = m_doc->title(); + for (const IElement* p : m_selectedElements) { + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == p) { + result.push_back(i); + break; + } + } + } + return result; +} + +bool EditorTitle::isSelected(int elementIndex) const +{ + const IElement* el = elementForIndex(elementIndex); + if (!el) + return false; + for (const IElement* p : m_selectedElements) { + if (p == el) + return true; + } + return false; +} + +int EditorTitle::selectionCount() const +{ + return (int)m_selectedElements.size(); +} + +void EditorTitle::setMultiSelection(const std::vector& indices, int activeIndex) +{ + std::vector resolved; + resolved.reserve(indices.size()); + for (int idx : indices) { + const IElement* el = elementForIndex(idx); + if (!el) + continue; + bool alreadyPresent = false; + for (const IElement* p : resolved) { + if (p == el) { + alreadyPresent = true; + break; + } + } + if (!alreadyPresent) + resolved.push_back(el); + } + m_selectedElements = std::move(resolved); + + const IElement* activeEl = elementForIndex(activeIndex); + bool activeValid = false; + if (activeEl) { + for (const IElement* p : m_selectedElements) { + if (p == activeEl) { + activeValid = true; + break; + } + } + } + + if (activeValid) { + m_selection = SelectionId{SelectionId::Level::Element, activeIndex}; + m_selectedElement = activeEl; + m_selectedElementId = activeEl->GetId(); + } else if (!m_selectedElements.empty()) { + m_selectedElement = m_selectedElements.front(); + m_selection = SelectionId{SelectionId::Level::Element, -1}; + const Title& t = m_doc->title(); + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == m_selectedElement) { + m_selection.elementIndex = i; + break; + } + } + m_selectedElementId = m_selectedElement->GetId(); + } else { + m_selection = SelectionId{}; + m_selectedElement = nullptr; + m_selectedElementId.clear(); + } + + emit selectionChanged(m_selection); + emit selectionSetChanged(); +} + +void EditorTitle::toggleSelection(int elementIndex) +{ + const IElement* el = elementForIndex(elementIndex); + if (!el) + return; + + auto it = m_selectedElements.begin(); + for (; it != m_selectedElements.end(); ++it) { + if (*it == el) + break; + } + + if (it != m_selectedElements.end()) { + bool wasActive = (m_selectedElement == el); + m_selectedElements.erase(it); + if (wasActive) { + if (!m_selectedElements.empty()) { + m_selectedElement = m_selectedElements.front(); + const Title& t = m_doc->title(); + int newIndex = -1; + for (int i = 1; i < (int)t.elements.size(); ++i) { + if (t.elements[i].get() == m_selectedElement) { + newIndex = i; + break; + } + } + m_selection = SelectionId{SelectionId::Level::Element, newIndex}; + m_selectedElementId = m_selectedElement->GetId(); + } else { + m_selection = SelectionId{}; + m_selectedElement = nullptr; + m_selectedElementId.clear(); + } + } + } else { + m_selectedElements.push_back(el); + m_selectedElement = el; + m_selection = SelectionId{SelectionId::Level::Element, elementIndex}; + m_selectedElementId = el->GetId(); + } + + emit selectionChanged(m_selection); + emit selectionSetChanged(); +} + +void EditorTitle::addToSelection(int elementIndex) +{ + const IElement* el = elementForIndex(elementIndex); + if (!el) + return; + + bool alreadyPresent = false; + for (const IElement* p : m_selectedElements) { + if (p == el) { + alreadyPresent = true; + break; + } + } + if (!alreadyPresent) + m_selectedElements.push_back(el); + + m_selectedElement = el; + m_selection = SelectionId{SelectionId::Level::Element, elementIndex}; + m_selectedElementId = el->GetId(); + emit selectionChanged(m_selection); + emit selectionSetChanged(); } diff --git a/model/EditorTitle.h b/model/EditorTitle.h index d12382c..74f993d 100644 --- a/model/EditorTitle.h +++ b/model/EditorTitle.h @@ -1,8 +1,12 @@ #pragma once +#include +#include + #include class TitleDocument; +class IElement; struct SelectionId { enum class Level { None, Title, Element }; @@ -24,13 +28,27 @@ class EditorTitle : public QObject { } void setSelection(const SelectionId& id); + std::vector selectedIndices() const; + bool isSelected(int elementIndex) const; + int selectionCount() const; + void setMultiSelection(const std::vector& indices, int activeIndex); + void toggleSelection(int elementIndex); + void addToSelection(int elementIndex); + signals: void selectionChanged(SelectionId id); + void selectionSetChanged(); private slots: void validateSelection(); private: + const IElement* elementForSelection(const SelectionId& id) const; + const IElement* elementForIndex(int elementIndex) const; + TitleDocument* m_doc; SelectionId m_selection; + const IElement* m_selectedElement = nullptr; + std::string m_selectedElementId; + std::vector m_selectedElements; }; diff --git a/model/TitleDocument.cpp b/model/TitleDocument.cpp index 384e48d..13be11c 100644 --- a/model/TitleDocument.cpp +++ b/model/TitleDocument.cpp @@ -9,156 +9,10 @@ #include "engine/element_qr.h" #include "engine/element_rectangle.h" #include "engine/element_text.h" +#include "engine/title.h" using json = nlohmann::json; -// ── Serialization helpers ──────────────────────────────────────────────────── - -static std::string easingToString(Easing e) -{ - switch (e) { - case Easing::EaseIn: return "ease_in"; - case Easing::EaseOut: return "ease_out"; - case Easing::EaseInOut: return "ease_in_out"; - case Easing::EaseInCubic: return "ease_in_cubic"; - case Easing::EaseOutCubic: return "ease_out_cubic"; - case Easing::EaseInOutCubic: return "ease_in_out_cubic"; - case Easing::EaseInExpo: return "ease_in_expo"; - case Easing::EaseOutExpo: return "ease_out_expo"; - case Easing::EaseInOutExpo: return "ease_in_out_expo"; - case Easing::EaseInBack: return "ease_in_back"; - case Easing::EaseOutBack: return "ease_out_back"; - case Easing::EaseInOutBack: return "ease_in_out_back"; - case Easing::EaseInElastic: return "ease_in_elastic"; - case Easing::EaseOutElastic: return "ease_out_elastic"; - case Easing::EaseInOutElastic:return "ease_in_out_elastic"; - default: return "linear"; - } -} - -static std::string animTypeToString(AnimationType t) -{ - switch (t) { - case AnimationType::Fade: return "fade"; - case AnimationType::SlideUp: return "slide_up"; - case AnimationType::SlideDown: return "slide_down"; - case AnimationType::SlideLeft: return "slide_left"; - case AnimationType::SlideRight: return "slide_right"; - case AnimationType::ScaleIn: return "scale_in"; - case AnimationType::WipeUp: return "wipe_up"; - case AnimationType::WipeDown: return "wipe_down"; - case AnimationType::WipeLeft: return "wipe_left"; - case AnimationType::WipeRight: return "wipe_right"; - default: return "none"; - } -} - -static std::string fontWeightToString(FontWeight w) -{ - switch (static_cast(w)) { - case PANGO_WEIGHT_THIN: return "thin"; - case PANGO_WEIGHT_ULTRALIGHT: return "ultralight"; - case PANGO_WEIGHT_SEMILIGHT: return "semilight"; - case PANGO_WEIGHT_LIGHT: return "light"; - case PANGO_WEIGHT_BOOK: return "book"; - case PANGO_WEIGHT_MEDIUM: return "medium"; - case PANGO_WEIGHT_SEMIBOLD: return "semibold"; - case PANGO_WEIGHT_BOLD: return "bold"; - case PANGO_WEIGHT_ULTRABOLD: return "ultrabold"; - case PANGO_WEIGHT_HEAVY: return "heavy"; - case PANGO_WEIGHT_ULTRAHEAVY: return "ultraheavy"; - default: return "normal"; - } -} - -static std::string alignmentHToString(HorizontalAlignment a) -{ - switch (a) { - case HorizontalAlignment::Justify: return "justify"; - case HorizontalAlignment::Center: return "center"; - case HorizontalAlignment::Right: return "right"; - default: return "left"; - } -} - -static std::string alignmentVToString(VerticalAlignment a) -{ - switch (a) { - case VerticalAlignment::Bottom: return "bottom"; - case VerticalAlignment::Middle: return "middle"; - default: return "top"; - } -} - -static std::string ellipsizeToString(Ellipsize e) -{ - switch (e) { - case Ellipsize::Start: return "start"; - case Ellipsize::Middle: return "middle"; - case Ellipsize::End: return "end"; - default: return "none"; - } -} - -static std::string wrapModeToString(WrapMode w) -{ - switch (w) { - case WrapMode::Char: return "char"; - case WrapMode::WordChar: return "word_char"; - default: return "word"; - } -} - -static std::string textTransformToString(TextTransform t) -{ - switch (t) { - case TextTransform::Capitalize: return "capitalize"; - case TextTransform::Uppercase: return "uppercase"; - case TextTransform::Lowercase: return "lowercase"; - default: return "none"; - } -} - -static json paintToJson(const Paint& p) -{ - if (p.type == Paint::Type::None) - return json(nullptr); - - if (p.type == Paint::Type::Solid) - return json::array({p.params[0], p.params[1], p.params[2], p.params[3]}); - - if (p.type == Paint::Type::Image) - return json(p.imagePath); - - json obj; - if (p.type == Paint::Type::Linear) { - obj["type"] = "linear"; - obj["x0"] = p.params[0]; obj["y0"] = p.params[1]; - obj["x1"] = p.params[2]; obj["y1"] = p.params[3]; - } else { - obj["type"] = "radial"; - obj["cx0"] = p.params[0]; obj["cy0"] = p.params[1]; obj["r0"] = p.params[2]; - obj["cx1"] = p.params[3]; obj["cy1"] = p.params[4]; obj["r1"] = p.params[5]; - } - json stops = json::array(); - for (const auto& s : p.stops) { - json stop; - stop["offset"] = s.offset; - stop["color"] = json::array({s.r, s.g, s.b, s.a}); - stops.push_back(stop); - } - obj["stops"] = stops; - return obj; -} - -static json animDefToJson(const AnimationDef& a) -{ - return {{"type", animTypeToString(a.type)}, - {"easing", easingToString(a.easing)}, - {"duration", a.duration}, - {"delay", a.delay}}; -} - // ── TitleDocument ───────────────────────────────────────────────────────────── TitleDocument::TitleDocument(QObject* parent) : QObject(parent) @@ -168,11 +22,14 @@ TitleDocument::TitleDocument(QObject* parent) : QObject(parent) bool TitleDocument::load(const QString& path) { + m_lastError.clear(); try { m_title = Title::Load(path.toStdString()); - } catch (const std::exception&) { + } catch (const std::exception& e) { + m_lastError = QString::fromStdString(e.what()); return false; } + m_lastLoadDiagnostic = m_title.GetLoadDiagnostic(); // Load brand colors from metadata, fall back to defaults if (m_title.metadata.contains("brand_colors") && m_title.metadata["brand_colors"].is_array()) { @@ -207,9 +64,11 @@ bool TitleDocument::save() bool TitleDocument::saveAs(const QString& path) { + m_lastError.clear(); try { m_title.Save(path.toStdString()); - } catch (...) { + } catch (const std::exception& e) { + m_lastError = QString::fromStdString(e.what()); return false; } @@ -228,8 +87,8 @@ VisualElement& TitleDocument::getElement(const std::string& elementId) QString TitleDocument::titleName() const { - if (!m_title.id.empty()) - return QString::fromStdString(m_title.id); + if (!m_title.name.empty()) + return QString::fromStdString(m_title.name); if (!m_filePath.isEmpty()) return QFileInfo(m_filePath).baseName(); return QString("Untitled"); @@ -237,7 +96,7 @@ QString TitleDocument::titleName() const void TitleDocument::setTitleName(const QString& name) { - m_title.id = name.toStdString(); + m_title.name = name.toStdString(); setModified(true); emit documentChanged(); } @@ -253,6 +112,43 @@ void TitleDocument::setBrandColors(const QList& colors) emit documentChanged(); } +bool TitleDocument::isElementLocked(const std::string& id) const +{ + if (!m_title.metadata.contains("locked_ids") || !m_title.metadata["locked_ids"].is_array()) + return false; + for (const auto& v : m_title.metadata["locked_ids"]) { + if (v.is_string() && v.get() == id) + return true; + } + return false; +} + +void TitleDocument::setElementLocked(const std::string& id, bool locked) +{ + // Lock is persisted (metadata) but intentionally not on the undo stack. + applyMutation([&](Title& t) { + nlohmann::json& arr = t.metadata["locked_ids"]; + if (!arr.is_array()) + arr = nlohmann::json::array(); + + bool present = false; + for (const auto& v : arr) { + if (v.is_string() && v.get() == id) { present = true; break; } + } + + if (locked && !present) { + arr.push_back(id); + } else if (!locked && present) { + nlohmann::json filtered = nlohmann::json::array(); + for (const auto& v : arr) { + if (!(v.is_string() && v.get() == id)) + filtered.push_back(v); + } + arr = filtered; + } + }); +} + // static QList TitleDocument::defaultBrandColors() { @@ -273,7 +169,7 @@ QList TitleDocument::defaultBrandColors() void TitleDocument::reset() { m_title = Title{}; - m_title.id = "new_title"; + m_title.name = "new_title"; m_title.width = 1920; m_title.height = 1080; m_brandColors = defaultBrandColors(); @@ -287,88 +183,9 @@ void TitleDocument::reset() // static nlohmann::json TitleDocument::elementToJson(const VisualElement& el) { - json j; - j["id"] = el.GetId(); - - const Rectangle& b = el.GetBounds(); - j["x"] = b.x; - j["y"] = b.y; - j["w"] = b.width; - j["h"] = b.height; - j["z_order"] = el.zOrder; - j["opacity"] = el.opacity; - j["rotation"] = el.GetRotation(); - if (el.GetShearX() != 0.0f) j["shear_x"] = el.GetShearX(); - if (el.GetShearY() != 0.0f) j["shear_y"] = el.GetShearY(); - j["stroke_width"] = el.strokeWidth; - - bool allEqual = el.cornerRadius[0] == el.cornerRadius[1] && - el.cornerRadius[1] == el.cornerRadius[2] && - el.cornerRadius[2] == el.cornerRadius[3]; - if (allEqual) - j["corner_radius"] = el.cornerRadius[0]; - else - j["corner_radius"] = json::array({el.cornerRadius[0], el.cornerRadius[1], - el.cornerRadius[2], el.cornerRadius[3]}); - - if (el.fill.Valid()) j["fill"] = paintToJson(el.fill); - if (el.stroke.Valid()) j["stroke"] = paintToJson(el.stroke); - - if (el.inAnimation.type != AnimationType::None) - j["anim_in"] = animDefToJson(el.inAnimation); - if (el.outAnimation.type != AnimationType::None) - j["anim_out"] = animDefToJson(el.outAnimation); - - if (el.shadow.enabled) { - j["shadow"] = {{"enabled", el.shadow.enabled}, - {"offset_x", el.shadow.offsetX}, - {"offset_y", el.shadow.offsetY}, - {"blur", el.shadow.blur}, - {"color", json::array({el.shadow.color[0], el.shadow.color[1], - el.shadow.color[2], el.shadow.color[3]})}}; - } - - if (el.clipChildren) - j["clip_children"] = true; - if (el.GetParent() && el.GetParent()->GetId() != "__root") - j["parent"] = el.GetParent()->GetId(); - - if (el.fitToChildren) { - j["fit_to_children"] = true; - j["children_padding"] = json::array({el.childrenPadding[0], el.childrenPadding[1], - el.childrenPadding[2], el.childrenPadding[3]}); - } - - if (const auto* te = dynamic_cast(&el)) { - j["type"] = "text"; - j["text"] = te->text; - j["font_family"] = te->font.family; - j["font_size"] = te->font.size; - j["font_italic"] = te->font.isItalic; - j["font_underline"] = te->font.isUnderline; - j["font_strikethrough"] = te->font.isStrikethrough; - j["font_weight"] = fontWeightToString(te->font.weight); - j["auto_scale"] = te->textStyle.autoScale; - j["text_align_x"] = alignmentHToString(te->textStyle.alignX); - j["text_align_y"] = alignmentVToString(te->textStyle.alignY); - j["ellipsize"] = ellipsizeToString(te->textStyle.ellipsize); - j["wrap"] = wrapModeToString(te->textStyle.wrapMode); - j["text_transform"] = textTransformToString(te->textStyle.transform); - if (te->textStyle.lineSpacing != 0.0f) j["line_spacing"] = te->textStyle.lineSpacing; - if (te->textStyle.letterSpacing != 0.0f) j["letter_spacing"] = te->textStyle.letterSpacing; - } else if (const auto* ie = dynamic_cast(&el)) { - j["type"] = "image"; - if (!ie->imagePath.empty()) j["image_path"] = ie->imagePath; - static const char* kScaleModeStr[] = {"stretch","contain","cover","fit_width","fit_height","none"}; - j["scale_mode"] = kScaleModeStr[static_cast(ie->imageScaleMode)]; - } else if (const auto* qe = dynamic_cast(&el)) { - j["type"] = "qr_code"; - j["text"] = qe->text; - } else { - j["type"] = "rectangle"; - } - - return j; + const IElement* root = ⪙ + while (root->GetParent()) root = root->GetParent(); // reach "__root" + return ogt::SerializeElement(&el, root); } void TitleDocument::applyMutation(std::function fn) diff --git a/model/TitleDocument.h b/model/TitleDocument.h index ec03d0b..102bd08 100644 --- a/model/TitleDocument.h +++ b/model/TitleDocument.h @@ -29,8 +29,12 @@ class TitleDocument : public QObject { VisualElement& getElement(const std::string& elementId); QString filePath() const { return m_filePath; } bool isModified() const { return m_modified; } + QString lastError() const { return m_lastError; } + // Schema-version compatibility diagnostic from the most recent load() + // (severity None when the file loaded cleanly, or on failure). + const Title::LoadDiagnostic& lastLoadDiagnostic() const { return m_lastLoadDiagnostic; } - // Display name (maps to title.id for persistence) + // Display name (maps to title.name for persistence) QString titleName() const; QUndoStack* undoStack() { return &m_undoStack; } @@ -49,6 +53,11 @@ class TitleDocument : public QObject { void setBrandColors(const QList& colors); static QList defaultBrandColors(); + // Per-element lock state — persisted in title.metadata["locked_ids"], but + // intentionally NOT on the undo stack (locking/unlocking is not undoable). + bool isElementLocked(const std::string& id) const; + void setElementLocked(const std::string& id, bool locked); + // Serialization of a single element to JSON (for clipboard/undo snapshots) static nlohmann::json elementToJson(const VisualElement& el); @@ -62,6 +71,8 @@ class TitleDocument : public QObject { QUndoStack m_undoStack; QString m_filePath; bool m_modified{false}; + QString m_lastError; + Title::LoadDiagnostic m_lastLoadDiagnostic; QList m_brandColors; void applyFitToChildren(); diff --git a/model/UndoCommands.cpp b/model/UndoCommands.cpp index 0516c3d..3821b4c 100644 --- a/model/UndoCommands.cpp +++ b/model/UndoCommands.cpp @@ -9,138 +9,11 @@ #include "engine/element_rectangle.h" #include "engine/element_text.h" #include "engine/spatial.h" +#include "engine/title.h" #include "engine/types.hpp" using json = nlohmann::json; -static Paint parsePaint(const json& v) -{ - if (v.is_null()) return Paint{}; - if (v.is_array() && v.size() >= 3) { - double a = v.size() >= 4 ? (double)v[3] : 1.0; - return Paint::Solid(v[0], v[1], v[2], a); - } - if (v.is_string()) - return Paint::Image(v.get()); - if (v.is_object()) { - std::string type = v.value("type", ""); - if (type == "image") return Paint::Image(v.value("path", "")); - Paint p = (type == "radial") - ? Paint::Radial(v.value("cx0", 0.0), v.value("cy0", 0.0), v.value("r0", 0.0), - v.value("cx1", 0.0), v.value("cy1", 0.0), v.value("r1", 1.0)) - : Paint::Linear(v.value("x0", 0.0), v.value("y0", 0.0), - v.value("x1", 1.0), v.value("y1", 0.0)); - if (v.contains("stops") && v["stops"].is_array()) { - for (const auto& s : v["stops"]) { - const auto& c = s.at("color"); - double a = c.size() >= 4 ? (double)c[3] : 1.0; - p.AddStop(s.value("offset", 0.0), c[0], c[1], c[2], a); - } - } - return p; - } - return Paint{}; -} - -static FontWeight parseFontWeight(const std::string& s) -{ - if (s == "thin") return FontWeight::Thin; - if (s == "ultralight") return FontWeight::UltraLight; - if (s == "semilight") return FontWeight::SemiLight; - if (s == "light") return FontWeight::Light; - if (s == "book") return FontWeight::Book; - if (s == "medium") return FontWeight::Medium; - if (s == "semibold") return FontWeight::SemiBold; - if (s == "bold") return FontWeight::Bold; - if (s == "ultrabold") return FontWeight::UltraBold; - if (s == "heavy") return FontWeight::Heavy; - if (s == "ultraheavy") return FontWeight::UltraHeavy; - return FontWeight::Normal; -} - -static HorizontalAlignment parseAlignmentH(const std::string& s) -{ - if (s == "justify") return HorizontalAlignment::Justify; - if (s == "center") return HorizontalAlignment::Center; - if (s == "right") return HorizontalAlignment::Right; - return HorizontalAlignment::Left; -} - -static VerticalAlignment parseAlignmentV(const std::string& s) -{ - if (s == "bottom") return VerticalAlignment::Bottom; - if (s == "middle") return VerticalAlignment::Middle; - return VerticalAlignment::Top; -} - -static Ellipsize parseEllipsize(const std::string& s) -{ - if (s == "start") return Ellipsize::Start; - if (s == "middle") return Ellipsize::Middle; - if (s == "end") return Ellipsize::End; - return Ellipsize::None; -} - -static WrapMode parseWrapMode(const std::string& s) -{ - if (s == "char") return WrapMode::Char; - if (s == "word_char") return WrapMode::WordChar; - return WrapMode::Word; -} - -static TextTransform parseTextTransform(const std::string& s) -{ - if (s == "capitalize") return TextTransform::Capitalize; - if (s == "uppercase") return TextTransform::Uppercase; - if (s == "lowercase") return TextTransform::Lowercase; - return TextTransform::None; -} - -static AnimationType parseAnimType(const std::string& s) -{ - if (s == "fade") return AnimationType::Fade; - if (s == "slide_up") return AnimationType::SlideUp; - if (s == "slide_down") return AnimationType::SlideDown; - if (s == "slide_left") return AnimationType::SlideLeft; - if (s == "slide_right") return AnimationType::SlideRight; - if (s == "scale_in") return AnimationType::ScaleIn; - if (s == "wipe_up") return AnimationType::WipeUp; - if (s == "wipe_down") return AnimationType::WipeDown; - if (s == "wipe_left") return AnimationType::WipeLeft; - if (s == "wipe_right") return AnimationType::WipeRight; - return AnimationType::None; -} - -static Easing parseEasing(const std::string& s) -{ - if (s == "ease_in") return Easing::EaseIn; - if (s == "ease_out") return Easing::EaseOut; - if (s == "ease_in_out") return Easing::EaseInOut; - if (s == "ease_in_cubic") return Easing::EaseInCubic; - if (s == "ease_out_cubic") return Easing::EaseOutCubic; - if (s == "ease_in_out_cubic") return Easing::EaseInOutCubic; - if (s == "ease_in_expo") return Easing::EaseInExpo; - if (s == "ease_out_expo") return Easing::EaseOutExpo; - if (s == "ease_in_out_expo") return Easing::EaseInOutExpo; - if (s == "ease_in_back") return Easing::EaseInBack; - if (s == "ease_out_back") return Easing::EaseOutBack; - if (s == "ease_in_out_back") return Easing::EaseInOutBack; - if (s == "ease_in_elastic") return Easing::EaseInElastic; - if (s == "ease_out_elastic") return Easing::EaseOutElastic; - if (s == "ease_in_out_elastic") return Easing::EaseInOutElastic; - return Easing::Linear; -} - -static AnimationDef parseAnimDef(const json& j) -{ - AnimationDef a; - a.type = parseAnimType(j.value("type", "none")); - a.easing = parseEasing(j.value("easing", "linear")); - a.duration = static_cast(j.value("duration", 0.5)); - a.delay = static_cast(j.value("delay", 0.0)); - return a; -} - // ───────────────────────────────────────────────────────────────────────────── // insertElementFromJson — re-parse a single element JSON blob into the title. // JSON x,y are LOCAL coords; converts to world before AddChild so SetParent @@ -149,122 +22,28 @@ static AnimationDef parseAnimDef(const json& j) static void insertElementFromJson(Title& t, const json& ej) { - const std::string type = ej.value("type", "rectangle"); - const std::string id = ej.value("id", ""); - - std::unique_ptr el; - if (type == "text") el = std::make_unique(); - else if (type == "image") el = std::make_unique(); - else if (type == "qr_code") el = std::make_unique(); - else el = std::make_unique(); - - el->SetId(id); + auto el = ogt::ParseElement(ej); // all fields incl data_anim / tile / justify - // Bounds (local coords from JSON) - double x = ej.value("x", 0.0), y = ej.value("y", 0.0); + // JSON bounds are LOCAL; convert to world before AddChild so Spatial::SetParent's + // world-preservation subtraction lands the element back at the correct local pos. + double x = ej.value("x", 0.0), y = ej.value("y", 0.0); double w = ej.value("w", 100.0), h = ej.value("h", 100.0); - el->zOrder = ej.value("z_order", 0); - el->opacity = static_cast(ej.value("opacity", 1.0)); - el->strokeWidth = static_cast(ej.value("stroke_width", 0.0)); - - if (ej.contains("fill")) el->fill = parsePaint(ej["fill"]); - if (ej.contains("stroke")) el->stroke = parsePaint(ej["stroke"]); - - if (ej.contains("corner_radius")) { - const auto& cr = ej["corner_radius"]; - if (cr.is_number()) { - float v = cr.get(); - el->cornerRadius[0] = el->cornerRadius[1] = el->cornerRadius[2] = el->cornerRadius[3] = v; - } else if (cr.is_array() && cr.size() == 4) { - for (int k = 0; k < 4; ++k) el->cornerRadius[k] = cr[k].get(); - } - } - - if (ej.value("fit_to_children", false)) { - el->fitToChildren = true; - if (ej.contains("children_padding") && ej["children_padding"].is_array() && - ej["children_padding"].size() == 4) { - for (int k = 0; k < 4; ++k) - el->childrenPadding[k] = ej["children_padding"][k].get(); - } - } - el->clipChildren = ej.value("clip_children", false); - - if (ej.contains("shadow") && ej["shadow"].is_object()) { - const auto& sh = ej["shadow"]; - el->shadow.enabled = sh.value("enabled", false); - el->shadow.offsetX = sh.value("offset_x", 4.0); - el->shadow.offsetY = sh.value("offset_y", 4.0); - el->shadow.blur = sh.value("blur", 8.0); - if (sh.contains("color") && sh["color"].is_array() && sh["color"].size() == 4) { - for (int k = 0; k < 4; ++k) el->shadow.color[k] = sh["color"][k].get(); - } - } - - if (ej.contains("anim_in")) el->inAnimation = parseAnimDef(ej["anim_in"]); - if (ej.contains("anim_out")) el->outAnimation = parseAnimDef(ej["anim_out"]); - - // Type-specific fields - if (auto* te = dynamic_cast(el.get())) { - te->text = ej.value("text", ""); - te->font.family = ej.value("font_family", ""); - te->font.size = static_cast(ej.value("font_size", 36.0)); - te->font.isItalic = ej.value("font_italic", false); - te->font.isUnderline = ej.value("font_underline", false); - te->font.isStrikethrough= ej.value("font_strikethrough", false); - te->font.weight = parseFontWeight(ej.value("font_weight", "normal")); - te->textStyle.autoScale = ej.value("auto_scale", false); - te->textStyle.alignX = parseAlignmentH(ej.value("text_align_x", "left")); - te->textStyle.alignY = parseAlignmentV(ej.value("text_align_y", "top")); - te->textStyle.ellipsize = parseEllipsize(ej.value("ellipsize", "none")); - te->textStyle.wrapMode = parseWrapMode(ej.value("wrap", "word")); - te->textStyle.transform = parseTextTransform(ej.value("text_transform", "none")); - te->textStyle.lineSpacing = static_cast(ej.value("line_spacing", 0.0)); - te->textStyle.letterSpacing = static_cast(ej.value("letter_spacing", 0.0)); - } else if (auto* ie = dynamic_cast(el.get())) { - ie->imagePath = ej.value("image_path", ""); - const std::string sm = ej.value("scale_mode", "stretch"); - if (sm == "contain") ie->imageScaleMode = ScaleMode::Contain; - else if (sm == "cover") ie->imageScaleMode = ScaleMode::Cover; - else if (sm == "fit_width") ie->imageScaleMode = ScaleMode::FitWidth; - else if (sm == "fit_height") ie->imageScaleMode = ScaleMode::FitHeight; - else if (sm == "none") ie->imageScaleMode = ScaleMode::None; - else ie->imageScaleMode = ScaleMode::Stretch; - } else if (auto* qe = dynamic_cast(el.get())) { - qe->text = ej.value("text", ""); - } - - // Find parent element (by id); default to root const std::string parentId = ej.value("parent", ""); IElement* parentEl = t.GetRoot(); if (!parentId.empty()) { - for (auto& pe : t.elements) { - if (pe->GetId() == parentId) { - parentEl = pe.get(); - break; - } - } + for (auto& pe : t.elements) + if (pe->GetId() == parentId) { parentEl = pe.get(); break; } } - - // Convert local coords to world coords so SetParent in AddChild is correct if (parentEl && parentEl != t.GetRoot()) { Point pw = parentEl->GetGlobalPosition(); - x += pw.x; - y += pw.y; + x += pw.x; y += pw.y; } el->SetBounds({x, y, w, h}); - // Rotation and shear must be set before AddChild - el->SetRotation(static_cast(ej.value("rotation", 0.0))); - el->SetShearX(static_cast(ej.value("shear_x", 0.0))); - el->SetShearY(static_cast(ej.value("shear_y", 0.0))); - VisualElement* rawEl = el.get(); t.elements.push_back(std::move(el)); - - if (parentEl) - parentEl->AddChild(rawEl); + if (parentEl) parentEl->AddChild(rawEl); } // ───────────────────────────────────────────────────────────────────────────── @@ -357,8 +136,8 @@ bool SetElementBoundsCmd::mergeWith(const QUndoCommand* other) // ───────────────────────────────────────────────────────────────────────────── SetElementRotationCmd::SetElementRotationCmd(TitleDocument* doc, std::string ei, float after, - QUndoCommand* parent) - : QUndoCommand(parent), m_doc(doc), m_ei(std::move(ei)), m_after(after) + int mergeTag, QUndoCommand* parent) + : QUndoCommand(parent), m_doc(doc), m_ei(std::move(ei)), m_after(after), m_mergeTag(mergeTag) { setText("Set rotation"); try { m_before = m_doc->getElement(m_ei).GetRotation(); } catch (...) { m_before = after; } @@ -378,7 +157,7 @@ void SetElementRotationCmd::redo() }); } -int SetElementRotationCmd::id() const { return ElemMergeTag::Rotation; } +int SetElementRotationCmd::id() const { return m_mergeTag; } bool SetElementRotationCmd::mergeWith(const QUndoCommand* other) { @@ -614,6 +393,33 @@ void SetCornerRadiusCmd::redo() }); } +// ───────────────────────────────────────────────────────────────────────────── +// SetElementIdCmd +// ───────────────────────────────────────────────────────────────────────────── + +SetElementIdCmd::SetElementIdCmd(TitleDocument* doc, std::string oldId, std::string newId, + QUndoCommand* parent) + : QUndoCommand(parent), m_doc(doc), m_oldId(std::move(oldId)), m_newId(std::move(newId)) +{ + setText("Rename element"); +} + +void SetElementIdCmd::redo() +{ + m_doc->applyMutation([&](Title&) { + try { m_doc->getElement(m_oldId).SetId(m_newId); } + catch (const std::runtime_error&) {} + }); +} + +void SetElementIdCmd::undo() +{ + m_doc->applyMutation([&](Title&) { + try { m_doc->getElement(m_newId).SetId(m_oldId); } + catch (const std::runtime_error&) {} + }); +} + // ───────────────────────────────────────────────────────────────────────────── // CopyElementStyleCmd // ───────────────────────────────────────────────────────────────────────────── diff --git a/model/UndoCommands.h b/model/UndoCommands.h index d112201..df2faef 100644 --- a/model/UndoCommands.h +++ b/model/UndoCommands.h @@ -75,6 +75,7 @@ enum : int { ShearY = 1009, LineSpacing = 1010, CharSpacing = 1011, + TextContent = 1012, }; } @@ -159,7 +160,7 @@ class SetElementBoundsCmd : public QUndoCommand { class SetElementRotationCmd : public QUndoCommand { public: SetElementRotationCmd(TitleDocument* doc, std::string ei, float after, - QUndoCommand* parent = nullptr); + int mergeTag = ElemMergeTag::Rotation, QUndoCommand* parent = nullptr); void undo() override; void redo() override; int id() const override; @@ -170,6 +171,7 @@ class SetElementRotationCmd : public QUndoCommand { std::string m_ei; float m_before; float m_after; + int m_mergeTag; }; // ── SetElementShearCmd ──────────────────────────────────────────────────────── @@ -282,6 +284,21 @@ class SetCornerRadiusCmd : public QUndoCommand { float m_after[4]; }; +// ── SetElementIdCmd — undoable element rename ───────────────────────────────── + +class SetElementIdCmd : public QUndoCommand { +public: + SetElementIdCmd(TitleDocument* doc, std::string oldId, std::string newId, + QUndoCommand* parent = nullptr); + void undo() override; + void redo() override; + +private: + TitleDocument* m_doc; + std::string m_oldId; + std::string m_newId; +}; + // ── CopyElementStyleCmd ─────────────────────────────────────────────────────── // Copies fill/stroke/strokeWidth/cornerRadius/opacity/shadow from src onto dst as // one undoable action. If both src and dst are TextElement, also copies font + diff --git a/ui/UiUtils.h b/ui/UiUtils.h index bd523bb..4ecaa8a 100644 --- a/ui/UiUtils.h +++ b/ui/UiUtils.h @@ -9,6 +9,7 @@ #include #include #include +#include // ── UpdateGuard ─────────────────────────────────────────────────────────────── // RAII guard that sets a bool flag on construction and clears it on @@ -23,6 +24,17 @@ struct UpdateGuard { bool& m_flag; }; +// ── WaitCursor ──────────────────────────────────────────────────────────────── +// RAII guard that pushes a Qt::WaitCursor override on construction and pops +// it on destruction — use around blocking file/image I/O so the user sees a +// busy cursor instead of the app appearing to hang. +struct WaitCursor { + WaitCursor() { QApplication::setOverrideCursor(Qt::WaitCursor); } + ~WaitCursor() { QApplication::restoreOverrideCursor(); } + WaitCursor(const WaitCursor&) = delete; + WaitCursor& operator=(const WaitCursor&) = delete; +}; + // ── Widget factories ────────────────────────────────────────────────────────── // Double spinbox with common ribbon defaults. diff --git a/ui/animationeditor.cpp b/ui/animationeditor.cpp deleted file mode 100644 index 74269b0..0000000 --- a/ui/animationeditor.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "animationeditor.h" -#include "engine/animation.h" -#include -#include - -AnimationEditor::AnimationEditor(QWidget* parent) : QWidget(parent) -{ - auto* layout = new QGridLayout(this); - layout->setColumnStretch(0, 0); - layout->setColumnStretch(1, 1); - layout->setColumnStretch(2, 1); - layout->setColumnStretch(3, 1); - - auto* typeLabel = new QLabel("Type"); - typeLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - typeComboBox = new QComboBox; - typeComboBox->addItems({"None", "Fade", "Scale In", "Slide Up", "Slide Down", "Slide Left", - "Slide Right", "Wipe Up", "Wipe Down", "Wipe Left", "Wipe Right"}); - layout->addWidget(typeLabel, 0, 0); - layout->addWidget(typeComboBox, 0, 1, 1, 3); - - auto* easingLabel = new QLabel("Easing"); - easingLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - easingComboBox = new QComboBox; - easingComboBox->addItems({ - "Linear", "Ease In", "Ease Out", "Ease In Out", - "Ease In (Cubic)", "Ease Out (Cubic)", "Ease In/Out (Cubic)", - "Ease In (Expo)", "Ease Out (Expo)", "Ease In/Out (Expo)", - "Ease In (Back)", "Ease Out (Back)", "Ease In/Out (Back)", - "Ease In (Elastic)", "Ease Out (Elastic)", "Ease In/Out (Elastic)", - }); - layout->addWidget(easingLabel, 1, 0); - layout->addWidget(easingComboBox, 1, 1, 1, 3); - - auto* durationLabel = new QLabel("Duration"); - durationLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - durationDoubleSpinBox = new QDoubleSpinBox; - durationDoubleSpinBox->setMaximum(100.0); - durationDoubleSpinBox->setSingleStep(0.05); - durationDoubleSpinBox->setSuffix(" s"); - durationDoubleSpinBox->setValue(0.5); - - auto* delayLabel = new QLabel("Delay"); - delayLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - delayDoubleSpinBox = new QDoubleSpinBox; - delayDoubleSpinBox->setMaximum(100.0); - delayDoubleSpinBox->setSingleStep(0.05); - delayDoubleSpinBox->setSuffix(" s"); - - layout->addWidget(durationLabel, 2, 0); - layout->addWidget(durationDoubleSpinBox, 2, 1); - layout->addWidget(delayLabel, 2, 2); - layout->addWidget(delayDoubleSpinBox, 2, 3); - - connect(typeComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &AnimationEditor::onTypeChanged); - connect(easingComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &AnimationEditor::onEasingChanged); - connect(durationDoubleSpinBox, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &AnimationEditor::onDurationChanged); - connect(delayDoubleSpinBox, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &AnimationEditor::onDelayChanged); -} - -void AnimationEditor::load(const AnimationDef& anim) -{ - typeComboBox->blockSignals(true); - easingComboBox->blockSignals(true); - durationDoubleSpinBox->blockSignals(true); - delayDoubleSpinBox->blockSignals(true); - - typeComboBox->setCurrentIndex(static_cast(anim.type)); - easingComboBox->setCurrentIndex(static_cast(anim.easing)); - durationDoubleSpinBox->setValue(static_cast(anim.duration)); - delayDoubleSpinBox->setValue(static_cast(anim.delay)); - - typeComboBox->blockSignals(false); - easingComboBox->blockSignals(false); - durationDoubleSpinBox->blockSignals(false); - delayDoubleSpinBox->blockSignals(false); -} - -AnimationDef AnimationEditor::getAnimationDef() const -{ - AnimationDef anim; - anim.type = static_cast(typeComboBox->currentIndex()); - anim.easing = static_cast(easingComboBox->currentIndex()); - anim.duration = static_cast(durationDoubleSpinBox->value()); - anim.delay = static_cast(delayDoubleSpinBox->value()); - return anim; -} - -void AnimationEditor::onTypeChanged(int) -{ - emit animationChanged(); -} -void AnimationEditor::onEasingChanged(int) -{ - emit animationChanged(); -} -void AnimationEditor::onDurationChanged(double) -{ - emit animationChanged(); -} -void AnimationEditor::onDelayChanged(double) -{ - emit animationChanged(); -} diff --git a/ui/animationeditor.h b/ui/animationeditor.h deleted file mode 100644 index 8e69e4f..0000000 --- a/ui/animationeditor.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef ANIMATIONEDITOR_H -#define ANIMATIONEDITOR_H - -#include -#include -#include - -struct AnimationDef; - -class AnimationEditor : public QWidget { - Q_OBJECT - -public: - explicit AnimationEditor(QWidget* parent = nullptr); - - void load(const AnimationDef& anim); - AnimationDef getAnimationDef() const; - -signals: - void animationChanged(); - -private slots: - void onTypeChanged(int index); - void onEasingChanged(int index); - void onDurationChanged(double value); - void onDelayChanged(double value); - -private: - QComboBox *typeComboBox, *easingComboBox; - QDoubleSpinBox *durationDoubleSpinBox, *delayDoubleSpinBox; -}; - -#endif // ANIMATIONEDITOR_H diff --git a/ui/graphicproperties.cpp b/ui/graphicproperties.cpp deleted file mode 100644 index 45aa3a6..0000000 --- a/ui/graphicproperties.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "graphicproperties.h" -#include "engine/scene.h" -#include "model/TitleDocument.h" -#include "model/UndoCommands.h" - -#include -#include - -GraphicProperties::GraphicProperties(TitleDocument* doc, QWidget* parent) - : QWidget(parent), m_doc(doc) -{ - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - - auto* form = new QFormLayout; - form->setRowWrapPolicy(QFormLayout::WrapAllRows); - - m_idEdit = new QLineEdit(this); - form->addRow("ID:", m_idEdit); - - m_zOrderSpin = new QSpinBox(this); - m_zOrderSpin->setRange(-9999, 9999); - form->addRow("Z Order:", m_zOrderSpin); - - layout->addLayout(form); - layout->addStretch(1); - - connect(m_idEdit, &QLineEdit::editingFinished, this, &GraphicProperties::onIdEditingFinished); - connect(m_zOrderSpin, QOverload::of(&QSpinBox::valueChanged), this, - &GraphicProperties::onZOrderChanged); - connect(m_doc, &TitleDocument::documentChanged, this, &GraphicProperties::onDocumentChanged); -} - -void GraphicProperties::setSelection(const std::string& graphicId) -{ - m_graphicId = graphicId; - onDocumentChanged(); -} - -void GraphicProperties::onDocumentChanged() -{ - if (m_updating) - return; - - const Scene& s = m_doc->scene(); - const Graphic* g = nullptr; - for (const auto& gr : s.graphics) - if (gr.id == m_graphicId) { - g = &gr; - break; - } - - if (!g) - return; - - m_updating = true; - m_idEdit->setText(QString::fromStdString(g->id)); - m_zOrderSpin->setValue(g->zOrder); - m_updating = false; -} - -void GraphicProperties::onIdEditingFinished() -{ - if (m_updating || m_graphicId.empty()) - return; - const std::string newId = m_idEdit->text().trimmed().toStdString(); - if (newId.empty() || newId == m_graphicId) - return; - - // Check uniqueness - for (const auto& g : m_doc->scene().graphics) - if (g.id != m_graphicId && g.id == newId) - return; - - const std::string oldId = m_graphicId; - m_graphicId = newId; - m_doc->undoStack()->push(new RenameGraphicCmd(m_doc, oldId, newId)); -} - -void GraphicProperties::onZOrderChanged(int value) -{ - if (m_updating || m_graphicId.empty()) - return; - m_doc->undoStack()->push(new SetGraphicFieldCmd( - m_doc, m_graphicId, value, [](Graphic& g) -> int& { return g.zOrder; }, "z_order")); -} diff --git a/ui/graphicproperties.h b/ui/graphicproperties.h deleted file mode 100644 index 46a1c39..0000000 --- a/ui/graphicproperties.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -class TitleDocument; - -class GraphicProperties : public QWidget { - Q_OBJECT -public: - explicit GraphicProperties(TitleDocument* doc, QWidget* parent = nullptr); - - void setSelection(const std::string& graphicId); - -private slots: - void onDocumentChanged(); - void onIdEditingFinished(); - void onZOrderChanged(int value); - -private: - TitleDocument* m_doc; - std::string m_graphicId; - bool m_updating{false}; - - QLineEdit* m_idEdit; - QSpinBox* m_zOrderSpin; -}; diff --git a/ui/painteditor.cpp b/ui/painteditor.cpp index b18d331..6367f79 100644 --- a/ui/painteditor.cpp +++ b/ui/painteditor.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -233,7 +234,8 @@ PaintEditor::PaintEditor(TitleDocument* doc, QWidget* parent) : QWidget(parent) if (doc) { auto* brandLabel = new QLabel("Brand Colors"); - brandLabel->setStyleSheet("font-size: 10px; color: gray;"); + brandLabel->setStyleSheet(QString("font-size: 10px; color: %1;") + .arg(brandLabel->palette().color(QPalette::PlaceholderText).name())); layout->addWidget(brandLabel, 2, 0, 1, 2); m_brandGrid = new BrandColorSwatchGrid(doc, BrandColorSwatchGrid::Mode::Full); diff --git a/ui/titleproperties.cpp b/ui/titleproperties.cpp deleted file mode 100644 index 3d04c3d..0000000 --- a/ui/titleproperties.cpp +++ /dev/null @@ -1,129 +0,0 @@ -#include "titleproperties.h" -#include "engine/title.h" -#include "model/TitleDocument.h" -#include "model/UndoCommands.h" - -#include -#include -#include -#include -#include -#include - -const TitleProperties::Preset TitleProperties::kPresets[] = { - {"1920 × 1080 (16:9 FHD)", 1920, 1080}, {"1280 × 720 (16:9 HD)", 1280, 720}, - {"3840 × 2160 (4K UHD)", 3840, 2160}, {"1080 × 1920 (9:16 Vert)", 1080, 1920}, - {"2560 × 1440 (16:9 QHD)", 2560, 1440}, {"Custom", 0, 0}, -}; - -TitleProperties::TitleProperties(TitleDocument* doc, QWidget* parent) : QWidget(parent), m_doc(doc) -{ - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - - auto* form = new QFormLayout; - form->setRowWrapPolicy(QFormLayout::WrapAllRows); - - m_nameEdit = new QLineEdit(this); - form->addRow("Name:", m_nameEdit); - - m_presetCombo = new QComboBox(this); - for (const auto& p : kPresets) - m_presetCombo->addItem(p.label); - form->addRow("Preset:", m_presetCombo); - - m_widthSpin = new QSpinBox(this); - m_widthSpin->setRange(1, 7680); - m_widthSpin->setSuffix(" px"); - form->addRow("Width:", m_widthSpin); - - m_heightSpin = new QSpinBox(this); - m_heightSpin->setRange(1, 7680); - m_heightSpin->setSuffix(" px"); - form->addRow("Height:", m_heightSpin); - - layout->addLayout(form); - layout->addStretch(); - - connect(m_nameEdit, &QLineEdit::editingFinished, this, &TitleProperties::onNameEditingFinished); - connect(m_presetCombo, QOverload::of(&QComboBox::currentIndexChanged), this, - &TitleProperties::onPresetChanged); - connect(m_widthSpin, &QSpinBox::editingFinished, this, &TitleProperties::onWidthFinished); - connect(m_heightSpin, &QSpinBox::editingFinished, this, &TitleProperties::onHeightFinished); - connect(m_doc, &TitleDocument::documentChanged, this, &TitleProperties::onDocumentChanged); - - onDocumentChanged(); -} - -int TitleProperties::findPresetIndex(int w, int h) const -{ - for (int i = 0; i < kCustomIndex; ++i) { - if (kPresets[i].w == w && kPresets[i].h == h) - return i; - } - return kCustomIndex; -} - -void TitleProperties::onDocumentChanged() -{ - if (m_updating) - return; - m_updating = true; - - const Title& t = m_doc->title(); - m_nameEdit->setText(m_doc->titleName()); - m_widthSpin->setValue(t.width); - m_heightSpin->setValue(t.height); - m_presetCombo->setCurrentIndex(findPresetIndex(t.width, t.height)); - - m_updating = false; -} - -void TitleProperties::onNameEditingFinished() -{ - if (m_updating) - return; - QString newName = m_nameEdit->text().trimmed(); - if (newName == m_doc->titleName()) - return; - m_doc->undoStack()->push(new SetTitleNameCmd(m_doc, newName)); -} - -void TitleProperties::onPresetChanged(int index) -{ - if (m_updating || index < 0 || index >= kCustomIndex) - return; - int w = kPresets[index].w; - int h = kPresets[index].h; - if (w == m_doc->title().width && h == m_doc->title().height) - return; - - m_updating = true; - m_widthSpin->setValue(w); - m_heightSpin->setValue(h); - m_updating = false; - - m_doc->undoStack()->push(new SetTitleDimensionsCmd(m_doc, w, h)); -} - -void TitleProperties::onWidthFinished() -{ - if (m_updating) - return; - int w = m_widthSpin->value(); - int h = m_heightSpin->value(); - if (w == m_doc->title().width) - return; - m_doc->undoStack()->push(new SetTitleDimensionsCmd(m_doc, w, h)); -} - -void TitleProperties::onHeightFinished() -{ - if (m_updating) - return; - int w = m_widthSpin->value(); - int h = m_heightSpin->value(); - if (h == m_doc->title().height) - return; - m_doc->undoStack()->push(new SetTitleDimensionsCmd(m_doc, w, h)); -} diff --git a/ui/titleproperties.h b/ui/titleproperties.h deleted file mode 100644 index 565d125..0000000 --- a/ui/titleproperties.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include - -class TitleDocument; -class QComboBox; -class QLineEdit; -class QSpinBox; - -class TitleProperties : public QWidget { - Q_OBJECT -public: - explicit TitleProperties(TitleDocument* doc, QWidget* parent = nullptr); - -private slots: - void onDocumentChanged(); - void onNameEditingFinished(); - void onPresetChanged(int index); - void onWidthFinished(); - void onHeightFinished(); - -private: - TitleDocument* m_doc; - QLineEdit* m_nameEdit; - QComboBox* m_presetCombo; - QSpinBox* m_widthSpin; - QSpinBox* m_heightSpin; - bool m_updating{false}; - - struct Preset { - const char* label; - int w; - int h; - }; - static const Preset kPresets[]; - static constexpr int kCustomIndex = 5; - - int findPresetIndex(int w, int h) const; -}; diff --git a/ui/transformeditor.cpp b/ui/transformeditor.cpp deleted file mode 100644 index 4c324b4..0000000 --- a/ui/transformeditor.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include "transformeditor.h" -#include -#include - -static QLabel* sectionLabel(const QString& text) -{ - auto* lbl = new QLabel(text); - lbl->setStyleSheet("QLabel { font-weight: bold; color: #aaa; }"); - return lbl; -} - -static QDoubleSpinBox* makeSpinBox() -{ - auto* sb = new QDoubleSpinBox; - sb->setRange(-9999, 9999); - sb->setDecimals(1); - return sb; -} - -TransformEditor::TransformEditor(QWidget* parent) : QWidget(parent) -{ - auto* layout = new QGridLayout(this); - - layout->addWidget(sectionLabel("Position (X/Y)"), 0, 0, 1, 2); - spinX = makeSpinBox(); - spinY = makeSpinBox(); - layout->addWidget(spinX, 1, 0); - layout->addWidget(spinY, 1, 1); - - layout->addWidget(sectionLabel("Size (Width/Height)"), 2, 0, 1, 2); - spinW = makeSpinBox(); - spinH = makeSpinBox(); - layout->addWidget(spinW, 3, 0); - layout->addWidget(spinH, 3, 1); - - layout->addWidget(sectionLabel("Rotation"), 4, 0, 1, 2); - spinRot = makeSpinBox(); - spinRot->setSuffix("°"); - layout->addWidget(spinRot, 5, 0, 1, 2); - - connect(spinX, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &TransformEditor::onSpinXChanged); - connect(spinY, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &TransformEditor::onSpinYChanged); - connect(spinW, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &TransformEditor::onSpinWChanged); - connect(spinH, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &TransformEditor::onSpinHChanged); - connect(spinRot, QOverload::of(&QDoubleSpinBox::valueChanged), this, - &TransformEditor::onSpinRotChanged); -} - -void TransformEditor::load(const Rectangle& bounds, float rotation) -{ - spinX->blockSignals(true); - spinY->blockSignals(true); - spinW->blockSignals(true); - spinH->blockSignals(true); - spinRot->blockSignals(true); - - spinX->setValue(bounds.x); - spinY->setValue(bounds.y); - spinW->setValue(bounds.width); - spinH->setValue(bounds.height); - spinRot->setValue(static_cast(rotation)); - - spinX->blockSignals(false); - spinY->blockSignals(false); - spinW->blockSignals(false); - spinH->blockSignals(false); - spinRot->blockSignals(false); -} - -void TransformEditor::onSpinXChanged(double value) -{ - emit xChanged(value); -} -void TransformEditor::onSpinYChanged(double value) -{ - emit yChanged(value); -} -void TransformEditor::onSpinWChanged(double value) -{ - emit wChanged(value); -} -void TransformEditor::onSpinHChanged(double value) -{ - emit hChanged(value); -} -void TransformEditor::onSpinRotChanged(double value) -{ - emit rotChanged(value); -} diff --git a/ui/transformeditor.h b/ui/transformeditor.h deleted file mode 100644 index c6d1473..0000000 --- a/ui/transformeditor.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifndef TRANSFORMEDITOR_H -#define TRANSFORMEDITOR_H - -#include -#include - -#include "engine/types.hpp" - -class TransformEditor : public QWidget { - Q_OBJECT - -public: - explicit TransformEditor(QWidget* parent = nullptr); - - void load(const Rectangle& bounds, float rotation); - -signals: - void xChanged(double); - void yChanged(double); - void wChanged(double); - void hChanged(double); - void rotChanged(double); - -private slots: - void onSpinXChanged(double value); - void onSpinYChanged(double value); - void onSpinWChanged(double value); - void onSpinHChanged(double value); - void onSpinRotChanged(double value); - -private: - QDoubleSpinBox *spinX, *spinY, *spinW, *spinH, *spinRot; -}; - -#endif // TRANSFORMEDITOR_H diff --git a/ui/widgets/AlignMath.h b/ui/widgets/AlignMath.h new file mode 100644 index 0000000..70206e6 --- /dev/null +++ b/ui/widgets/AlignMath.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include + +namespace alignmath { + +enum class AlignMode { Left, HCenter, Right, Top, VMiddle, Bottom }; + +// boxes: each element's axis-aligned bounding box in one common (title) space. +// Returns the per-element translation delta that aligns them to the group's +// union bounding box. (QRectF::right()==left()+width(), bottom()==top()+height() — +// no QRect off-by-one.) +inline std::vector alignDeltas(const std::vector& boxes, AlignMode mode) +{ + std::vector deltas(boxes.size(), QPointF(0, 0)); + if (boxes.empty()) return deltas; + QRectF g = boxes[0]; + for (const auto& b : boxes) g = g.united(b); + for (std::size_t i = 0; i < boxes.size(); ++i) { + const QRectF& b = boxes[i]; + double dx = 0, dy = 0; + switch (mode) { + case AlignMode::Left: dx = g.left() - b.left(); break; + case AlignMode::HCenter: dx = g.center().x() - b.center().x(); break; + case AlignMode::Right: dx = g.right() - b.right(); break; + case AlignMode::Top: dy = g.top() - b.top(); break; + case AlignMode::VMiddle: dy = g.center().y() - b.center().y(); break; + case AlignMode::Bottom: dy = g.bottom() - b.bottom(); break; + } + deltas[i] = QPointF(dx, dy); + } + return deltas; +} + +// Distribute the interior elements so all centers are evenly spaced between the +// two extreme elements along one axis. horizontal ⇒ X centers, else Y centers. +// Needs >=3 boxes; the two extremes stay put; returns zero deltas otherwise. +inline std::vector distributeDeltas(const std::vector& boxes, bool horizontal) +{ + const std::size_t n = boxes.size(); + std::vector deltas(n, QPointF(0, 0)); + if (n < 3) return deltas; + auto center = [&](std::size_t i) { + return horizontal ? boxes[i].center().x() : boxes[i].center().y(); + }; + std::vector order(n); + for (std::size_t i = 0; i < n; ++i) order[i] = i; + std::stable_sort(order.begin(), order.end(), + [&](std::size_t a, std::size_t b) { return center(a) < center(b); }); + const double first = center(order.front()); + const double last = center(order.back()); + const double step = (last - first) / (double)(n - 1); + for (std::size_t k = 1; k + 1 < n; ++k) { + const std::size_t idx = order[k]; + const double target = first + step * (double)k; + const double d = target - center(idx); + deltas[idx] = horizontal ? QPointF(d, 0) : QPointF(0, d); + } + return deltas; +} + +} // namespace alignmath diff --git a/ui/widgets/AnimationTimelineEditor.cpp b/ui/widgets/AnimationTimelineEditor.cpp new file mode 100644 index 0000000..d90ab54 --- /dev/null +++ b/ui/widgets/AnimationTimelineEditor.cpp @@ -0,0 +1,739 @@ +#include "AnimationTimelineEditor.h" + +#include "model/TitleDocument.h" +#include "engine/title.h" +#include "engine/visual_element.h" +#include "engine/animation.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int kRulerH = 22; +constexpr int kGutterW = 140; +constexpr int kRowH = 32; +constexpr int kHandleZoneWidth = 14; +constexpr int kPadPx = 400; +constexpr double kMinPps = 2.0; +constexpr double kMaxPps = 2000.0; +constexpr double kBasePps = 120.0; + +QString animationTypeName(AnimationType t) +{ + switch (t) { + case AnimationType::None: return "None"; + case AnimationType::Fade: return "Fade"; + case AnimationType::SlideUp: return "Slide Up"; + case AnimationType::SlideDown: return "Slide Down"; + case AnimationType::SlideLeft: return "Slide Left"; + case AnimationType::SlideRight: return "Slide Right"; + case AnimationType::ScaleIn: return "Scale In"; + case AnimationType::WipeUp: return "Wipe Up"; + case AnimationType::WipeDown: return "Wipe Down"; + case AnimationType::WipeLeft: return "Wipe Left"; + case AnimationType::WipeRight: return "Wipe Right"; + } + return {}; +} + +// Cannibalized from AnimationTimingEditor.cpp's qUtilsDrawButton: rounded +// gradient bar with left/right "handle" tick marks and elided label text. +void qUtilsDrawButton(QPainter *painter, + const QRect &rect, + const QColor &base, + bool sunken, + const QString &text, + int radius) +{ + const QColor textColor = base.darker(300); + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + + QRect r = rect.adjusted(1, 1, -1, -1); + + painter->setPen(QPen(base.darker(160), 1.0)); + painter->setBrush(Qt::NoBrush); + painter->drawRoundedRect(r, radius + 1, radius + 1); + + QLinearGradient grad(r.topLeft(), r.bottomLeft()); + if (sunken) { + grad.setColorAt(0.0, base.darker(115)); + grad.setColorAt(1.0, base.darker(105)); + } else { + grad.setColorAt(0.0, base.lighter(120)); + grad.setColorAt(1.0, base.darker(115)); + } + + painter->setPen(QPen(base.darker(140), 1.0)); + painter->setBrush(grad); + painter->drawRoundedRect(r, radius, radius); + + QRect textRect = r.adjusted(16, 2, -16, -2); + if (!text.isEmpty() && textRect.width() > 0) { + if (sunken) textRect.translate(0, 1); + + painter->save(); + painter->setClipRect(textRect); + + QFont font = painter->font(); + font.setBold(false); + painter->setFont(font); + + QFontMetrics fm(font); + QString elided = fm.elidedText(text, Qt::ElideRight, textRect.width()); + + painter->setPen(textColor); + painter->drawText(textRect, Qt::AlignLeft, elided); + + painter->restore(); + } + + int lx = rect.left(); + int rx = textRect.right() + 16; + painter->setPen(textColor); + painter->drawLine(lx + 5, textRect.y()+1, lx + 5, textRect.bottom()-2); + painter->drawLine(lx + 8, textRect.y()+1, lx + 8, textRect.bottom()-2); + painter->drawLine(rx - 5, textRect.y()+1, rx - 5, textRect.bottom()-2); + painter->drawLine(rx - 8, textRect.y()+1, rx - 8, textRect.bottom()-2); + + painter->restore(); +} + +constexpr AnimationType kAllTypes[] = { + AnimationType::None, + AnimationType::Fade, + AnimationType::SlideUp, AnimationType::SlideDown, + AnimationType::SlideLeft, AnimationType::SlideRight, + AnimationType::ScaleIn, + AnimationType::WipeUp, AnimationType::WipeDown, + AnimationType::WipeLeft, AnimationType::WipeRight, +}; + +struct EasingEntry { Easing easing; const char* name; }; +constexpr EasingEntry kAllEasings[] = { + { Easing::Linear, "Linear" }, + { Easing::EaseIn, "Ease In" }, + { Easing::EaseOut, "Ease Out" }, + { Easing::EaseInOut, "Ease In/Out" }, + { Easing::EaseInCubic, "Ease In (Cubic)" }, + { Easing::EaseOutCubic, "Ease Out (Cubic)" }, + { Easing::EaseInOutCubic, "Ease In/Out (Cubic)" }, + { Easing::EaseInExpo, "Ease In (Expo)" }, + { Easing::EaseOutExpo, "Ease Out (Expo)" }, + { Easing::EaseInOutExpo, "Ease In/Out (Expo)" }, + { Easing::EaseInBack, "Ease In (Back)" }, + { Easing::EaseOutBack, "Ease Out (Back)" }, + { Easing::EaseInOutBack, "Ease In/Out (Back)" }, + { Easing::EaseInElastic, "Ease In (Elastic)" }, + { Easing::EaseOutElastic, "Ease Out (Elastic)" }, + { Easing::EaseInOutElastic,"Ease In/Out (Elastic)" }, +}; + +// Cannibalized from ScrubRuler.cpp: adaptive tick step so major ticks (every +// 5th minor) land ~50px apart. +constexpr float kTickSteps[] = {0.05f, 0.1f, 0.25f, 0.5f, 1.0f, 2.0f, 5.0f, 10.0f}; + +} // namespace + +AnimationTimelineEditor::AnimationTimelineEditor(TitleDocument* doc, QWidget* parent) + : QAbstractScrollArea(parent) + , m_doc(doc) +{ + viewport()->setMouseTracking(true); + setFrameStyle(QFrame::NoFrame); + setMinimumHeight(150); + horizontalScrollBar()->setSingleStep(20); + verticalScrollBar()->setSingleStep(kRowH); + rebuild(); +} + +void AnimationTimelineEditor::setSlot(Slot s) +{ + if (m_slot == s) + return; + m_slot = s; + rebuild(); +} + +void AnimationTimelineEditor::rebuild() +{ + // Remember the active row's element so it can be re-resolved by identity + // after the rows are rebuilt (rows are position-indexed, not id-stable). + int activeElementIndex = -1; + if (m_activeRow >= 0 && m_activeRow < int(m_rows.size())) + activeElementIndex = m_rows[m_activeRow].elementIndex; + + m_rows.clear(); + + if (m_doc) { + const Title& t = m_doc->title(); + for (size_t i = 1; i < t.elements.size(); ++i) { + const auto* ve = dynamic_cast(t.elements[i].get()); + if (!ve) + continue; + + AnimationDef def; + switch (m_slot) { + case Slot::In: def = ve->inAnimation; break; + case Slot::Out: def = ve->outAnimation; break; + case Slot::DataIn: def = ve->dataInAnimation; break; + case Slot::DataOut: def = ve->dataOutAnimation;break; + } + m_rows.push_back(Row{int(i), ve->GetId(), def}); + } + } + + // Re-resolve the active row by element index (may have shifted or vanished). + m_activeRow = -1; + if (activeElementIndex >= 0) { + for (int i = 0; i < int(m_rows.size()); ++i) { + if (m_rows[i].elementIndex == activeElementIndex) { + m_activeRow = i; + break; + } + } + } + + updateScrollRanges(); + viewport()->update(); +} + +void AnimationTimelineEditor::updateScrollRanges() +{ + double contentEndSec = 0.0; + for (const auto& r : m_rows) + contentEndSec = std::max(contentEndSec, double(r.def.delay + r.def.duration)); + + const double viewportSec = viewport()->width() / m_pps; + const double maxSec = std::max({contentEndSec, m_playheadTime, viewportSec}); + + horizontalScrollBar()->setRange(0, int(maxSec * m_pps) + kPadPx); + horizontalScrollBar()->setPageStep(viewport()->width()); + horizontalScrollBar()->setSingleStep(int(m_pps / 10.0) + 1); + + // The ruler band overlays the top kRulerH of the row area, so the content + // extent must include it for the last rows to be scrollable into view. + verticalScrollBar()->setRange(0, std::max(0, int(m_rows.size()) * kRowH + kRulerH - viewport()->height())); + verticalScrollBar()->setPageStep(viewport()->height()); + verticalScrollBar()->setSingleStep(kRowH); +} + +int AnimationTimelineEditor::timeToX(double t) const +{ + return kGutterW + int(t * m_pps) - horizontalScrollBar()->value(); +} + +double AnimationTimelineEditor::xToTime(int x) const +{ + return (x - kGutterW + horizontalScrollBar()->value()) / m_pps; +} + +int AnimationTimelineEditor::rowTopY(int row) const +{ + return kRulerH + row * kRowH - verticalScrollBar()->value(); +} + +int AnimationTimelineEditor::yToRow(int y) const +{ + int r = (y - kRulerH + verticalScrollBar()->value()) / kRowH; + return r; +} + +void AnimationTimelineEditor::resizeEvent(QResizeEvent* event) +{ + QAbstractScrollArea::resizeEvent(event); + updateScrollRanges(); +} + +void AnimationTimelineEditor::scrollContentsBy(int dx, int dy) +{ + Q_UNUSED(dx); + Q_UNUSED(dy); + viewport()->update(); +} + +void AnimationTimelineEditor::paintEvent(QPaintEvent*) +{ + QPainter p(viewport()); + p.setRenderHint(QPainter::Antialiasing, true); + + const int vw = viewport()->width(); + const int vh = viewport()->height(); + + p.fillRect(viewport()->rect(), QColor(30, 30, 30)); + + // --- 2. clip rows (culled, clipped to x >= kGutterW) --- + p.save(); + p.setClipRect(kGutterW, 0, vw - kGutterW, vh); + for (int r = 0; r < int(m_rows.size()); ++r) { + const int y = rowTopY(r); + if (y + kRowH < kRulerH || y > vh) + continue; + + const Row& row = m_rows[r]; + const AnimationDef& drawDef = + (m_dragZone != DragZone::None && r == m_dragRow) ? m_dragDef : row.def; + + QColor rowBg = (r % 2 == 0) ? QColor(38, 38, 38) : QColor(34, 34, 34); + if (r == m_activeRow) + rowBg = QColor(52, 60, 70); + p.fillRect(QRect(kGutterW, y, vw - kGutterW, kRowH), rowBg); + if (r == m_activeRow) { + p.setPen(QPen(QColor(90, 160, 230), 1)); + p.drawRect(QRect(kGutterW, y, vw - kGutterW - 1, kRowH - 1)); + } + + if (drawDef.type == AnimationType::None) { + const int x0 = timeToX(0.0); + const int x1 = timeToX(0.5); + QRect ph(x0, y + 3, std::max(4, x1 - x0), kRowH - 6); + p.save(); + QPen pen(QColor(90, 90, 90)); + pen.setStyle(Qt::DashLine); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawRect(ph); + p.restore(); + } else { + const int x0 = timeToX(drawDef.delay); + const int x1 = timeToX(drawDef.delay + drawDef.duration); + QRect br(x0, y + 3, x1 - x0, kRowH - 6); + qUtilsDrawButton(&p, br, QColor(241, 162, 24), false, + animationTypeName(drawDef.type), 3); + } + } + p.restore(); + + // --- 3. name gutter overlay --- + p.save(); + p.setClipRect(0, kRulerH, kGutterW, vh); + p.fillRect(QRect(0, kRulerH, kGutterW, vh), QColor(26, 26, 26)); + for (int r = 0; r < int(m_rows.size()); ++r) { + const int y = rowTopY(r); + if (y + kRowH < kRulerH || y > vh) + continue; + + QColor rowBg = (r % 2 == 0) ? QColor(38, 38, 38) : QColor(34, 34, 34); + if (r == m_activeRow) + rowBg = QColor(52, 60, 70); + p.fillRect(QRect(0, y, kGutterW, kRowH), rowBg); + + p.setPen(QColor(220, 220, 220)); + QRect labelRect(4, y, kGutterW - 10, kRowH); + const QString elided = QFontMetrics(p.font()).elidedText( + QString::fromStdString(m_rows[r].id), Qt::ElideLeft, labelRect.width()); + p.drawText(labelRect, Qt::AlignRight | Qt::AlignVCenter, elided); + } + p.restore(); + + // --- 4. ruler band overlay --- + p.save(); + p.setClipRect(kGutterW, 0, vw - kGutterW, kRulerH); + p.fillRect(QRect(kGutterW, 0, vw - kGutterW, kRulerH), QColor(22, 22, 22)); + + float minorStep = 10.0f; + for (float s : kTickSteps) { + if (m_pps * s * 5.0 >= 50.0) { minorStep = s; break; } + } + + const double startTime = xToTime(kGutterW); + const double endTime = xToTime(vw); + int kStart = int(std::floor(startTime / minorStep)) - 1; + int kEnd = int(std::ceil(endTime / minorStep)) + 1; + kStart = std::max(0, kStart); + + for (int k = kStart; k <= kEnd; ++k) { + const float t = k * minorStep; + const int x = timeToX(t); + if (x < kGutterW || x > vw) + continue; + + const bool isMajor = (k % 5 == 0); + if (isMajor) { + p.setPen(QColor(130, 130, 130)); + p.drawLine(x, kRulerH - 9, x, kRulerH); + QFont f = p.font(); + f.setPointSize(7); + p.setFont(f); + p.drawText(QRect(x + 2, 0, 40, kRulerH - 7), + Qt::AlignLeft | Qt::AlignVCenter, + QString("%1s").arg(double(t), 0, 'f', t < 1.0f ? 2 : 1)); + } else { + p.setPen(QColor(65, 65, 65)); + p.drawLine(x, kRulerH - 4, x, kRulerH); + } + } + p.restore(); + + // --- 5. top-left corner --- + p.fillRect(QRect(0, 0, kGutterW, kRulerH), QColor(26, 26, 26)); + + // --- 6. playhead --- + const int cx = timeToX(m_playheadTime); + if (cx >= kGutterW) { + p.save(); + p.setClipRect(kGutterW, 0, vw - kGutterW, vh); + p.setPen(QPen(QColor(255, 200, 50), 2)); + p.drawLine(cx, 0, cx, vh); + p.restore(); + + p.setPen(Qt::NoPen); + p.setBrush(QColor(255, 200, 50)); + const QPoint tri[3] = { {cx - 6, 0}, {cx + 6, 0}, {cx, 10} }; + p.drawPolygon(tri, 3); + } +} + +AnimationTimelineEditor::HitResult AnimationTimelineEditor::hitTest(const QPoint& pos) const +{ + HitResult res; + + if (pos.y() < kRulerH) { + res.zone = HitZone::Ruler; + return res; + } + + if (pos.x() < kGutterW) { + res.zone = HitZone::Gutter; + const int r = yToRow(pos.y()); + if (r >= 0 && r < int(m_rows.size())) + res.row = r; + return res; + } + + const int r = yToRow(pos.y()); + if (r < 0 || r >= int(m_rows.size())) { + res.zone = HitZone::Empty; + return res; + } + + res.zone = HitZone::Row; + res.row = r; + + const Row& row = m_rows[r]; + if (row.def.type == AnimationType::None) { + res.dragZone = DragZone::None; + return res; + } + + const int x0 = timeToX(row.def.delay); + const int x1 = timeToX(row.def.delay + row.def.duration); + const QRect br(x0, rowTopY(r) + 3, x1 - x0, kRowH - 6); + + if (!br.contains(pos)) { + res.dragZone = DragZone::None; + return res; + } + + if (pos.x() <= br.left() + kHandleZoneWidth) + res.dragZone = DragZone::LeftHandle; + else if (pos.x() >= br.right() - kHandleZoneWidth) + res.dragZone = DragZone::RightHandle; + else + res.dragZone = DragZone::Body; + + return res; +} + +void AnimationTimelineEditor::updateCursorForPos(const QPoint& pos) +{ + const HitResult hit = hitTest(pos); + if (hit.zone == HitZone::Ruler) { + viewport()->setCursor(Qt::SizeHorCursor); + return; + } + switch (hit.dragZone) { + case DragZone::LeftHandle: + case DragZone::RightHandle: + viewport()->setCursor(Qt::SizeHorCursor); + break; + case DragZone::Body: + viewport()->setCursor(Qt::SizeAllCursor); + break; + default: + viewport()->unsetCursor(); + break; + } +} + +void AnimationTimelineEditor::mousePressEvent(QMouseEvent* event) +{ + if (event->button() != Qt::LeftButton) + return; + + const HitResult hit = hitTest(event->pos()); + + if (hit.zone == HitZone::Ruler) { + m_draggingPlayhead = true; + m_playheadTime = std::max(0.0, xToTime(event->pos().x())); + viewport()->update(); + emit playheadMoved(m_playheadTime); + event->accept(); + return; + } + + if ((hit.zone == HitZone::Gutter || hit.zone == HitZone::Row) && hit.row >= 0) { + m_activeRow = hit.row; + emit rowClicked(m_rows[hit.row].elementIndex); + emit activeClipChanged(true, m_rows[hit.row].def); + viewport()->update(); + + if (hit.zone == HitZone::Row && hit.dragZone != DragZone::None) { + m_dragZone = hit.dragZone; + m_dragRow = hit.row; + m_dragStartX = event->pos().x(); + m_dragStartDelay = m_rows[hit.row].def.delay; + m_dragStartDuration = m_rows[hit.row].def.duration; + m_dragDef = m_rows[hit.row].def; + } + } + + event->accept(); +} + +void AnimationTimelineEditor::mouseMoveEvent(QMouseEvent* event) +{ + if (m_draggingPlayhead) { + m_playheadTime = std::max(0.0, xToTime(event->pos().x())); + viewport()->update(); + emit playheadMoved(m_playheadTime); + event->accept(); + return; + } + + if (m_dragZone != DragZone::None) { + const float deltaSec = float(event->pos().x() - m_dragStartX) / float(m_pps); + const float minDur = float(2 * kHandleZoneWidth) / float(m_pps); + auto snap = [](float v) { return std::round(v * 100.0f) / 100.0f; }; + + switch (m_dragZone) { + case DragZone::LeftHandle: { + const float maxDelay = m_dragStartDelay + m_dragStartDuration - minDur; + float delay = std::max(0.0f, std::min(m_dragStartDelay + deltaSec, maxDelay)); + delay = snap(delay); + m_dragDef.delay = delay; + m_dragDef.duration = (m_dragStartDelay + m_dragStartDuration) - delay; + break; + } + case DragZone::Body: { + m_dragDef.delay = snap(std::max(0.0f, m_dragStartDelay + deltaSec)); + break; + } + case DragZone::RightHandle: { + m_dragDef.duration = snap(std::max(minDur, m_dragStartDuration + deltaSec)); + break; + } + default: + break; + } + + viewport()->update(); + event->accept(); + return; + } + + updateCursorForPos(event->pos()); + event->accept(); +} + +void AnimationTimelineEditor::mouseReleaseEvent(QMouseEvent* event) +{ + if (event->button() != Qt::LeftButton) + return; + + if (m_draggingPlayhead) { + m_draggingPlayhead = false; + event->accept(); + return; + } + + if (m_dragZone != DragZone::None && m_dragRow >= 0 && m_dragRow < int(m_rows.size())) { + const int elementIndex = m_rows[m_dragRow].elementIndex; + m_rows[m_dragRow].def = m_dragDef; + m_dragZone = DragZone::None; + m_dragRow = -1; + + emit clipEdited(elementIndex, m_slot, m_dragDef); + emit activeClipChanged(true, m_dragDef); + + viewport()->update(); + updateCursorForPos(event->pos()); + event->accept(); + return; + } + + m_dragZone = DragZone::None; + m_dragRow = -1; + event->accept(); +} + +void AnimationTimelineEditor::wheelEvent(QWheelEvent* event) +{ + const QPoint pos = event->position().toPoint(); + + if (event->modifiers() & Qt::ControlModifier) { + const double tCursor = xToTime(pos.x()); + const double factor = (event->angleDelta().y() > 0) ? 1.15 : (1.0 / 1.15); + m_pps = std::clamp(m_pps * factor, kMinPps, kMaxPps); + updateScrollRanges(); + horizontalScrollBar()->setValue(int(tCursor * m_pps) - (pos.x() - kGutterW)); + viewport()->update(); + emit zoomChanged(zoomPercent()); + event->accept(); + return; + } + + if (event->modifiers() & Qt::ShiftModifier) { + horizontalScrollBar()->setValue(horizontalScrollBar()->value() - event->angleDelta().y()); + event->accept(); + return; + } + + verticalScrollBar()->setValue(verticalScrollBar()->value() - event->angleDelta().y()); + event->accept(); +} + +void AnimationTimelineEditor::contextMenuEvent(QContextMenuEvent* event) +{ + const HitResult hit = hitTest(event->pos()); + if (hit.zone != HitZone::Row || hit.row < 0) + return; + + const int row = hit.row; + const int elementIndex = m_rows[row].elementIndex; + + m_activeRow = row; + emit activeClipChanged(true, m_rows[row].def); + viewport()->update(); + + QMenu menu(this); + + QMenu* typeMenu = menu.addMenu("Set Animation Type"); + for (AnimationType t : kAllTypes) { + QAction* a = typeMenu->addAction(animationTypeName(t)); + a->setCheckable(true); + a->setChecked(t == m_rows[row].def.type); + connect(a, &QAction::triggered, this, [this, row, elementIndex, t] { + if (row < 0 || row >= int(m_rows.size())) + return; + AnimationDef def = m_rows[row].def; + def.type = t; + m_rows[row].def = def; + emit clipEdited(elementIndex, m_slot, def); + emit activeClipChanged(true, def); + viewport()->update(); + }); + } + + QMenu* easingMenu = menu.addMenu("Set Easing"); + for (const auto& e : kAllEasings) { + QAction* a = easingMenu->addAction(e.name); + a->setCheckable(true); + a->setChecked(e.easing == m_rows[row].def.easing); + connect(a, &QAction::triggered, this, [this, row, elementIndex, e] { + if (row < 0 || row >= int(m_rows.size())) + return; + AnimationDef def = m_rows[row].def; + def.easing = e.easing; + m_rows[row].def = def; + emit clipEdited(elementIndex, m_slot, def); + emit activeClipChanged(true, def); + viewport()->update(); + }); + } + + menu.exec(event->globalPos()); + event->accept(); +} + +void AnimationTimelineEditor::focusElement(int elementIndex) +{ + for (int i = 0; i < int(m_rows.size()); ++i) { + if (m_rows[i].elementIndex == elementIndex) { + m_activeRow = i; + scrollRowIntoView(i); + emit activeClipChanged(true, m_rows[i].def); + viewport()->update(); + return; + } + } + clearActive(); +} + +void AnimationTimelineEditor::clearActive() +{ + m_activeRow = -1; + emit activeClipChanged(false, AnimationDef{}); + viewport()->update(); +} + +void AnimationTimelineEditor::scrollRowIntoView(int row) +{ + // Visible row window is [kRulerH, viewH] because the ruler overlays the top. + const int viewH = viewport()->height(); + const int topNeeded = row * kRowH; // scroll to put row flush under the ruler + const int bottomNeeded = (row + 1) * kRowH + kRulerH - viewH; // scroll to reveal the row's bottom + int v = verticalScrollBar()->value(); + if (v > topNeeded) { + v = topNeeded; + } else if (v < bottomNeeded) { + v = bottomNeeded; + } + verticalScrollBar()->setValue(std::max(0, v)); +} + +int AnimationTimelineEditor::zoomPercent() const +{ + return int(std::lround(m_pps / kBasePps * 100.0)); +} + +void AnimationTimelineEditor::setZoomPercent(int percent) +{ + const double desiredPps = kBasePps * percent / 100.0; + const double clampedPps = std::clamp(desiredPps, kMinPps, kMaxPps); + + const double tLeft = xToTime(kGutterW); + m_pps = clampedPps; + updateScrollRanges(); + horizontalScrollBar()->setValue(int(tLeft * m_pps)); + viewport()->update(); + emit zoomChanged(zoomPercent()); +} + +void AnimationTimelineEditor::zoomToFit() +{ + double dur = contentDuration(); + if (dur <= 0.01) + dur = 2.0; + + const int availPx = std::max(50, viewport()->width() - kGutterW - 20); + m_pps = std::clamp(availPx / dur, kMinPps, kMaxPps); + horizontalScrollBar()->setValue(0); + updateScrollRanges(); + viewport()->update(); + emit zoomChanged(zoomPercent()); +} + +double AnimationTimelineEditor::contentDuration() const +{ + double maxEnd = 0.0; + for (const auto& r : m_rows) + maxEnd = std::max(maxEnd, double(r.def.delay + r.def.duration)); + return std::max(0.0, maxEnd); +} + +void AnimationTimelineEditor::setPlayhead(double t) +{ + m_playheadTime = std::max(0.0, t); + viewport()->update(); +} diff --git a/ui/widgets/AnimationTimelineEditor.h b/ui/widgets/AnimationTimelineEditor.h new file mode 100644 index 0000000..bc88fa6 --- /dev/null +++ b/ui/widgets/AnimationTimelineEditor.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include "engine/animation.h" + +class TitleDocument; + +class AnimationTimelineEditor : public QAbstractScrollArea { + Q_OBJECT +public: + enum class Slot { In, Out, DataIn, DataOut }; + + explicit AnimationTimelineEditor(TitleDocument* doc, QWidget* parent = nullptr); + + void setSlot(Slot s); // switch which AnimationDef field is shown; rebuild() + Slot slot() const { return m_slot; } + + void rebuild(); // re-read title().elements, recompute scroll ranges, repaint + void focusElement(int elementIndex); // set active row + scroll it into view + emit activeClipChanged + void clearActive(); // no active row; emit activeClipChanged(false, {}) + + int zoomPercent() const; // round(m_pps / kBasePps * 100) + void setZoomPercent(int percent); // clamp so m_pps stays in [kMinPps,kMaxPps]; keep left edge fixed; update ranges; repaint; emit zoomChanged(actualPercent) + void zoomToFit(); // fit all clips (contentDuration) into the viewport clip area; reset pan to 0; emit zoomChanged + double contentDuration() const; // max(delay+duration) over m_rows, >= 0 + void setPlayhead(double t); // set m_playheadTime = max(0,t); viewport()->update(); NO signal + double playhead() const { return m_playheadTime; } + +signals: + void activeClipChanged(bool valid, AnimationDef def); // active row's clip -> side panel + void rowClicked(int elementIndex); // user clicked a row/clip + void clipEdited(int elementIndex, AnimationTimelineEditor::Slot slot, AnimationDef def); + void zoomChanged(int percent); + void playheadMoved(double t); // emitted only when the USER drags the playhead (not from setPlayhead) + +protected: + void paintEvent(QPaintEvent*) override; + void mousePressEvent(QMouseEvent*) override; + void mouseMoveEvent(QMouseEvent*) override; + void mouseReleaseEvent(QMouseEvent*) override; + void wheelEvent(QWheelEvent*) override; + void contextMenuEvent(QContextMenuEvent*) override; + void resizeEvent(QResizeEvent*) override; + void scrollContentsBy(int dx, int dy) override; + +private: + struct Row { + int elementIndex; + std::string id; + AnimationDef def; + }; + + enum class DragZone { None, LeftHandle, Body, RightHandle }; + enum class HitZone { Ruler, Gutter, Row, Empty }; + struct HitResult { + HitZone zone{HitZone::Empty}; + int row{-1}; + DragZone dragZone{DragZone::None}; + }; + + TitleDocument* m_doc; + Slot m_slot{Slot::In}; + std::vector m_rows; + + double m_pps{120.0}; // pixels per second (zoom) + double m_playheadTime{0.0}; + int m_activeRow{-1}; // index into m_rows, or -1 + + // drag state + DragZone m_dragZone{DragZone::None}; + int m_dragRow{-1}; + int m_dragStartX{0}; + float m_dragStartDelay{0.0f}; + float m_dragStartDuration{0.0f}; + AnimationDef m_dragDef; // live local copy during drag + + bool m_draggingPlayhead{false}; + + // coordinate model + int timeToX(double t) const; + double xToTime(int x) const; + int rowTopY(int row) const; + int yToRow(int y) const; + + void updateScrollRanges(); + HitResult hitTest(const QPoint& pos) const; + void updateCursorForPos(const QPoint& pos); + void scrollRowIntoView(int row); +}; diff --git a/ui/widgets/AnimationTimingEditor.cpp b/ui/widgets/AnimationTimingEditor.cpp deleted file mode 100644 index 66642df..0000000 --- a/ui/widgets/AnimationTimingEditor.cpp +++ /dev/null @@ -1,303 +0,0 @@ -#include "AnimationTimingEditor.h" -#include -#include -#include -#include -#include -#include - -static QString animationTypeName(AnimationType t) -{ - switch (t) { - case AnimationType::None: return "None"; - case AnimationType::Fade: return "Fade"; - case AnimationType::SlideUp: return "Slide Up"; - case AnimationType::SlideDown: return "Slide Down"; - case AnimationType::SlideLeft: return "Slide Left"; - case AnimationType::SlideRight: return "Slide Right"; - case AnimationType::ScaleIn: return "Scale In"; - case AnimationType::WipeUp: return "Wipe Up"; - case AnimationType::WipeDown: return "Wipe Down"; - case AnimationType::WipeLeft: return "Wipe Left"; - case AnimationType::WipeRight: return "Wipe Right"; - } - return {}; -} - -static constexpr int kHandleZoneWidth = 14; - -static void qUtilsDrawButton(QPainter *painter, - const QRect &rect, - const QColor &base, - bool sunken, - const QString &text, - int radius) -{ - const QColor textColor = base.darker(300); - - painter->save(); - painter->setRenderHint(QPainter::Antialiasing); - - QRect r = rect.adjusted(1, 1, -1, -1); - - // --- outer ring --- - painter->setPen(QPen(base.darker(160), 1.0)); - painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(r, radius + 1, radius + 1); - - // --- gradient fill --- - QLinearGradient grad(r.topLeft(), r.bottomLeft()); - if (sunken) { - grad.setColorAt(0.0, base.darker(115)); - grad.setColorAt(1.0, base.darker(105)); - } else { - grad.setColorAt(0.0, base.lighter(120)); - grad.setColorAt(1.0, base.darker(115)); - } - - painter->setPen(QPen(base.darker(140), 1.0)); - painter->setBrush(grad); - painter->drawRoundedRect(r, radius, radius); - - // --- text --- - QRect textRect = r.adjusted(16, 2, -16, -2); - if (!text.isEmpty()) { - if (sunken) textRect.translate(0, 1); - - painter->save(); - painter->setClipRect(textRect); - - QFont font = painter->font(); - font.setBold(true); - painter->setFont(font); - - QFontMetrics fm(font); - QString elided = fm.elidedText(text, Qt::ElideRight, textRect.width()); - - painter->setPen(textColor); - painter->drawText(textRect, Qt::AlignLeft, elided); - - painter->restore(); - } - - // --- handles --- - int lx = rect.left(); - int rx = textRect.right() + 16; - painter->setPen(textColor); - painter->drawLine(lx + 5, textRect.y()+1, lx + 5, textRect.bottom()-2); - painter->drawLine(lx + 8, textRect.y()+1, lx + 8, textRect.bottom()-2); - painter->drawLine(rx - 5, textRect.y()+1, rx - 5, textRect.bottom()-2); - painter->drawLine(rx - 8, textRect.y()+1, rx - 8, textRect.bottom()-2); - - painter->restore(); -} - -AnimationTimingEditor::AnimationTimingEditor(QWidget* parent) : QWidget{parent} { - setMinimumSize(50, 24); - setMaximumHeight(24); - setMouseTracking(true); -} - -void AnimationTimingEditor::load(const AnimationDef &def) -{ - m_duration = def.duration; - m_delay = def.delay; - m_type = def.type; - m_easing = def.easing; - m_duration = def.duration; - m_delay = def.delay; - update(); -} - -void AnimationTimingEditor::setMaxDuration(float maxDuration) -{ - m_maxDuration = qMax(maxDuration, 0.01f); - update(); -} - -void AnimationTimingEditor::paintEvent(QPaintEvent *) -{ - const QColor base = (m_type == AnimationType::None) ? QColor(100, 100, 100) : QColor(241, 162, 24); - const int radius = 3; - - QPainter p(this); - QPalette pal = this->palette(); - - p.save(); - p.setBrush(QColor(30, 30, 30)); - p.drawRect(rect().adjusted(-1, -1, 2, 2)); - p.restore(); - - p.save(); - p.setPen(QColor(80, 80, 80)); - p.drawLine(rect().bottomLeft(), rect().bottomRight()); - p.restore(); - - QString fmtText = (animationTypeName(m_type) + " (%1s)").arg(m_duration, 2, 'f', 2); - setToolTip(fmtText); - - qUtilsDrawButton(&p, buttonRect(), base, false, fmtText, radius); -} - -QRect AnimationTimingEditor::buttonRect() const -{ - const float w = float(geometry().width()); - int xPos = int(w * m_delay / m_maxDuration); - int bw = int(w * m_duration / m_maxDuration); - return QRect(QPoint{xPos, 0}, QSize{bw, geometry().height()}); -} - -AnimationTimingEditor::DragZone AnimationTimingEditor::hitTest(const QPoint& pos) const -{ - QRect br = buttonRect(); - if (!br.contains(pos)) - return DragZone::None; - if (pos.x() <= br.left() + kHandleZoneWidth) - return DragZone::LeftHandle; - if (pos.x() >= br.right() - kHandleZoneWidth) - return DragZone::RightHandle; - return DragZone::Body; -} - -void AnimationTimingEditor::updateCursor(const QPoint& pos) -{ - if (m_type == AnimationType::None) { unsetCursor(); return; } - switch (hitTest(pos)) { - case DragZone::LeftHandle: - case DragZone::RightHandle: - setCursor(Qt::SizeHorCursor); - break; - case DragZone::Body: - setCursor(Qt::SizeAllCursor); - break; - default: - unsetCursor(); - break; - } -} - -void AnimationTimingEditor::mousePressEvent(QMouseEvent* event) -{ - if (event->button() != Qt::LeftButton || m_type == AnimationType::None) - return; - m_dragZone = hitTest(event->pos()); - if (m_dragZone == DragZone::None) - return; - m_dragStartX = event->pos().x(); - m_dragStartDelay = m_delay; - m_dragStartDuration = m_duration; - event->accept(); -} - -void AnimationTimingEditor::mouseMoveEvent(QMouseEvent* event) -{ - if (m_dragZone == DragZone::None) { - updateCursor(event->pos()); - return; - } - - const float w = float(width()); - if (w <= 0.0f) - return; - - const float deltaSec = float(event->pos().x() - m_dragStartX) / w * m_maxDuration; - const float minDuration = float(kHandleZoneWidth * 2) / w * m_maxDuration; - - auto snap = [](float v) { return std::round(v * 100.0f) / 100.0f; }; - - switch (m_dragZone) { - case DragZone::LeftHandle: { - // Right edge stays fixed; left edge moves. - const float maxDelay = m_dragStartDelay + m_dragStartDuration - minDuration; - m_delay = snap(qBound(0.0f, m_dragStartDelay + deltaSec, maxDelay)); - m_duration = (m_dragStartDelay + m_dragStartDuration) - m_delay; - break; - } - case DragZone::Body: { - // Whole button shifts; duration unchanged. - m_delay = snap(qBound(0.0f, m_dragStartDelay + deltaSec, m_maxDuration - m_dragStartDuration)); - break; - } - case DragZone::RightHandle: { - // Left edge stays fixed; right edge moves. - m_duration = snap(qBound(minDuration, m_dragStartDuration + deltaSec, m_maxDuration - m_dragStartDelay)); - break; - } - default: - break; - } - - update(); - emit animationChanged(m_type, m_easing, m_delay, m_duration); - event->accept(); -} - -void AnimationTimingEditor::contextMenuEvent(QContextMenuEvent* event) -{ - static constexpr AnimationType kAllTypes[] = { - AnimationType::None, - AnimationType::Fade, - AnimationType::SlideUp, AnimationType::SlideDown, - AnimationType::SlideLeft, AnimationType::SlideRight, - AnimationType::ScaleIn, - AnimationType::WipeUp, AnimationType::WipeDown, - AnimationType::WipeLeft, AnimationType::WipeRight, - }; - - static constexpr struct { Easing easing; const char* name; } kAllEasings[] = { - { Easing::Linear, "Linear" }, - { Easing::EaseIn, "Ease In" }, - { Easing::EaseOut, "Ease Out" }, - { Easing::EaseInOut, "Ease In/Out" }, - { Easing::EaseInCubic, "Ease In (Cubic)" }, - { Easing::EaseOutCubic, "Ease Out (Cubic)" }, - { Easing::EaseInOutCubic, "Ease In/Out (Cubic)" }, - { Easing::EaseInExpo, "Ease In (Expo)" }, - { Easing::EaseOutExpo, "Ease Out (Expo)" }, - { Easing::EaseInOutExpo, "Ease In/Out (Expo)" }, - { Easing::EaseInBack, "Ease In (Back)" }, - { Easing::EaseOutBack, "Ease Out (Back)" }, - { Easing::EaseInOutBack, "Ease In/Out (Back)" }, - { Easing::EaseInElastic, "Ease In (Elastic)" }, - { Easing::EaseOutElastic, "Ease Out (Elastic)" }, - { Easing::EaseInOutElastic,"Ease In/Out (Elastic)"}, - }; - - QMenu menu(this); - - QMenu* typeMenu = menu.addMenu("Set Animation Type"); - for (AnimationType t : kAllTypes) { - QAction* a = typeMenu->addAction(animationTypeName(t)); - a->setCheckable(true); - a->setChecked(t == m_type); - connect(a, &QAction::triggered, this, [this, t] { - m_type = t; - update(); - emit animationChanged(m_type, m_easing, m_delay, m_duration); - }); - } - - QMenu* easingMenu = menu.addMenu("Set Easing"); - for (const auto& e : kAllEasings) { - QAction* a = easingMenu->addAction(e.name); - a->setCheckable(true); - a->setChecked(e.easing == m_easing); - connect(a, &QAction::triggered, this, [this, e] { - m_easing = e.easing; - update(); - emit animationChanged(m_type, m_easing, m_delay, m_duration); - }); - } - - menu.exec(event->globalPos()); - event->accept(); -} - -void AnimationTimingEditor::mouseReleaseEvent(QMouseEvent* event) -{ - if (event->button() != Qt::LeftButton || m_dragZone == DragZone::None) - return; - m_dragZone = DragZone::None; - updateCursor(event->pos()); - event->accept(); -} diff --git a/ui/widgets/AnimationTimingEditor.h b/ui/widgets/AnimationTimingEditor.h deleted file mode 100644 index 031a015..0000000 --- a/ui/widgets/AnimationTimingEditor.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include "engine/animation.h" -#include - -class AnimationTimingEditor : public QWidget { - Q_OBJECT -public: - explicit AnimationTimingEditor(QWidget* parent = nullptr); - - void load(const AnimationDef& def); - void setMaxDuration(float maxDuration); - -signals: - void animationChanged(AnimationType type, Easing easing, float delay, float duration); - -protected: - void paintEvent(QPaintEvent*) override; - void mousePressEvent(QMouseEvent*) override; - void mouseMoveEvent(QMouseEvent*) override; - void mouseReleaseEvent(QMouseEvent*) override; - void contextMenuEvent(QContextMenuEvent*) override; - -private: - enum class DragZone { None, LeftHandle, Body, RightHandle }; - - float m_maxDuration{1.0f}; - AnimationType m_type{AnimationType::None}; - Easing m_easing{Easing::Linear}; - float m_duration{0.5f}; - float m_delay{0.0f}; - - DragZone m_dragZone{DragZone::None}; - int m_dragStartX{0}; - float m_dragStartDelay{0.0f}; - float m_dragStartDuration{0.0f}; - - QRect buttonRect() const; - DragZone hitTest(const QPoint& pos) const; - void updateCursor(const QPoint& pos); -}; diff --git a/ui/widgets/AnimationTimingPanel.cpp b/ui/widgets/AnimationTimingPanel.cpp new file mode 100644 index 0000000..0f973bf --- /dev/null +++ b/ui/widgets/AnimationTimingPanel.cpp @@ -0,0 +1,351 @@ +#include "ui/widgets/AnimationTimingPanel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "engine/title.h" +#include "engine/visual_element.h" +#include "icons.h" +#include "model/EditorTitle.h" +#include "model/TitleDocument.h" +#include "model/UndoCommands.h" +#include "ui/UiUtils.h" +#include "ui/widgets/ClipPropertiesPanel.h" + +namespace { +QFrame* makeSeparator(QWidget* parent) +{ + auto* sep = new QFrame(parent); + sep->setFrameShape(QFrame::VLine); + sep->setFrameShadow(QFrame::Plain); + sep->setFixedWidth(1); + return sep; +} +} // namespace + +AnimationTimingPanel::AnimationTimingPanel(TitleDocument* doc, EditorTitle* sel, QWidget* parent) + : QWidget(parent), m_doc(doc), m_sel(sel) +{ + m_timeline = new AnimationTimelineEditor(doc, this); + m_panel = new ClipPropertiesPanel(this); + + m_slotCombo = new QComboBox(this); + m_slotCombo->setObjectName("slotCombo"); + m_slotCombo->addItem(tr("In")); + m_slotCombo->addItem(tr("Out")); + m_slotCombo->addItem(tr("Data In")); + m_slotCombo->addItem(tr("Data Out")); + + m_playBtn = makeIconToolButton(themedIcon(Icons16::Media_Play), tr("Play")); + m_playBtn->setObjectName("playBtn"); + m_playBtn->setAccessibleName(tr("Play")); + m_stopBtn = makeIconToolButton(themedIcon(Icons16::Media_Stop), + tr("Stop — rewind to the start and return the canvas to the editable state")); + m_stopBtn->setObjectName("stopBtn"); + m_stopBtn->setAccessibleName(tr("Stop")); + m_resetBtn = makeIconToolButton(themedIcon(Icons16::Action_Reset), + tr("Reset to Visible — return the canvas to the editable state, keeping the playhead where it is")); + m_resetBtn->setObjectName("resetBtn"); + m_resetBtn->setAccessibleName(tr("Reset to Visible")); + + m_speedSpin = new QSpinBox(this); + m_speedSpin->setObjectName("speedSpin"); + m_speedSpin->setRange(10, 400); + m_speedSpin->setSuffix(tr(" %")); + m_speedSpin->setValue(100); + m_speedSpin->setFixedWidth(70); + m_speedSpin->setToolTip(tr("Playback speed")); + + m_zoomSpin = new QSpinBox(this); + m_zoomSpin->setObjectName("zoomSpin"); + m_zoomSpin->setRange(2, 1667); + m_zoomSpin->setSuffix(tr(" %")); + m_zoomSpin->setValue(100); + m_zoomSpin->setFixedWidth(80); + m_zoomSpin->setToolTip(tr("Zoom")); + + m_fitBtn = makeIconToolButton(themedIcon(Icons16::Action_ZoomFit), tr("Zoom to fit all clips")); + m_fitBtn->setObjectName("fitBtn"); + m_fitBtn->setAccessibleName(tr("Zoom to fit")); + + m_playTimer = new QTimer(this); + m_playTimer->setInterval(16); + + auto* topStrip = new QHBoxLayout(); + topStrip->setContentsMargins(8, 4, 8, 4); + topStrip->setSpacing(6); + topStrip->addWidget(makeRibbonLabel(tr("Show"))); + topStrip->addWidget(m_slotCombo); + topStrip->addWidget(makeSeparator(this)); + topStrip->addWidget(m_playBtn); + topStrip->addWidget(m_stopBtn); + topStrip->addWidget(m_resetBtn); + topStrip->addWidget(makeSeparator(this)); + topStrip->addWidget(makeRibbonLabel(tr("Speed"))); + topStrip->addWidget(m_speedSpin); + topStrip->addStretch(1); + topStrip->addWidget(makeRibbonLabel(tr("Zoom"))); + topStrip->addWidget(m_zoomSpin); + topStrip->addWidget(m_fitBtn); + + auto* row = new QHBoxLayout(); + row->setContentsMargins(0, 0, 0, 0); + row->addWidget(m_timeline, 1); + row->addWidget(m_panel, 0); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + root->addLayout(topStrip); + root->addLayout(row, 1); + + connect(m_slotCombo, &QComboBox::currentIndexChanged, this, + &AnimationTimingPanel::onSlotComboChanged); + + connect(m_timeline, &AnimationTimelineEditor::clipEdited, this, + [this](int elementIndex, AnimationTimelineEditor::Slot, AnimationDef def) { + pushAnimCmd(elementIndex, def); + }); + connect(m_timeline, &AnimationTimelineEditor::rowClicked, this, + &AnimationTimingPanel::onTimelineRowClicked); + connect(m_timeline, &AnimationTimelineEditor::activeClipChanged, this, + &AnimationTimingPanel::onTimelineActiveClipChanged); + + connect(m_panel, &ClipPropertiesPanel::clipChanged, this, + &AnimationTimingPanel::onPanelClipChanged); + + connect(m_sel, &EditorTitle::selectionChanged, this, &AnimationTimingPanel::onSelectionChanged); + connect(m_sel, &EditorTitle::selectionSetChanged, this, + &AnimationTimingPanel::onSelectionChanged); + + connect(m_doc, &TitleDocument::documentChanged, this, &AnimationTimingPanel::onDocumentChanged); + + connect(m_playBtn, &QToolButton::clicked, this, [this]() { + if (m_playing) { + pausePlayback(); + } else { + startPlayback(); + } + }); + connect(m_stopBtn, &QToolButton::clicked, this, &AnimationTimingPanel::stopPlayback); + connect(m_resetBtn, &QToolButton::clicked, this, &AnimationTimingPanel::resetToVisible); + connect(m_playTimer, &QTimer::timeout, this, &AnimationTimingPanel::onPlayTick); + + connect(m_zoomSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this](int value) { m_timeline->setZoomPercent(value); }); + connect(m_timeline, &AnimationTimelineEditor::zoomChanged, this, [this](int percent) { + const QSignalBlocker blocker(m_zoomSpin); + m_zoomSpin->setValue(percent); + }); + connect(m_fitBtn, &QToolButton::clicked, this, [this]() { m_timeline->zoomToFit(); }); + + connect(m_timeline, &AnimationTimelineEditor::playheadMoved, this, [this](double t) { + if (m_playing) pausePlayback(); + m_scrubTime = t; + emitScrub(t); + }); + + { + const QSignalBlocker blocker(m_zoomSpin); + m_zoomSpin->setValue(m_timeline->zoomPercent()); + } + + m_slotCombo->setCurrentIndex(0); + onSelectionChanged(); + updateTransportState(); +} + +void AnimationTimingPanel::onDocumentChanged() +{ + if (m_previewActive) { + if (m_playing) pausePlayback(); + emitPreviewStopped(); + } + + UpdateGuard guard(m_updating); + m_timeline->rebuild(); + if (m_activeElementIndex >= 1) { + m_timeline->focusElement(m_activeElementIndex); + } else { + m_panel->setClip(false, {}); + } +} + +void AnimationTimingPanel::onSelectionChanged() +{ + if (m_sel->selectionCount() > 1) { + if (m_playing || m_previewActive) stopPlayback(); + setEnabled(false); + return; + } + setEnabled(true); + + UpdateGuard guard(m_updating); + const SelectionId sid = m_sel->selection(); + if (sid.level == SelectionId::Level::Element && sid.elementIndex >= 1) { + m_activeElementIndex = sid.elementIndex; + m_timeline->focusElement(m_activeElementIndex); + } else { + m_activeElementIndex = -1; + m_timeline->clearActive(); + m_panel->setClip(false, {}); + } +} + +void AnimationTimingPanel::onSlotComboChanged(int index) +{ + if (m_playing) pausePlayback(); + emitPreviewStopped(); + + m_timeline->setSlot(static_cast(index)); + if (m_activeElementIndex >= 1) { + m_timeline->focusElement(m_activeElementIndex); + } else { + m_panel->setClip(false, {}); + } +} + +void AnimationTimingPanel::onTimelineRowClicked(int elementIndex) +{ + m_activeElementIndex = elementIndex; + UpdateGuard guard(m_updating); + SelectionId sid; + sid.level = SelectionId::Level::Element; + sid.elementIndex = elementIndex; + m_sel->setSelection(sid); +} + +void AnimationTimingPanel::onTimelineActiveClipChanged(bool valid, AnimationDef def) +{ + m_panel->setClip(valid, def); +} + +void AnimationTimingPanel::onPanelClipChanged(AnimationDef def) +{ + if (m_activeElementIndex >= 1) { + pushAnimCmd(m_activeElementIndex, def); + } +} + +void AnimationTimingPanel::pushAnimCmd(int elementIndex, AnimationDef def) +{ + const auto& elements = m_doc->title().elements; + if (elementIndex < 1 || static_cast(elementIndex) >= elements.size()) return; + + const auto* ve = dynamic_cast(elements[elementIndex].get()); + if (!ve) return; + std::string id = ve->GetId(); + + SetElementAnimCmd::Target target; + switch (m_timeline->slot()) { + case AnimationTimelineEditor::Slot::In: target = SetElementAnimCmd::Target::AnimIn; break; + case AnimationTimelineEditor::Slot::Out: target = SetElementAnimCmd::Target::AnimOut; break; + case AnimationTimelineEditor::Slot::DataIn: + target = SetElementAnimCmd::Target::DataAnimIn; + break; + case AnimationTimelineEditor::Slot::DataOut: + target = SetElementAnimCmd::Target::DataAnimOut; + break; + } + + m_doc->undoStack()->push(new SetElementAnimCmd(m_doc, id, target, def)); +} + +void AnimationTimingPanel::slotFlags(bool& isIn, bool& isData) const +{ + switch (m_timeline->slot()) { + case AnimationTimelineEditor::Slot::In: isIn = true; isData = false; break; + case AnimationTimelineEditor::Slot::Out: isIn = false; isData = false; break; + case AnimationTimelineEditor::Slot::DataIn: isIn = true; isData = true; break; + case AnimationTimelineEditor::Slot::DataOut: isIn = false; isData = true; break; + } +} + +void AnimationTimingPanel::startPlayback() +{ + const double total = m_timeline->contentDuration(); + if (total <= 0.0) return; + if (m_scrubTime >= total) { + m_scrubTime = 0.0; + } + m_timeline->setPlayhead(m_scrubTime); + m_playing = true; + m_playBtn->setIcon(themedIcon(Icons16::Media_Pause)); + m_playBtn->setToolTip(tr("Pause")); + m_playClock.start(); + m_playTimer->start(); + updateTransportState(); +} + +void AnimationTimingPanel::pausePlayback() +{ + m_playTimer->stop(); + m_playing = false; + m_playBtn->setIcon(themedIcon(Icons16::Media_Play)); + m_playBtn->setToolTip(tr("Play")); + updateTransportState(); +} + +void AnimationTimingPanel::stopPlayback() +{ + pausePlayback(); + m_scrubTime = 0.0; + m_timeline->setPlayhead(0.0); + emitPreviewStopped(); +} + +void AnimationTimingPanel::resetToVisible() +{ + pausePlayback(); + emitPreviewStopped(); +} + +void AnimationTimingPanel::updateTransportState() +{ + m_stopBtn->setEnabled(m_playing || m_previewActive); + m_resetBtn->setEnabled(m_previewActive); +} + +void AnimationTimingPanel::emitScrub(double t) +{ + bool isIn, isData; + slotFlags(isIn, isData); + m_previewActive = true; + emit scrubTimeChanged(isIn, isData, t); + updateTransportState(); +} + +void AnimationTimingPanel::emitPreviewStopped() +{ + if (!m_previewActive) return; + m_previewActive = false; + emit previewStopped(); + updateTransportState(); +} + +void AnimationTimingPanel::onPlayTick() +{ + const double speed = m_speedSpin->value() / 100.0; + m_scrubTime += (m_playClock.restart() / 1000.0) * speed; + const double total = m_timeline->contentDuration(); + if (total <= 0.0) { + stopPlayback(); + return; + } + if (m_scrubTime >= total) { + m_scrubTime = total; + m_timeline->setPlayhead(total); + emitScrub(total); + pausePlayback(); + return; + } + m_timeline->setPlayhead(m_scrubTime); + emitScrub(m_scrubTime); +} diff --git a/ui/widgets/AnimationTimingPanel.h b/ui/widgets/AnimationTimingPanel.h new file mode 100644 index 0000000..8836d4f --- /dev/null +++ b/ui/widgets/AnimationTimingPanel.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include "ui/widgets/AnimationTimelineEditor.h" + +class TitleDocument; +class EditorTitle; +class ClipPropertiesPanel; +class QComboBox; +class QSpinBox; +class QTimer; +class QToolButton; + +// Dock-level container composing AnimationTimelineEditor + ClipPropertiesPanel, +// wired to the document + selection model. Pushes SetElementAnimCmd on edits. +class AnimationTimingPanel : public QWidget { + Q_OBJECT +public: + explicit AnimationTimingPanel(TitleDocument* doc, EditorTitle* sel, QWidget* parent = nullptr); + +signals: + void scrubTimeChanged(bool isIn, bool isData, double t); + void previewStopped(); + +private slots: + void onDocumentChanged(); + void onSelectionChanged(); + void onSlotComboChanged(int index); + void onTimelineRowClicked(int elementIndex); + void onTimelineActiveClipChanged(bool valid, AnimationDef def); + void onPanelClipChanged(AnimationDef def); + void onPlayTick(); + +private: + void pushAnimCmd(int elementIndex, AnimationDef def); + void slotFlags(bool& isIn, bool& isData) const; + void startPlayback(); + void pausePlayback(); + void stopPlayback(); + void resetToVisible(); + void updateTransportState(); + void emitScrub(double t); + void emitPreviewStopped(); + + TitleDocument* m_doc; + EditorTitle* m_sel; + AnimationTimelineEditor* m_timeline; + ClipPropertiesPanel* m_panel; + QComboBox* m_slotCombo; + QToolButton* m_playBtn; + QToolButton* m_stopBtn; + QToolButton* m_resetBtn; + QSpinBox* m_speedSpin; + QSpinBox* m_zoomSpin; + QToolButton* m_fitBtn; + QTimer* m_playTimer; + QElapsedTimer m_playClock; + double m_scrubTime{0.0}; + bool m_playing{false}; + bool m_previewActive{false}; + int m_activeElementIndex{-1}; + bool m_updating{false}; +}; diff --git a/ui/widgets/CanvasWidget.cpp b/ui/widgets/CanvasWidget.cpp index 7de0ff4..545743c 100644 --- a/ui/widgets/CanvasWidget.cpp +++ b/ui/widgets/CanvasWidget.cpp @@ -15,10 +15,13 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include @@ -34,6 +37,9 @@ #include "model/TitleDocument.h" #include "model/UndoCommands.h" +#include "ui/UiUtils.h" + +#include "ResizeMath.h" #include "SelectionHandles.h" #include "icons.h" @@ -49,9 +55,39 @@ static Rectangle globalBounds(const VisualElement& el) return {pos.x, pos.y, b.width, b.height}; } +// Element's own rect in LOCAL coordinates: (0,0,width,height). +static QRectF localBoundsRect(const VisualElement& el) +{ + const Rectangle b = el.GetBounds(); + return QRectF(0.0, 0.0, b.width, b.height); +} + +// Maps element-LOCAL coords (0..w, 0..h) to WIDGET pixels. +// lbX/lbY/lbW/lbH describe the letterbox rect; titleW/titleH the title size. +// Mirrors the engine's Cairo render order exactly (see Transform::Apply in +// engine/types.hpp): translate(cx,cy) -> shear -> rotate -> translate(-cx,-cy), +// applied at the element's global position, itself placed within the +// letterboxed title viewport. +static QTransform makeElementTransform( + double gposX, double gposY, double w, double h, + double rotationDeg, double shearX, double shearY, + double lbX, double lbY, double lbW, double lbH, double titleW, double titleH) +{ + QTransform m; + m.translate(lbX, lbY); + m.scale(lbW / titleW, lbH / titleH); // letterbox scale + m.translate(gposX, gposY); // world position + m.translate(w / 2.0, h / 2.0); // to center + m.shear(shearX, shearY); // == cairo shearMatrix + m.rotate(rotationDeg); + m.translate(-w / 2.0, -h / 2.0); + return m; +} + static constexpr double kZoomMin = 0.05; static constexpr double kZoomMax = 30.0; static constexpr double kZoomStep = 1.12; +static constexpr double kFitMargin = 0.92; // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -68,6 +104,7 @@ CanvasWidget::CanvasWidget(TitleDocument* doc, EditorTitle* editorState, QWidget connect(m_doc, &TitleDocument::documentChanged, this, &CanvasWidget::onDocumentChanged); connect(m_editorState, &EditorTitle::selectionChanged, this, &CanvasWidget::onSelectionChanged); + connect(m_editorState, &EditorTitle::selectionSetChanged, this, [this] { update(); }); renderStaticTitle(); } @@ -134,6 +171,46 @@ QRectF CanvasWidget::titleToWidget(const Rectangle& r) const return {lb.left() + r.x * sx, lb.top() + r.y * sy, r.width * sx, r.height * sy}; } +QTransform CanvasWidget::elementToWidgetTransform(const VisualElement& el) const +{ + const Point pos = el.GetGlobalPosition(); + const Rectangle b = el.GetBounds(); + const QRectF lb = letterboxRect(); + return makeElementTransform(pos.x, pos.y, b.width, b.height, + el.GetRotation(), el.GetShearX(), el.GetShearY(), + lb.left(), lb.top(), lb.width(), lb.height(), + titleW(), titleH()); +} + +QTransform CanvasWidget::elementLinear(const VisualElement& el) const +{ + // Pure linear part (rotation+shear) matching the center-pivot transform's + // linear component, in title/world space. No translation. + QTransform r; + r.shear(el.GetShearX(), el.GetShearY()); + r.rotate(el.GetRotation()); + return r; +} + +QTransform CanvasWidget::elementLocalToTitle(const VisualElement& el) const +{ + const Point pos = el.GetGlobalPosition(); + const Rectangle b = el.GetBounds(); + return makeElementTransform(pos.x, pos.y, b.width, b.height, + el.GetRotation(), el.GetShearX(), el.GetShearY(), + 0.0, 0.0, titleW(), titleH(), titleW(), titleH()); +} + +QRectF CanvasWidget::elementTitleAABB(const VisualElement& el) const +{ + const QTransform xf = elementLocalToTitle(el); + const QRectF lr = localBoundsRect(el); + QPolygonF poly; + poly << xf.map(lr.topLeft()) << xf.map(lr.topRight()) + << xf.map(lr.bottomRight()) << xf.map(lr.bottomLeft()); + return poly.boundingRect(); +} + // ── Zoom helpers ────────────────────────────────────────────────────────────── void CanvasWidget::zoomToward(QPointF cursor, double factor) @@ -155,7 +232,7 @@ void CanvasWidget::zoomToward(QPointF cursor, double factor) void CanvasWidget::zoomIn() { zoomToward(rect().center(), 1.25); } void CanvasWidget::zoomOut() { zoomToward(rect().center(), 1.0 / 1.25); } -void CanvasWidget::fitToWindow() { m_zoom = 1.0; m_panOffset = {}; update(); } +void CanvasWidget::fitToWindow() { m_zoom = kFitMargin; m_panOffset = {}; update(); } // ── Paint (copy-style) mode ─────────────────────────────────────────────────── @@ -267,6 +344,15 @@ static double snapEdge(double edge, const QList& cands, double threshold // ── Hit testing ─────────────────────────────────────────────────────────────── +bool CanvasWidget::isActiveSelectionLocked() const +{ + const SelectionId sel = m_editorState->selection(); + if (sel.level != SelectionId::Level::Element) return false; + const Title& t = m_doc->title(); + if (sel.elementIndex < 1 || sel.elementIndex >= (int)t.elements.size()) return false; + return m_doc->isElementLocked(t.elements[sel.elementIndex]->GetId()); +} + SelectionId CanvasWidget::hitTest(QPointF titlePt) const { const Title& t = m_doc->title(); @@ -284,15 +370,46 @@ SelectionId CanvasWidget::hitTest(QPointF titlePt) const for (int ei : order) { const auto* ve = dynamic_cast(t.elements[ei].get()); if (!ve) continue; - const Rectangle gb = globalBounds(*ve); - if (titlePt.x() >= gb.x && titlePt.x() <= gb.x + gb.width && - titlePt.y() >= gb.y && titlePt.y() <= gb.y + gb.height) { + if (m_doc->isElementLocked(ve->GetId())) continue; // locked: not click-selectable on canvas + bool ok = false; + const QTransform inv = elementLocalToTitle(*ve).inverted(&ok); + if (!ok) continue; // degenerate transform (e.g. shear det 0): skip + const QPointF lp = inv.map(titlePt); + const Rectangle b = ve->GetBounds(); + if (lp.x() >= 0.0 && lp.x() <= b.width && lp.y() >= 0.0 && lp.y() <= b.height) return {SelectionId::Level::Element, ei}; - } } return {}; } +std::vector CanvasWidget::elementsInMarquee(const QRectF& widgetRect) const +{ + std::vector result; + const Title& t = m_doc->title(); + + QPainterPath rectPath; + rectPath.addRect(widgetRect); + + for (int i = 1; i < (int)t.elements.size(); ++i) { + const auto* ve = dynamic_cast(t.elements[i].get()); + if (!ve) continue; + if (m_doc->isElementLocked(ve->GetId())) continue; // locked: excluded from marquee selection + const QTransform xf = elementToWidgetTransform(*ve); + const QRectF lr = localBoundsRect(*ve); + QPolygonF poly; + poly << xf.map(lr.topLeft()) << xf.map(lr.topRight()) + << xf.map(lr.bottomRight()) << xf.map(lr.bottomLeft()) + << xf.map(lr.topLeft()); // close + + QPainterPath polyPath; + polyPath.addPolygon(poly); + + if (rectPath.intersects(polyPath)) + result.push_back(i); + } + return result; +} + int CanvasWidget::hitHandle(QPointF widgetPt) const { const SelectionId sel = m_editorState->selection(); @@ -303,7 +420,7 @@ int CanvasWidget::hitHandle(QPointF widgetPt) const return -1; const auto* ve = dynamic_cast(t.elements[sel.elementIndex].get()); if (!ve) return -1; - return SelectionHandles::hitTest(widgetPt, titleToWidget(globalBounds(*ve))); + return SelectionHandles::hitTest(widgetPt, elementToWidgetTransform(*ve), localBoundsRect(*ve)); } // ── Rendering ───────────────────────────────────────────────────────────────── @@ -424,21 +541,33 @@ void CanvasWidget::drawSnapLines(QPainter& p, const QRectF& lb) void CanvasWidget::drawElementOutlines(QPainter& p) { - const SelectionId sel = m_editorState->selection(); const Title& t = m_doc->title(); + const bool singleSelect = m_editorState->selectionCount() == 1; p.save(); - p.setRenderHint(QPainter::Antialiasing, false); + p.setRenderHint(QPainter::Antialiasing, true); p.setBrush(Qt::NoBrush); for (int i = 1; i < (int)t.elements.size(); ++i) { - if (i == sel.elementIndex) continue; + const bool selected = m_editorState->isSelected(i); + if (selected && singleSelect) continue; // active single selection gets the handle box instead const auto* ve = dynamic_cast(t.elements[i].get()); if (!ve) continue; - const QRectF r = titleToWidget(globalBounds(*ve)); - p.setPen(QPen(QColor(0, 0, 0, 84), 3.0, Qt::DashLine)); - p.drawRect(r); - p.setPen(QPen(QColor(255, 255, 255, 178), 1.0, Qt::DashLine)); - p.drawRect(r); + const QTransform xf = elementToWidgetTransform(*ve); + const QRectF lr = localBoundsRect(*ve); + QPolygonF poly; + poly << xf.map(lr.topLeft()) << xf.map(lr.topRight()) + << xf.map(lr.bottomRight()) << xf.map(lr.bottomLeft()); + if (selected) { + p.setPen(QPen(QColor(0, 0, 0, 100), 2.5)); + p.drawPolygon(poly); + p.setPen(QPen(palette().highlight().color(), 1.5)); + p.drawPolygon(poly); + } else { + p.setPen(QPen(QColor(0, 0, 0, 84), 3.0, Qt::DashLine)); + p.drawPolygon(poly); + p.setPen(QPen(QColor(255, 255, 255, 178), 1.0, Qt::DashLine)); + p.drawPolygon(poly); + } } p.restore(); } @@ -471,19 +600,31 @@ void CanvasWidget::paintEvent(QPaintEvent*) if (!m_previewTitle) drawElementOutlines(p); - if (!m_previewTitle) { + if (!m_previewTitle && m_editorState->selectionCount() <= 1) { const SelectionId sel = m_editorState->selection(); if (sel.level == SelectionId::Level::Element) { const Title& t = m_doc->title(); if (sel.elementIndex >= 1 && sel.elementIndex < (int)t.elements.size()) { const auto* ve = dynamic_cast(t.elements[sel.elementIndex].get()); if (ve) - SelectionHandles::draw(p, titleToWidget(globalBounds(*ve)), + SelectionHandles::draw(p, elementToWidgetTransform(*ve), localBoundsRect(*ve), m_dragging ? m_dragHandle : -1, palette().highlight().color()); } } } + + if (m_marqueeActive) { + const QRectF mr = QRectF(m_marqueeStartWidget, m_marqueeCurWidget).normalized(); + p.save(); + QColor fill = palette().highlight().color(); + fill.setAlpha(40); + p.setBrush(fill); + QPen pen(palette().highlight().color(), 1.0, Qt::DashLine); + p.setPen(pen); + p.drawRect(mr); + p.restore(); + } } void CanvasWidget::resizeEvent(QResizeEvent*) { update(); } @@ -608,7 +749,7 @@ void CanvasWidget::previewAtTime(bool isIn, bool isData, double t) void CanvasWidget::keyReleaseEvent(QKeyEvent* event) { - if (m_dragging && m_dragMode == DragMode::Resize) { + if (m_dragging && (m_dragMode == DragMode::Resize || m_dragMode == DragMode::Rotate)) { Qt::KeyboardModifiers mods = event->modifiers(); switch (event->key()) { case Qt::Key_Shift: mods &= ~Qt::ShiftModifier; break; @@ -616,7 +757,8 @@ void CanvasWidget::keyReleaseEvent(QKeyEvent* event) case Qt::Key_Alt: mods &= ~Qt::AltModifier; break; default: break; } - applyResizeDrag(m_lastDragWidgetPos, mods); + if (m_dragMode == DragMode::Resize) applyResizeDrag(m_lastDragWidgetPos, mods); + else applyRotateDrag(m_lastDragWidgetPos, mods); event->accept(); return; } @@ -625,8 +767,9 @@ void CanvasWidget::keyReleaseEvent(QKeyEvent* event) void CanvasWidget::keyPressEvent(QKeyEvent* event) { - if (m_dragging && m_dragMode == DragMode::Resize) { - applyResizeDrag(m_lastDragWidgetPos, event->modifiers()); + if (m_dragging && (m_dragMode == DragMode::Resize || m_dragMode == DragMode::Rotate)) { + if (m_dragMode == DragMode::Resize) applyResizeDrag(m_lastDragWidgetPos, event->modifiers()); + else applyRotateDrag(m_lastDragWidgetPos, event->modifiers()); event->accept(); return; } @@ -649,17 +792,46 @@ void CanvasWidget::keyPressEvent(QKeyEvent* event) return; } - const int ei = sel.elementIndex; const Title& t = m_doc->title(); - if (ei < 1 || ei >= (int)t.elements.size()) return; - const auto* ve = dynamic_cast(t.elements[ei].get()); - if (!ve) return; + if (m_editorState->selectionCount() <= 1) { + const int ei = sel.elementIndex; + if (ei < 1 || ei >= (int)t.elements.size()) return; - Rectangle b = ve->GetBounds(); - b.x += dx; - b.y += dy; - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, ve->GetId(), b, kArrowMergeTag)); + const auto* ve = dynamic_cast(t.elements[ei].get()); + if (!ve) return; + if (m_doc->isElementLocked(ve->GetId())) { event->accept(); return; } // locked: not nudgeable + + Rectangle b = ve->GetBounds(); + b.x += dx; + b.y += dy; + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, ve->GetId(), b, kArrowMergeTag)); + event->accept(); + return; + } + + // Multi-selection nudge: one undo macro per key press (cross-press + // coalescing via kArrowMergeTag does not apply to macros). Locked members + // stay put; only unlocked members move. + std::vector movable; + for (int gi : m_editorState->selectedIndices()) { + if (gi < 1 || gi >= (int)t.elements.size()) continue; + const auto* gve = dynamic_cast(t.elements[gi].get()); + if (!gve) continue; + if (m_doc->isElementLocked(gve->GetId())) continue; + movable.push_back(gi); + } + if (movable.empty()) { event->accept(); return; } + + m_doc->undoStack()->beginMacro("Move elements"); + for (int gi : movable) { + const auto* gve = static_cast(t.elements[gi].get()); + Rectangle b = gve->GetBounds(); + b.x += dx; + b.y += dy; + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, gve->GetId(), b, kArrowMergeTag)); + } + m_doc->undoStack()->endMacro(); event->accept(); } @@ -709,8 +881,37 @@ void CanvasWidget::mousePressEvent(QMouseEvent* event) return; } - // 1. Resize handle hit? - const int handle = hitHandle(wpos); + // 1. Rotate/resize handle hit? Handles only exist for a single selection + // (2+ selected is move-only), so don't grab an invisible handle otherwise. + // A locked active element (e.g. selected via the tree) can't be dragged. + const int handle = (m_editorState->selectionCount() <= 1 && !isActiveSelectionLocked()) + ? hitHandle(wpos) : -1; + if (handle == SelectionHandles::kRotateHandle) { + const SelectionId sel = m_editorState->selection(); + const Title& t = m_doc->title(); + const auto* ve = (sel.level == SelectionId::Level::Element && + sel.elementIndex >= 1 && sel.elementIndex < (int)t.elements.size()) + ? dynamic_cast(t.elements[sel.elementIndex].get()) + : nullptr; + if (ve) { + m_dragMode = DragMode::Rotate; + m_dragHandle = handle; + m_dragEi = sel.elementIndex; + m_dragOrigRotation = ve->GetRotation(); + const QTransform x0 = elementLocalToTitle(*ve); + const Rectangle b = ve->GetBounds(); + m_dragOrigCenterTitle = x0.map(QPointF(b.width / 2.0, b.height / 2.0)); + const QPointF sp = widgetToTitle(wpos); + m_dragStartAngle = std::atan2(sp.y() - m_dragOrigCenterTitle.y(), + sp.x() - m_dragOrigCenterTitle.x()); + m_lastDragWidgetPos = wpos; + m_dragging = true; + emit interactiveEditStarted(); + } + return; + } + + // 2. Resize handle hit? if (handle >= 0) { const SelectionId sel = m_editorState->selection(); const Title& t = m_doc->title(); @@ -722,23 +923,54 @@ void CanvasWidget::mousePressEvent(QMouseEvent* event) m_dragOrigBounds = ve ? ve->GetBounds() : Rectangle{}; m_dragEi = sel.elementIndex; m_dragging = true; + + if (ve) { + const QTransform x0 = elementLocalToTitle(*ve); + const double ow0 = m_dragOrigBounds.width, oh0 = m_dragOrigBounds.height; + double ax0, ay0; + resizemath::anchorNorm(handle, ax0, ay0); + m_dragAnchorTitle = x0.map(QPointF(ax0 * ow0, ay0 * oh0)); + m_dragOrigCenterTitle = x0.map(QPointF(ow0 / 2.0, oh0 / 2.0)); + const Point gpos = ve->GetGlobalPosition(); + m_dragParentOffset = QPointF(gpos.x - m_dragOrigBounds.x, gpos.y - m_dragOrigBounds.y); + } + + emit interactiveEditStarted(); return; } const SelectionId curSel = m_editorState->selection(); const QPointF sp = widgetToTitle(wpos); + const bool additive = (event->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier)); - // 2. Always hit-test (top-most element under cursor wins), then decide + // 3. Always hit-test (top-most element under cursor wins), then decide // whether to keep dragging the current selection or select the new hit. const SelectionId hit = hitTest(sp); if (hit.level != SelectionId::Level::Element) { - m_editorState->setSelection(hit); + // Empty canvas: begin a rubber-band marquee. Selection is only + // cleared on release if the marquee turns out to be a plain click + // (see mouseReleaseEvent). + m_marqueeActive = true; + m_marqueeStartWidget = wpos; + m_marqueeCurWidget = wpos; + m_marqueeAdditive = additive; + update(); + return; + } + + if (additive) { + m_editorState->toggleSelection(hit.elementIndex); return; } - if (!(hit == curSel)) + if (m_editorState->isSelected(hit.elementIndex) && m_editorState->selectionCount() > 1) { + // Element is already part of a multi-selection: keep the set, just + // make it the active/anchor element (does not change membership). + m_editorState->addToSelection(hit.elementIndex); + } else if (!(hit == curSel)) { m_editorState->setSelection(hit); + } const Title& t = m_doc->title(); const auto* ve = dynamic_cast(t.elements[hit.elementIndex].get()); @@ -750,63 +982,55 @@ void CanvasWidget::mousePressEvent(QMouseEvent* event) m_dragOrigBounds = ve->GetBounds(); m_dragEi = hit.elementIndex; m_dragging = true; + + m_dragGroup.clear(); + if (m_editorState->selectionCount() > 1 && m_editorState->isSelected(hit.elementIndex)) { + for (int gi : m_editorState->selectedIndices()) { + if (gi < 1 || gi >= (int)t.elements.size()) continue; + const auto* gve = dynamic_cast(t.elements[gi].get()); + if (gve && !m_doc->isElementLocked(gve->GetId())) // locked members stay put + m_dragGroup.emplace_back(gi, gve->GetBounds()); + } + } + + emit interactiveEditStarted(); } } void CanvasWidget::applyResizeDrag(QPointF widgetPos, Qt::KeyboardModifiers mods) { + const Title& t = m_doc->title(); + if (m_dragEi < 1 || m_dragEi >= (int)t.elements.size()) return; + const auto* ve = dynamic_cast(t.elements[m_dragEi].get()); + if (!ve) return; + const QPointF titlePt = widgetToTitle(widgetPos); - const double dx = titlePt.x() - m_dragStartTitle.x(); - const double dy = titlePt.y() - m_dragStartTitle.y(); - const double ox = m_dragOrigBounds.x, oy = m_dragOrigBounds.y; - const double ow = m_dragOrigBounds.width, oh = m_dragOrigBounds.height; - Rectangle newBounds = m_dragOrigBounds; + const QPointF deltaWorld = titlePt - m_dragStartTitle; - switch (m_dragHandle) { - case 0: newBounds.x = ox+dx; newBounds.y = oy+dy; newBounds.width = std::max(1.0,ow-dx); newBounds.height = std::max(1.0,oh-dy); break; - case 1: newBounds.y = oy+dy; newBounds.height = std::max(1.0,oh-dy); break; - case 2: newBounds.y = oy+dy; newBounds.width = std::max(1.0,ow+dx); newBounds.height = std::max(1.0,oh-dy); break; - case 3: newBounds.x = ox+dx; newBounds.width = std::max(1.0,ow-dx); break; - case 4: newBounds.width = std::max(1.0,ow+dx); break; - case 5: newBounds.x = ox+dx; newBounds.width = std::max(1.0,ow-dx); newBounds.height = std::max(1.0,oh+dy); break; - case 6: newBounds.height = std::max(1.0,oh+dy); break; - case 7: newBounds.width = std::max(1.0,ow+dx); newBounds.height = std::max(1.0,oh+dy); break; - default: break; - } - - if ((mods & Qt::ShiftModifier) && oh > 0 && - (m_dragHandle == 0 || m_dragHandle == 2 || m_dragHandle == 5 || m_dragHandle == 7)) { - const double scale = std::min(newBounds.width / ow, newBounds.height / oh); - newBounds.width = std::max(1.0, ow * scale); - newBounds.height = std::max(1.0, oh * scale); - switch (m_dragHandle) { - case 0: newBounds.x = ox+ow-newBounds.width; newBounds.y = oy+oh-newBounds.height; break; - case 2: newBounds.x = ox; newBounds.y = oy+oh-newBounds.height; break; - case 5: newBounds.x = ox+ow-newBounds.width; newBounds.y = oy; break; - case 7: newBounds.x = ox; newBounds.y = oy; break; - default: break; - } - } + const QTransform R = elementLinear(*ve); + bool ok = false; + const QTransform Rinv = R.inverted(&ok); + const QPointF dl = ok ? (Rinv.map(deltaWorld) - Rinv.map(QPointF(0, 0))) : deltaWorld; - if (mods & Qt::ControlModifier) { - const double cx = ox + ow / 2.0, cy = oy + oh / 2.0; - newBounds.width = std::max(1.0, 2.0*newBounds.width - ow); - newBounds.height = std::max(1.0, 2.0*newBounds.height - oh); - newBounds.x = cx - newBounds.width / 2.0; - newBounds.y = cy - newBounds.height / 2.0; - } + Rectangle newBounds = resizemath::resizeSolve( + m_dragHandle, m_dragOrigBounds, R, dl.x(), dl.y(), + m_dragAnchorTitle, m_dragOrigCenterTitle, m_dragParentOffset, + (mods & Qt::ShiftModifier) != 0, (mods & Qt::ControlModifier) != 0); + + const bool identity = ve->GetRotation() == 0.0f && ve->GetShearX() == 0.0f && ve->GetShearY() == 0.0f; + if (identity && m_snappingEnabled && !(mods & Qt::ControlModifier)) { + const double ox = m_dragOrigBounds.x, oy = m_dragOrigBounds.y; + const double ow = m_dragOrigBounds.width, oh = m_dragOrigBounds.height; - if (m_snappingEnabled) { const double threshold = 8.0 * titleW() / letterboxRect().width(); QList candX = {0.0, titleW()/2.0, double(titleW())}; QList candY = {0.0, titleH()/2.0, double(titleH())}; appendGuideCandidates(candX, candY); - const Title& t = m_doc->title(); for (int i = 1; i < (int)t.elements.size(); ++i) { if (i == m_dragEi) continue; - const auto* ve = dynamic_cast(t.elements[i].get()); - if (!ve) continue; - const Rectangle& ob = ve->GetBounds(); + const auto* ove = dynamic_cast(t.elements[i].get()); + if (!ove) continue; + const Rectangle& ob = ove->GetBounds(); candX << ob.x << ob.x+ob.width/2.0 << ob.x+ob.width; candY << ob.y << ob.y+ob.height/2.0 << ob.y+ob.height; } @@ -833,14 +1057,34 @@ void CanvasWidget::applyResizeDrag(QPointF widgetPos, Qt::KeyboardModifiers mods } const int ei = m_dragEi; - m_doc->applyMutation([ei, newBounds](Title& t) { - if (ei >= 1 && ei < (int)t.elements.size()) { - auto* sp = dynamic_cast(t.elements[ei].get()); + m_doc->applyMutation([ei, newBounds](Title& tt) { + if (ei >= 1 && ei < (int)tt.elements.size()) { + auto* sp = dynamic_cast(tt.elements[ei].get()); if (sp) sp->SetBounds(newBounds); } }); } +void CanvasWidget::applyRotateDrag(QPointF widgetPos, Qt::KeyboardModifiers mods) +{ + const Title& t = m_doc->title(); + if (m_dragEi < 1 || m_dragEi >= (int)t.elements.size()) return; + const auto* ve = dynamic_cast(t.elements[m_dragEi].get()); + if (!ve) return; + const QPointF sp = widgetToTitle(widgetPos); + const double curAngle = std::atan2(sp.y() - m_dragOrigCenterTitle.y(), + sp.x() - m_dragOrigCenterTitle.x()); + const double newRot = resizemath::rotateSolve(m_dragOrigRotation, m_dragStartAngle, + curAngle, (mods & Qt::ShiftModifier) != 0); + const int ei = m_dragEi; + m_doc->applyMutation([ei, newRot](Title& tt) { + if (ei >= 1 && ei < (int)tt.elements.size()) { + auto* sp2 = dynamic_cast(tt.elements[ei].get()); + if (sp2) sp2->SetRotation(static_cast(newRot)); + } + }); +} + void CanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (m_panning) { @@ -849,6 +1093,13 @@ void CanvasWidget::mouseMoveEvent(QMouseEvent* event) return; } + if (m_marqueeActive) { + m_marqueeCurWidget = event->position(); + update(); + event->accept(); + return; + } + if (!m_dragging || m_previewTitle) { if (!m_previewTitle) updateCursorForPos(event->position()); @@ -865,6 +1116,11 @@ void CanvasWidget::mouseMoveEvent(QMouseEvent* event) return; } + if (m_dragMode == DragMode::Rotate) { + applyRotateDrag(wpos, event->modifiers()); + return; + } + // Move drag Rectangle newBounds = m_dragOrigBounds; newBounds.x = m_dragOrigBounds.x + (titlePt.x() - m_dragStartTitle.x()); @@ -877,13 +1133,34 @@ void CanvasWidget::mouseMoveEvent(QMouseEvent* event) m_snapLinesY.clear(); } - const int ei = m_dragEi; - m_doc->applyMutation([ei, newBounds](Title& t) { - if (ei >= 1 && ei < (int)t.elements.size()) { - auto* sp = dynamic_cast(t.elements[ei].get()); - if (sp) sp->SetBounds(newBounds); - } - }); + if (m_dragGroup.empty()) { + const int ei = m_dragEi; + m_doc->applyMutation([ei, newBounds](Title& t) { + if (ei >= 1 && ei < (int)t.elements.size()) { + auto* sp = dynamic_cast(t.elements[ei].get()); + if (sp) sp->SetBounds(newBounds); + } + }); + } else { + // Group move: apply the same (snapped) delta — derived from the + // active element's actual result — to every member, relative to its + // own captured orig bounds. The group snaps as a unit relative to + // the active element. + const double dxA = newBounds.x - m_dragOrigBounds.x; + const double dyA = newBounds.y - m_dragOrigBounds.y; + auto group = m_dragGroup; // capture by value + m_doc->applyMutation([group, dxA, dyA](Title& t) { + for (const auto& [gi, orig] : group) { + if (gi < 1 || gi >= (int)t.elements.size()) continue; + auto* sp = dynamic_cast(t.elements[gi].get()); + if (!sp) continue; + Rectangle nb = orig; + nb.x = orig.x + dxA; + nb.y = orig.y + dyA; + sp->SetBounds(nb); + } + }); + } } void CanvasWidget::mouseReleaseEvent(QMouseEvent* event) @@ -895,47 +1172,139 @@ void CanvasWidget::mouseReleaseEvent(QMouseEvent* event) return; } + if (m_marqueeActive) { + m_marqueeActive = false; + const QPointF wpos = event->position(); + const QRectF r = QRectF(m_marqueeStartWidget, wpos).normalized(); + const double kClickPx = 3.0; + if (r.width() < kClickPx && r.height() < kClickPx) { + // Treated as a plain click on empty canvas. + if (!m_marqueeAdditive) m_editorState->setSelection(SelectionId{}); + } else { + std::vector inside = elementsInMarquee(r); + if (m_marqueeAdditive) { + // Union with the current selection, preserving current members first. + std::vector cur = m_editorState->selectedIndices(); + for (int i : inside) + if (std::find(cur.begin(), cur.end(), i) == cur.end()) + cur.push_back(i); + inside.swap(cur); + } + if (inside.empty()) { + if (!m_marqueeAdditive) m_editorState->setSelection(SelectionId{}); + } else { + m_editorState->setMultiSelection(inside, inside.back()); // active = a member (contract-safe) + } + } + update(); + event->accept(); + return; + } + if (event->button() != Qt::LeftButton || !m_dragging || m_previewTitle) { QWidget::mouseReleaseEvent(event); return; } m_dragging = false; + emit interactiveEditFinished(); m_snapLinesX.clear(); m_snapLinesY.clear(); const Title& t = m_doc->title(); if (m_dragEi < 1 || m_dragEi >= (int)t.elements.size()) { m_dragMode = DragMode::None; + m_dragGroup.clear(); return; } const auto* ve = dynamic_cast(t.elements[m_dragEi].get()); if (!ve) { m_dragMode = DragMode::None; + m_dragGroup.clear(); return; } - const Rectangle finalBounds = ve->GetBounds(); - const std::string eid = ve->GetId(); + if (m_dragMode == DragMode::Rotate) { + const float finalRot = ve->GetRotation(); + const std::string eid = ve->GetId(); + if (finalRot != m_dragOrigRotation) { + // revert to orig so SetElementRotationCmd captures the correct before-value + const int ei = m_dragEi; + const float orig = m_dragOrigRotation; + m_doc->applyMutation([ei, orig](Title& tt) { + if (ei >= 1 && ei < (int)tt.elements.size()) { + auto* sp2 = dynamic_cast(tt.elements[ei].get()); + if (sp2) sp2->SetRotation(orig); + } + }); + m_doc->undoStack()->push(new SetElementRotationCmd(m_doc, eid, finalRot, -1)); + } + m_dragMode = DragMode::None; + m_dragHandle = -1; + m_dragEi = -1; + m_dragGroup.clear(); + updateCursorForPos(event->position()); + return; + } - if (finalBounds.x != m_dragOrigBounds.x || finalBounds.y != m_dragOrigBounds.y || - finalBounds.width != m_dragOrigBounds.width || finalBounds.height != m_dragOrigBounds.height) { - // Revert to original so SetElementBoundsCmd captures the correct before-value - const int ei = m_dragEi; - const Rectangle orig = m_dragOrigBounds; - m_doc->applyMutation([ei, orig](Title& t) { - if (ei >= 1 && ei < (int)t.elements.size()) { - auto* sp = dynamic_cast(t.elements[ei].get()); - if (sp) sp->SetBounds(orig); + if (m_dragGroup.empty()) { + const Rectangle finalBounds = ve->GetBounds(); + const std::string eid = ve->GetId(); + + if (finalBounds.x != m_dragOrigBounds.x || finalBounds.y != m_dragOrigBounds.y || + finalBounds.width != m_dragOrigBounds.width || finalBounds.height != m_dragOrigBounds.height) { + // Revert to original so SetElementBoundsCmd captures the correct before-value + const int ei = m_dragEi; + const Rectangle orig = m_dragOrigBounds; + m_doc->applyMutation([ei, orig](Title& t) { + if (ei >= 1 && ei < (int)t.elements.size()) { + auto* sp = dynamic_cast(t.elements[ei].get()); + if (sp) sp->SetBounds(orig); + } + }); + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, eid, finalBounds)); + } + } else { + // Group move commit: gather (id, origBounds, finalBounds) for every + // member whose bounds actually changed, then revert+push each under + // a single undo macro so the whole group move is ONE undo step. + struct Member { std::string id; Rectangle orig; Rectangle final_; }; + std::vector changed; + for (const auto& [gi, orig] : m_dragGroup) { + if (gi < 1 || gi >= (int)t.elements.size()) continue; + const auto* gve = dynamic_cast(t.elements[gi].get()); + if (!gve) continue; + const Rectangle final_ = gve->GetBounds(); + if (final_.x != orig.x || final_.y != orig.y || + final_.width != orig.width || final_.height != orig.height) { + changed.push_back({gve->GetId(), orig, final_}); } - }); - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, eid, finalBounds)); + } + + if (!changed.empty()) { + m_doc->undoStack()->beginMacro("Move elements"); + for (const auto& m : changed) { + const std::string id = m.id; + const Rectangle orig = m.orig; + m_doc->applyMutation([id, orig](Title& tt) { + for (int i = 1; i < (int)tt.elements.size(); ++i) { + if (tt.elements[i]->GetId() != id) continue; + auto* sp = dynamic_cast(tt.elements[i].get()); + if (sp) sp->SetBounds(orig); + break; + } + }); + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m.id, m.final_)); + } + m_doc->undoStack()->endMacro(); + } } m_dragMode = DragMode::None; m_dragHandle = -1; m_dragEi = -1; + m_dragGroup.clear(); updateCursorForPos(event->position()); } @@ -992,37 +1361,40 @@ void CanvasWidget::dropEvent(QDropEvent* event) m_doc->undoStack()->beginMacro("Drop Images"); std::string firstNewId; - for (int idx = 0; idx < imagePaths.size(); ++idx) { - const QString& imagePath = imagePaths[idx]; - QImage img(imagePath); - - // Find unique element ID - int maxN = 0; - const Title& t = m_doc->title(); - for (int i = 1; i < (int)t.elements.size(); ++i) { - const std::string& eid = t.elements[i]->GetId(); - if (eid.rfind("image_", 0) == 0) - try { maxN = std::max(maxN, std::stoi(eid.substr(6))); } catch (...) {} + { + WaitCursor wc; + for (int idx = 0; idx < imagePaths.size(); ++idx) { + const QString& imagePath = imagePaths[idx]; + QImage img(imagePath); + + // Find unique element ID + int maxN = 0; + const Title& t = m_doc->title(); + for (int i = 1; i < (int)t.elements.size(); ++i) { + const std::string& eid = t.elements[i]->GetId(); + if (eid.rfind("image_", 0) == 0) + try { maxN = std::max(maxN, std::stoi(eid.substr(6))); } catch (...) {} + } + const std::string newId = "image_" + std::to_string(maxN + 1); + if (idx == 0) firstNewId = newId; + + double w = img.isNull() ? 200.0 : img.width(); + double h = img.isNull() ? 200.0 : img.height(); + if (w > titleW()) { h = h * titleW() / w; w = titleW(); } + if (h > titleH()) { w = w * titleH() / h; h = titleH(); } + + const double x = titlePt.x() - w / 2.0 + idx * 20.0; + const double y = titlePt.y() - h / 2.0 + idx * 20.0; + const int zOrder = static_cast(m_doc->title().elements.size()) - 1; + + using json = nlohmann::json; + json j = {{"id", newId}, {"type", "image"}, + {"x", x}, {"y", y}, {"w", w}, {"h", h}, + {"z_order", zOrder}, + {"image_path", imagePath.toStdString()}, + {"scale_mode", "contain"}}; + m_doc->undoStack()->push(new AddElementCmd(m_doc, std::move(j))); } - const std::string newId = "image_" + std::to_string(maxN + 1); - if (idx == 0) firstNewId = newId; - - double w = img.isNull() ? 200.0 : img.width(); - double h = img.isNull() ? 200.0 : img.height(); - if (w > titleW()) { h = h * titleW() / w; w = titleW(); } - if (h > titleH()) { w = w * titleH() / h; h = titleH(); } - - const double x = titlePt.x() - w / 2.0 + idx * 20.0; - const double y = titlePt.y() - h / 2.0 + idx * 20.0; - const int zOrder = static_cast(m_doc->title().elements.size()) - 1; - - using json = nlohmann::json; - json j = {{"id", newId}, {"type", "image"}, - {"x", x}, {"y", y}, {"w", w}, {"h", h}, - {"z_order", zOrder}, - {"image_path", imagePath.toStdString()}, - {"scale_mode", "contain"}}; - m_doc->undoStack()->push(new AddElementCmd(m_doc, std::move(j))); } if (needsMacro) @@ -1042,30 +1414,52 @@ void CanvasWidget::dropEvent(QDropEvent* event) event->acceptProposedAction(); } +// Pick a bidirectional resize cursor from a handle's outward direction (widget space, y-down). +static Qt::CursorShape resizeCursorForAngle(double dx, double dy) +{ + double a = std::atan2(dy, dx) * 180.0 / M_PI; // (-180,180] + a = std::fmod(a + 180.0, 180.0); // fold to [0,180) — cursor is bidirectional + if (a < 22.5 || a >= 157.5) return Qt::SizeHorCursor; + if (a < 67.5) return Qt::SizeFDiagCursor; // down-right / up-left "\" + if (a < 112.5) return Qt::SizeVerCursor; + return Qt::SizeBDiagCursor; // down-left / up-right "/" +} + void CanvasWidget::updateCursorForPos(QPointF wpos) { if (m_paintModeActive) return; // cursor already set by setPaintMode(); don't override - static const Qt::CursorShape kHandleCursors[8] = { - Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor, - Qt::SizeHorCursor, Qt::SizeBDiagCursor, Qt::SizeVerCursor, Qt::SizeFDiagCursor}; - - const int handle = hitHandle(wpos); - if (handle >= 0) { setCursor(kHandleCursors[handle]); return; } - const SelectionId sel = m_editorState->selection(); + const VisualElement* selVe = nullptr; if (sel.level == SelectionId::Level::Element) { - const QPointF sp = widgetToTitle(wpos); const Title& t = m_doc->title(); - if (sel.elementIndex >= 1 && sel.elementIndex < (int)t.elements.size()) { - const auto* ve = dynamic_cast(t.elements[sel.elementIndex].get()); - if (ve) { - const Rectangle gb = globalBounds(*ve); - if (sp.x() >= gb.x && sp.x() <= gb.x + gb.width && - sp.y() >= gb.y && sp.y() <= gb.y + gb.height) { - setCursor(Qt::SizeAllCursor); - return; - } + if (sel.elementIndex >= 1 && sel.elementIndex < (int)t.elements.size()) + selVe = dynamic_cast(t.elements[sel.elementIndex].get()); + } + + const int handle = (m_editorState->selectionCount() <= 1 && !isActiveSelectionLocked()) + ? hitHandle(wpos) : -1; + if (handle == SelectionHandles::kRotateHandle) { + setCursor(Qt::CrossCursor); // Qt has no native rotate cursor; CrossCursor is the stand-in + return; + } + if (handle >= 0 && selVe) { + const QTransform xf = elementToWidgetTransform(*selVe); + const QPointF center = xf.map(localBoundsRect(*selVe).center()); + const QPointF hc = SelectionHandles::handleCenterT(handle, xf, localBoundsRect(*selVe)); + setCursor(resizeCursorForAngle(hc.x() - center.x(), hc.y() - center.y())); + return; + } + + if (selVe) { + bool ok = false; + const QTransform inv = elementLocalToTitle(*selVe).inverted(&ok); + if (ok) { + const QPointF lp = inv.map(widgetToTitle(wpos)); + const Rectangle b = selVe->GetBounds(); + if (lp.x() >= 0.0 && lp.x() <= b.width && lp.y() >= 0.0 && lp.y() <= b.height) { + setCursor(Qt::SizeAllCursor); + return; } } } diff --git a/ui/widgets/CanvasWidget.h b/ui/widgets/CanvasWidget.h index 26eacda..f4b2855 100644 --- a/ui/widgets/CanvasWidget.h +++ b/ui/widgets/CanvasWidget.h @@ -2,12 +2,15 @@ #include #include +#include +#include #include #include #include #include #include +#include #include #ifdef __APPLE__ @@ -32,6 +35,7 @@ class QWheelEvent; class TitleDocument; class EditorTitle; +class VisualElement; struct Title; class CanvasWidget : public QWidget { @@ -71,8 +75,14 @@ class CanvasWidget : public QWidget { // Paint (copy-style) mode void setPaintMode(bool active, const std::string& sourceElementId); + // Axis-aligned bounding box of the element in TITLE/world space, accounting + // for rotation/shear (used by align/distribute math). + QRectF elementTitleAABB(const VisualElement& el) const; + signals: void paintModeFinished(); + void interactiveEditStarted(); + void interactiveEditFinished(); protected: void paintEvent(QPaintEvent* event) override; @@ -98,10 +108,27 @@ private slots: QRectF letterboxRect() const; QPointF widgetToTitle(QPointF pt) const; QRectF titleToWidget(const Rectangle& r) const; + // Full element-local (0..w, 0..h) -> widget-pixel transform, including + // rotation/shear about the element center. Reproduces the engine's + // Cairo render order exactly (see Transform::Apply in engine/types.hpp). + QTransform elementToWidgetTransform(const VisualElement& el) const; + // Pure linear (rotation+shear) part of the element's transform, in + // title/world space, with no translation or letterbox scale. Used to + // project a world-space delta into the element's local axes via + // .inverted().map(...). + QTransform elementLinear(const VisualElement& el) const; + // Element-LOCAL (0..w, 0..h) -> TITLE/world space transform (no letterbox + // scale/offset). Used to inverse-map a title-space point into local space. + QTransform elementLocalToTitle(const VisualElement& el) const; // Hit testing SelectionId hitTest(QPointF titlePt) const; int hitHandle(QPointF widgetPt) const; + // True when the active/anchor selected element is locked (blocks canvas drag). + bool isActiveSelectionLocked() const; + // Elements (indices into title.elements) whose widget-space quad + // intersects the given widget-space rubber-band rect. + std::vector elementsInMarquee(const QRectF& widgetRect) const; // Rendering void renderStaticTitle(); @@ -131,6 +158,8 @@ private slots: // Resize drag void applyResizeDrag(QPointF widgetPos, Qt::KeyboardModifiers mods); + // Rotate drag + void applyRotateDrag(QPointF widgetPos, Qt::KeyboardModifiers mods); // ── Document / selection ────────────────────────────────────────────────── TitleDocument* m_doc; @@ -163,7 +192,7 @@ private slots: QPointF m_panStartOffset; // ── Element drag / resize ───────────────────────────────────────────────── - enum class DragMode { None, Move, Resize }; + enum class DragMode { None, Move, Resize, Rotate }; DragMode m_dragMode{DragMode::None}; int m_dragHandle{-1}; QPointF m_dragStartWidget; @@ -173,6 +202,24 @@ private slots: int m_dragEi{-1}; // index into title.elements (1-based) bool m_dragging{false}; + // Group-move support: (elementIndex, origBounds) for every member of a + // multi-selection move, INCLUDING the active element (m_dragEi). Empty + // means an ordinary single-element move. + std::vector> m_dragGroup; + + QPointF m_dragAnchorTitle; // world pos of the resize anchor (opposite handle), captured at press + QPointF m_dragOrigCenterTitle; // world pos of the element center at press (for Ctrl-symmetric resize / rotate) + QPointF m_dragParentOffset; // P: parent global offset (gpos - local bounds x/y) + + double m_dragStartAngle{0.0}; // cursor angle about element center at rotate-drag start (radians) + float m_dragOrigRotation{0.0f};// element rotation at rotate-drag start (degrees) + + // ── Rubber-band marquee selection ───────────────────────────────────────── + bool m_marqueeActive{false}; + QPointF m_marqueeStartWidget; + QPointF m_marqueeCurWidget; + bool m_marqueeAdditive{false}; + // ── Paint (copy-style) mode ─────────────────────────────────────────────── bool m_paintModeActive{false}; std::string m_paintSourceEi; diff --git a/ui/widgets/ClipPropertiesPanel.cpp b/ui/widgets/ClipPropertiesPanel.cpp new file mode 100644 index 0000000..a0f2360 --- /dev/null +++ b/ui/widgets/ClipPropertiesPanel.cpp @@ -0,0 +1,138 @@ +#include "ClipPropertiesPanel.h" + +#include "ui/UiUtils.h" + +#include +#include +#include + +namespace { + +QString animationTypeName(AnimationType t) +{ + switch (t) { + case AnimationType::None: return "None"; + case AnimationType::Fade: return "Fade"; + case AnimationType::SlideUp: return "Slide Up"; + case AnimationType::SlideDown: return "Slide Down"; + case AnimationType::SlideLeft: return "Slide Left"; + case AnimationType::SlideRight: return "Slide Right"; + case AnimationType::ScaleIn: return "Scale In"; + case AnimationType::WipeUp: return "Wipe Up"; + case AnimationType::WipeDown: return "Wipe Down"; + case AnimationType::WipeLeft: return "Wipe Left"; + case AnimationType::WipeRight: return "Wipe Right"; + } + return {}; +} + +struct EasingEntry { Easing easing; const char* name; }; + +// Copied verbatim from AnimationTimingEditor.cpp's kAllEasings[] table. +constexpr EasingEntry kAllEasings[] = { + { Easing::Linear, "Linear" }, + { Easing::EaseIn, "Ease In" }, + { Easing::EaseOut, "Ease Out" }, + { Easing::EaseInOut, "Ease In/Out" }, + { Easing::EaseInCubic, "Ease In (Cubic)" }, + { Easing::EaseOutCubic, "Ease Out (Cubic)" }, + { Easing::EaseInOutCubic, "Ease In/Out (Cubic)" }, + { Easing::EaseInExpo, "Ease In (Expo)" }, + { Easing::EaseOutExpo, "Ease Out (Expo)" }, + { Easing::EaseInOutExpo, "Ease In/Out (Expo)" }, + { Easing::EaseInBack, "Ease In (Back)" }, + { Easing::EaseOutBack, "Ease Out (Back)" }, + { Easing::EaseInOutBack, "Ease In/Out (Back)" }, + { Easing::EaseInElastic, "Ease In (Elastic)" }, + { Easing::EaseOutElastic, "Ease Out (Elastic)" }, + { Easing::EaseInOutElastic,"Ease In/Out (Elastic)" }, +}; + +constexpr AnimationType kAllTypes[] = { + AnimationType::None, AnimationType::Fade, + AnimationType::SlideUp, AnimationType::SlideDown, + AnimationType::SlideLeft, AnimationType::SlideRight, + AnimationType::ScaleIn, AnimationType::WipeUp, + AnimationType::WipeDown, AnimationType::WipeLeft, + AnimationType::WipeRight, +}; + +} // namespace + +ClipPropertiesPanel::ClipPropertiesPanel(QWidget* parent) + : QWidget(parent) +{ + setFixedWidth(200); + + m_typeCombo = new QComboBox(this); + for (auto t : kAllTypes) + m_typeCombo->addItem(animationTypeName(t), int(t)); + + m_easingCombo = new QComboBox(this); + for (auto& e : kAllEasings) + m_easingCombo->addItem(e.name, int(e.easing)); + + m_delaySpin = makeSpinBox(0.0, 600.0, 2, " s"); + m_delaySpin->setSingleStep(0.05); + + m_durationSpin = makeSpinBox(0.01, 600.0, 2, " s"); + m_durationSpin->setSingleStep(0.05); + + auto* form = new QFormLayout(this); + form->addRow(tr("Type"), m_typeCombo); + form->addRow(tr("Easing"), m_easingCombo); + form->addRow(tr("Delay"), m_delaySpin); + form->addRow(tr("Duration"), m_durationSpin); + + connect(m_typeCombo, &QComboBox::currentIndexChanged, + this, &ClipPropertiesPanel::onControlChanged); + connect(m_easingCombo, &QComboBox::currentIndexChanged, + this, &ClipPropertiesPanel::onControlChanged); + connect(m_delaySpin, &QDoubleSpinBox::valueChanged, + this, &ClipPropertiesPanel::onControlChanged); + connect(m_durationSpin, &QDoubleSpinBox::valueChanged, + this, &ClipPropertiesPanel::onControlChanged); + + setClip(false, AnimationDef{}); +} + +void ClipPropertiesPanel::setClip(bool valid, const AnimationDef& def) +{ + UpdateGuard guard(m_updating); + + setEnabled(valid); + + if (!valid) { + m_typeCombo->setCurrentIndex(0); + m_easingCombo->setCurrentIndex(0); + m_delaySpin->setValue(0.0); + m_durationSpin->setValue(m_durationSpin->minimum()); + return; + } + + int typeIdx = m_typeCombo->findData(int(def.type)); + m_typeCombo->setCurrentIndex(typeIdx >= 0 ? typeIdx : 0); + + int easingIdx = m_easingCombo->findData(int(def.easing)); + m_easingCombo->setCurrentIndex(easingIdx >= 0 ? easingIdx : 0); + + m_delaySpin->setValue(def.delay); + m_durationSpin->setValue(def.duration); +} + +AnimationDef ClipPropertiesPanel::currentDef() const +{ + AnimationDef def; + def.type = AnimationType(m_typeCombo->currentData().toInt()); + def.easing = Easing(m_easingCombo->currentData().toInt()); + def.delay = float(m_delaySpin->value()); + def.duration = float(m_durationSpin->value()); + return def; +} + +void ClipPropertiesPanel::onControlChanged() +{ + if (m_updating) + return; + emit clipChanged(currentDef()); +} diff --git a/ui/widgets/ClipPropertiesPanel.h b/ui/widgets/ClipPropertiesPanel.h new file mode 100644 index 0000000..a15d38f --- /dev/null +++ b/ui/widgets/ClipPropertiesPanel.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "engine/animation.h" + +class QComboBox; +class QDoubleSpinBox; + +// Pure view widget showing the fields of ONE AnimationDef for precise editing. +// No document/undo/selection logic -- the container widget owns that. +class ClipPropertiesPanel : public QWidget { + Q_OBJECT +public: + explicit ClipPropertiesPanel(QWidget* parent = nullptr); + + // Populate the form. If valid==false, blank + disable the form controls. + // Does NOT emit clipChanged() as a side effect. + void setClip(bool valid, const AnimationDef& def); + +signals: + // Emitted whenever the user edits any field. Carries the FULL current + // AnimationDef (all four fields read from the controls). + void clipChanged(AnimationDef def); + +private slots: + void onControlChanged(); + +private: + AnimationDef currentDef() const; + + QComboBox* m_typeCombo{nullptr}; + QComboBox* m_easingCombo{nullptr}; + QDoubleSpinBox* m_delaySpin{nullptr}; + QDoubleSpinBox* m_durationSpin{nullptr}; + bool m_updating{false}; +}; diff --git a/ui/widgets/ColorWheel.cpp b/ui/widgets/ColorWheel.cpp index edf69be..46084c7 100644 --- a/ui/widgets/ColorWheel.cpp +++ b/ui/widgets/ColorWheel.cpp @@ -177,7 +177,10 @@ void ColorWheel::handleTriangleDrag(QPointF p) void ColorWheel::rebuildRing() { QSize sz = size(); - m_ringImage = QImage(sz, QImage::Format_ARGB32); + const qreal dpr = devicePixelRatioF(); + m_ringImage = QImage(QSize(qRound(sz.width() * dpr), qRound(sz.height() * dpr)), + QImage::Format_ARGB32); + m_ringImage.setDevicePixelRatio(dpr); m_ringImage.fill(Qt::transparent); QPainter p(&m_ringImage); @@ -205,13 +208,16 @@ void ColorWheel::rebuildRing() void ColorWheel::rebuildTriangle() { QSize sz = size(); - m_triangleImage = QImage(sz, QImage::Format_ARGB32); + const qreal dpr = devicePixelRatioF(); + const QSize physSz(qRound(sz.width() * dpr), qRound(sz.height() * dpr)); + m_triangleImage = QImage(physSz, QImage::Format_ARGB32); + m_triangleImage.setDevicePixelRatio(dpr); m_triangleImage.fill(Qt::transparent); auto verts = triangleVertices(); - QPointF v0 = verts[0]; // hue - QPointF v1 = verts[1]; // white - QPointF v2 = verts[2]; // black + QPointF v0 = verts[0] * dpr; // hue (physical coords) + QPointF v1 = verts[1] * dpr; // white + QPointF v2 = verts[2] * dpr; // black int hue = m_color.hsvHue(); if (hue < 0) @@ -228,9 +234,9 @@ void ColorWheel::rebuildTriangle() qreal invD = 1.0 / denom; int x0 = qMax(0, (int)std::floor(std::min({v0.x(), v1.x(), v2.x()}))); - int x1 = qMin(sz.width() - 1, (int)std::ceil(std::max({v0.x(), v1.x(), v2.x()}))); + int x1 = qMin(physSz.width() - 1, (int)std::ceil(std::max({v0.x(), v1.x(), v2.x()}))); int y0 = qMax(0, (int)std::floor(std::min({v0.y(), v1.y(), v2.y()}))); - int y1 = qMin(sz.height() - 1, (int)std::ceil(std::max({v0.y(), v1.y(), v2.y()}))); + int y1 = qMin(physSz.height() - 1, (int)std::ceil(std::max({v0.y(), v1.y(), v2.y()}))); for (int y = y0; y <= y1; ++y) { QRgb* line = reinterpret_cast(m_triangleImage.scanLine(y)); diff --git a/ui/widgets/GraphicTimingEditor.cpp b/ui/widgets/GraphicTimingEditor.cpp deleted file mode 100644 index b70ee8e..0000000 --- a/ui/widgets/GraphicTimingEditor.cpp +++ /dev/null @@ -1,240 +0,0 @@ -#include "GraphicTimingEditor.h" -#include "AnimationTimingEditor.h" -#include "icons.h" -#include "model/TitleDocument.h" -#include "engine/title.h" -#include "engine/visual_element.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static constexpr int kNameColWidth = 120; - -GraphicTimingEditor::GraphicTimingEditor(TitleDocument* doc, QWidget* parent) - : QWidget{parent}, m_doc{doc} -{ - m_playBtn = new QPushButton("▶"); - m_playBtn->setFixedSize(26, 26); - - m_speedSlider = new QSlider(Qt::Horizontal); - m_speedSlider->setRange(0, 100); - m_speedSlider->setValue(100); - m_speedSlider->setFixedWidth(80); - m_speedSlider->setToolTip("Playback speed (0–1×)"); - - m_timeLabel = new QLabel("0.00 s"); - m_timeLabel->setFixedWidth(52); - m_timeLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - m_tabBar = new QTabBar; - m_tabBar->setIconSize({16, 16}); - m_tabBar->addTab(themedIcon(Icons16::Action_LogIn), "In"); - m_tabBar->addTab(themedIcon(Icons16::Action_LogOut), "Out"); - m_tabBar->addTab(themedIcon(Icons16::Action_Download), "Data Change In"); - m_tabBar->addTab(themedIcon(Icons16::Action_Export), "Data Change Out"); - - m_maxDurSpin = new QDoubleSpinBox; - m_maxDurSpin->setRange(0.1, 60.0); - m_maxDurSpin->setSingleStep(0.1); - m_maxDurSpin->setDecimals(2); - m_maxDurSpin->setSuffix(" s"); - m_maxDurSpin->setValue(1.0); - m_maxDurSpin->setFixedWidth(80); - - auto* toolbar = new QHBoxLayout; - toolbar->setContentsMargins(4, 2, 4, 2); - toolbar->setSpacing(4); - toolbar->addWidget(m_tabBar); - toolbar->addWidget(m_playBtn); - toolbar->addWidget(m_timeLabel); - toolbar->addWidget(new QLabel("Speed:")); - toolbar->addWidget(m_speedSlider); - toolbar->addStretch(); - toolbar->addWidget(new QLabel("Max. Duration:")); - toolbar->addWidget(m_maxDurSpin); - - m_content = new QWidget; - m_content->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - m_grid = new QGridLayout(m_content); - m_grid->setContentsMargins(4, 4, 4, 4); - m_grid->setHorizontalSpacing(4); - m_grid->setVerticalSpacing(2); - m_grid->setColumnMinimumWidth(0, kNameColWidth); - m_grid->setColumnStretch(1, 1); - - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scroll->setWidget(m_content); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); - root->addLayout(toolbar); - root->addWidget(scroll); - - m_playTimer = new QTimer(this); - m_playTimer->setInterval(16); - - connect(m_tabBar, &QTabBar::currentChanged, this, &GraphicTimingEditor::onTabChanged); - connect(m_maxDurSpin, &QDoubleSpinBox::valueChanged, this, &GraphicTimingEditor::onMaxDurationChanged); - connect(m_playBtn, &QPushButton::clicked, this, [this] { - m_playState == PlayState::Playing ? onStop() : onPlay(); - }); - connect(m_speedSlider, &QSlider::valueChanged, this, [this](int v) { - m_playSpeed = v / 100.0f; - }); - connect(m_playTimer, &QTimer::timeout, this, &GraphicTimingEditor::onTick); -} - -// ── public ────────────────────────────────────────────────────────────────── - -void GraphicTimingEditor::load() -{ - onStop(); - m_maxDurSpin->blockSignals(true); - m_maxDurSpin->setValue(double(computeAutoMaxDuration())); - m_maxDurSpin->blockSignals(false); - rebuild(); -} - -void GraphicTimingEditor::clear() -{ - onStop(); - rebuild(); -} - -// ── private slots ──────────────────────────────────────────────────────────── - -void GraphicTimingEditor::onTabChanged(int) -{ - onStop(); - m_maxDurSpin->blockSignals(true); - m_maxDurSpin->setValue(double(computeAutoMaxDuration())); - m_maxDurSpin->blockSignals(false); - rebuild(); -} - -void GraphicTimingEditor::onMaxDurationChanged(double value) -{ - const float maxD = float(value); - for (AnimationTimingEditor* ed : m_editors) - ed->setMaxDuration(maxD); -} - -void GraphicTimingEditor::onPlay() -{ - m_playState = PlayState::Playing; - m_playBtn->setText("⏹"); - m_playTimer->start(); -} - -void GraphicTimingEditor::onStop() -{ - m_playState = PlayState::Stopped; - m_playBtn->setText("▶"); - m_playTimer->stop(); - setScrubTimeOnAll(0.0f); - emit previewStopped(); -} - -void GraphicTimingEditor::onTick() -{ - const float maxD = float(m_maxDurSpin->value()); - m_scrubTime += m_playTimer->interval() / 1000.0f * m_playSpeed; - if (m_scrubTime >= maxD) { - m_scrubTime = maxD; - setScrubTimeOnAll(m_scrubTime); - onStop(); - return; - } - setScrubTimeOnAll(m_scrubTime); -} - -// ── private ────────────────────────────────────────────────────────────────── - -bool GraphicTimingEditor::isIn() const -{ - const int tab = m_tabBar->currentIndex(); - return tab == 0 || tab == 2; -} - -bool GraphicTimingEditor::isDataAnim() const -{ - return m_tabBar->currentIndex() >= 2; -} - -float GraphicTimingEditor::computeAutoMaxDuration() const -{ - const Title& t = m_doc->title(); - const bool in = isIn(); - const bool data = isDataAnim(); - float maxEnd = 0.1f; - for (int i = 1; i < (int)t.elements.size(); ++i) { - const auto* ve = dynamic_cast(t.elements[i].get()); - if (!ve) continue; - const AnimationDef& def = data - ? (in ? ve->dataInAnimation : ve->dataOutAnimation) - : (in ? ve->inAnimation : ve->outAnimation); - maxEnd = qMax(maxEnd, def.delay + def.duration); - } - return std::ceil(maxEnd * 2.0f) / 2.0f; -} - -void GraphicTimingEditor::setScrubTimeOnAll(float t) -{ - m_scrubTime = t; - m_timeLabel->setText(QString("%1 s").arg(double(t), 0, 'f', 2)); - emit scrubTimeChanged(t); -} - -void GraphicTimingEditor::rebuild() -{ - while (QLayoutItem* item = m_grid->takeAt(0)) { - if (QWidget* w = item->widget()) - w->deleteLater(); - delete item; - } - m_editors.clear(); - - const Title& t = m_doc->title(); - const float maxD = float(m_maxDurSpin->value()); - const bool in = isIn(); - const bool data = isDataAnim(); - - for (int i = 1; i < (int)t.elements.size(); ++i) { - const auto* ve = dynamic_cast(t.elements[i].get()); - if (!ve) continue; - - auto* label = new QLabel(QString::fromStdString(ve->GetId())); - label->setFixedWidth(kNameColWidth); - label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - const AnimationDef& animDef = data - ? (in ? ve->dataInAnimation : ve->dataOutAnimation) - : (in ? ve->inAnimation : ve->outAnimation); - - auto* editor = new AnimationTimingEditor; - editor->setMaxDuration(maxD); - editor->load(animDef); - - const int row = static_cast(m_editors.size()); - connect(editor, &AnimationTimingEditor::animationChanged, - this, [this, i, in, data](AnimationType type, Easing easing, float delay, float duration) { - emit animationChanged(i, in, data, type, easing, delay, duration); - }); - - m_grid->addWidget(label, row, 0); - m_grid->addWidget(editor, row, 1); - m_editors.append(editor); - } -} diff --git a/ui/widgets/GraphicTimingEditor.h b/ui/widgets/GraphicTimingEditor.h deleted file mode 100644 index bb5b1c5..0000000 --- a/ui/widgets/GraphicTimingEditor.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include - -class TitleDocument; -class AnimationTimingEditor; -class QDoubleSpinBox; -class QTabBar; -class QGridLayout; -class QPushButton; -class QLabel; -class QSlider; -class QTimer; - -enum class AnimationType; -enum class Easing; - -class GraphicTimingEditor : public QWidget { - Q_OBJECT -public: - explicit GraphicTimingEditor(TitleDocument* doc, QWidget* parent = nullptr); - - void load(); - void clear(); - bool isIn() const; - bool isDataAnim() const; - -signals: - void animationChanged(int elementIndex, bool isIn, bool isData, - AnimationType type, Easing easing, float delay, float duration); - void scrubTimeChanged(float t); - void previewStopped(); - -private slots: - void onTabChanged(int index); - void onMaxDurationChanged(double value); - void onPlay(); - void onStop(); - void onTick(); - -private: - enum class PlayState { Stopped, Playing }; - - void rebuild(); - float computeAutoMaxDuration() const; - void setScrubTimeOnAll(float t); - - TitleDocument* m_doc; - PlayState m_playState{PlayState::Stopped}; - float m_scrubTime{0.0f}; - - QTabBar* m_tabBar; - QDoubleSpinBox* m_maxDurSpin; - QPushButton* m_playBtn; - QSlider* m_speedSlider; - QLabel* m_timeLabel; - float m_playSpeed{1.0f}; - QTimer* m_playTimer; - QWidget* m_content; - QGridLayout* m_grid; - QList m_editors; -}; diff --git a/ui/widgets/ResizeMath.h b/ui/widgets/ResizeMath.h new file mode 100644 index 0000000..0decb2b --- /dev/null +++ b/ui/widgets/ResizeMath.h @@ -0,0 +1,120 @@ +#pragma once +#include +#include +#include +#include +#include "engine/types.hpp" + +namespace resizemath { + +constexpr double kPi = 3.14159265358979323846; + +// Normalized coords (0, 0.5, 1) of the anchor (the handle OPPOSITE the dragged one). +inline void anchorNorm(int handle, double& ax, double& ay) +{ + switch (handle) { + case 0: ax = 1; ay = 1; break; // TL -> anchor BR + case 1: ax = 0.5; ay = 1; break; // TC -> BC + case 2: ax = 0; ay = 1; break; // TR -> BL + case 3: ax = 1; ay = 0.5; break; // ML -> MR + case 4: ax = 0; ay = 0.5; break; // MR -> ML + case 5: ax = 1; ay = 0; break; // BL -> TR + case 6: ax = 0.5; ay = 0; break; // BC -> TC + case 7: ax = 0; ay = 0; break; // BR -> TL + default: ax = 0; ay = 0; break; + } +} + +// handle: 0-7. orig: bounds at drag start. R: elementLinear (rotation+shear, no translation). +// dLx,dLy: mouse delta already projected into element-local axes (R^-1 * worldDelta). +// anchorTitle: world pos of the anchor, captured at drag start. origCenterTitle: world pos of +// the element center at drag start. parentOffset: P (parent global offset). shift/ctrl modifiers. +inline Rectangle resizeSolve(int handle, const Rectangle& orig, const QTransform& R, + double dLx, double dLy, + const QPointF& anchorTitle, const QPointF& origCenterTitle, + const QPointF& parentOffset, bool shift, bool ctrl) +{ + const double ow = orig.width, oh = orig.height; + double w1 = ow, h1 = oh; + switch (handle) { + case 0: w1 = ow - dLx; h1 = oh - dLy; break; + case 1: h1 = oh - dLy; break; + case 2: w1 = ow + dLx; h1 = oh - dLy; break; + case 3: w1 = ow - dLx; break; + case 4: w1 = ow + dLx; break; + case 5: w1 = ow - dLx; h1 = oh + dLy; break; + case 6: h1 = oh + dLy; break; + case 7: w1 = ow + dLx; h1 = oh + dLy; break; + default: return orig; + } + w1 = std::max(1.0, w1); + h1 = std::max(1.0, h1); + + // Shift: aspect-lock on corner handles. + const bool corner = (handle == 0 || handle == 2 || handle == 5 || handle == 7); + if (shift && corner && ow > 0.0 && oh > 0.0) { + const double scale = std::min(w1 / ow, h1 / oh); + w1 = std::max(1.0, ow * scale); + h1 = std::max(1.0, oh * scale); + } + + Rectangle nb = orig; + nb.width = w1; + nb.height = h1; + + if (ctrl) { + // Symmetric about the ORIGINAL center: double the size delta, keep center fixed. + w1 = std::max(1.0, 2.0 * w1 - ow); + h1 = std::max(1.0, 2.0 * h1 - oh); + nb.width = w1; + nb.height = h1; + // W(center) = P + (nb.x,nb.y) + c1 (R terms cancel) == origCenterTitle + nb.x = origCenterTitle.x() - parentOffset.x() - w1 / 2.0; + nb.y = origCenterTitle.y() - parentOffset.y() - h1 / 2.0; + return nb; + } + + // Anchor-invariant solve: (nb.x,nb.y) = anchorTitle - P - R*anchorLocal1 - (I-R)*c1 + double ax, ay; + anchorNorm(handle, ax, ay); + const QPointF anchorLocal1(ax * w1, ay * h1); + const QPointF c1(w1 / 2.0, h1 / 2.0); + // R has no translation, but subtract R.map(origin) defensively. + const QPointF Rzero = R.map(QPointF(0, 0)); + const QPointF Ra = R.map(anchorLocal1) - Rzero; + const QPointF Rc = R.map(c1) - Rzero; + nb.x = anchorTitle.x() - parentOffset.x() - Ra.x() - (c1.x() - Rc.x()); + nb.y = anchorTitle.y() - parentOffset.y() - Ra.y() - (c1.y() - Rc.y()); + return nb; +} + +// Normalize degrees to the canonical (-180, 180] range so a rotate drag that crosses the +// atan2 seam (or wraps past a full turn) can't leave the stored angle offset by a multiple +// of 360° — visually identical either way, but keeps the rotation spinbox reading sanely. +inline double normalizeDeg(double d) +{ + d = std::fmod(d, 360.0); + if (d > 180.0) d -= 360.0; + else if (d <= -180.0) d += 360.0; + return d; +} + +// Rotation drag: returns the new rotation (degrees). angles in radians (atan2 of the +// cursor about the element center). +// snap15 (Shift held) → hard-snap to the nearest 15°. +// otherwise → magnetic soft-snap to the nearest 45° increment when the raw +// angle lands within kSoftSnap45 of one; free rotation elsewhere. +inline double rotateSolve(double origRotDeg, double startAngleRad, double curAngleRad, bool snap15) +{ + double deg = origRotDeg + (curAngleRad - startAngleRad) * 180.0 / kPi; + if (snap15) { + deg = std::round(deg / 15.0) * 15.0; + } else { + constexpr double kSoftSnap45 = 4.0; // degrees of pull toward each 45° increment + const double nearest45 = std::round(deg / 45.0) * 45.0; + if (std::abs(deg - nearest45) <= kSoftSnap45) deg = nearest45; + } + return normalizeDeg(deg); +} + +} // namespace resizemath diff --git a/ui/widgets/RibbonFormatSection.cpp b/ui/widgets/RibbonFormatSection.cpp index c80820d..96b5b94 100644 --- a/ui/widgets/RibbonFormatSection.cpp +++ b/ui/widgets/RibbonFormatSection.cpp @@ -15,19 +15,24 @@ #include #include "ColorPicker.h" +#include #include #include #include +#include #include #include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -36,7 +41,9 @@ #include #include #include +#include #include +#include #include #include @@ -161,7 +168,11 @@ void RibbonFormatSection::updateFillSwatch() try { const VisualElement& el = m_doc->getElement(m_elementId); m_fillBtn->setIcon(makePaintSwatch(el.fill, m_fillBtn->iconSize())); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::updateStrokeSwatch() @@ -170,7 +181,11 @@ void RibbonFormatSection::updateStrokeSwatch() try { const VisualElement& el = m_doc->getElement(m_elementId); m_strokeBtn->setIcon(makePaintSwatch(el.stroke, m_strokeBtn->iconSize())); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } // ── construction ─────────────────────────────────────────────────────────────── @@ -185,6 +200,16 @@ RibbonFormatSection::RibbonFormatSection(TitleDocument* doc, SARibbonBar* ribbon buildQrTab(ribbon); connect(m_doc, &TitleDocument::documentChanged, this, &RibbonFormatSection::onDocumentChanged); + + m_mergeIdleTimer = new QTimer(this); + m_mergeIdleTimer->setSingleShot(true); + connect(m_mergeIdleTimer, &QTimer::timeout, this, [this]() { ++m_mergeGen; }); +} + +int RibbonFormatSection::mergeTag(int base) +{ + if (m_mergeIdleTimer) m_mergeIdleTimer->start(600); + return base + m_mergeGen * 100000; // base tags are < 100000, so no cross-field collision } static QGridLayout* addGroup(SARibbonCategory* category, const QString& name) @@ -340,6 +365,23 @@ void RibbonFormatSection::buildElementTab(SARibbonBar* ribbon) gl->addWidget(deleteBtn, 0, 0, 2, 1, Qt::AlignCenter); connect(deleteAct, &QAction::triggered, this, &RibbonFormatSection::deleteElementRequested); } + + QWidget::setTabOrder(m_idEdit, m_spinX); + QWidget::setTabOrder(m_spinX, m_spinY); + QWidget::setTabOrder(m_spinY, m_spinW); + QWidget::setTabOrder(m_spinW, m_spinH); + QWidget::setTabOrder(m_spinH, m_spinRot); + QWidget::setTabOrder(m_spinRot, m_spinShearX); + QWidget::setTabOrder(m_spinShearX, m_spinShearY); + QWidget::setTabOrder(m_spinShearY, m_spinZ); + QWidget::setTabOrder(m_spinZ, m_spinOpacity); + QWidget::setTabOrder(m_spinOpacity, m_clipChildrenCheck); + QWidget::setTabOrder(m_clipChildrenCheck, m_parentCombo); + QWidget::setTabOrder(m_parentCombo, m_fitCheck); + QWidget::setTabOrder(m_fitCheck, m_spinPadTop); + QWidget::setTabOrder(m_spinPadTop, m_spinPadRight); + QWidget::setTabOrder(m_spinPadRight, m_spinPadBottom); + QWidget::setTabOrder(m_spinPadBottom, m_spinPadLeft); } void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) @@ -352,9 +394,13 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) m_fillPicker = new PaintPickerWidget(m_doc); m_strokePicker = new PaintPickerWidget(m_doc); - auto makeGradDialog = [this](const QString& title, PaintEditor*& editor) -> QDialog* { + auto makeGradDialog = [this](const QString& title, PaintEditor*& editor, bool isFill) -> QDialog* { auto* dlg = new QDialog(nullptr, Qt::Tool); dlg->setAttribute(Qt::WA_DeleteOnClose, false); + connect(dlg, &QDialog::finished, this, [this, isFill](int) { + QPointer& focus = isFill ? m_focusBeforeFillGradDlg : m_focusBeforeStrokeGradDlg; + if (focus) focus->setFocus(); + }); dlg->setWindowTitle(title); dlg->setMinimumWidth(250); auto* l = new QVBoxLayout(dlg); @@ -363,8 +409,8 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) dlg->adjustSize(); return dlg; }; - auto* fillGradDlg = makeGradDialog("Fill", m_fillEditor); - auto* strokeGradDlg = makeGradDialog("Stroke", m_strokeEditor); + auto* fillGradDlg = makeGradDialog("Fill", m_fillEditor, true); + auto* strokeGradDlg = makeGradDialog("Stroke", m_strokeEditor, false); auto [fillBtn, fillMenu] = makePopupButton("Fill", m_fillPicker); auto [strokeBtn, strokeMenu] = makePopupButton("Stroke", m_strokePicker); @@ -388,7 +434,11 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) try { const VisualElement& el = m_doc->getElement(m_elementId); picker->setPaint(isFill ? el.fill : el.stroke); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } }); if (isFill) connect(picker, &PaintPickerWidget::paintChanged, this, &RibbonFormatSection::onFillPaintChanged); @@ -402,7 +452,12 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) const VisualElement& el = m_doc->getElement(m_elementId); QSignalBlocker b(editor); editor->setPaint(isFill ? el.fill : el.stroke); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } + (isFill ? m_focusBeforeFillGradDlg : m_focusBeforeStrokeGradDlg) = QApplication::focusWidget(); gradDlg->show(); gradDlg->raise(); gradDlg->activateWindow(); @@ -491,7 +546,11 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) const auto& c = el.shadow.color; QSignalBlocker b(m_shadowColorPicker); m_shadowColorPicker->setColor(QColor::fromRgbF(c[0], c[1], c[2], c[3])); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } }); connect(m_shadowColorPicker, &ColorPicker::colorChanged, this, [this](const QColor& picked) { if (m_elementId.empty()) return; @@ -502,7 +561,11 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) sh.color[2] = static_cast(picked.blueF()); sh.color[3] = static_cast(picked.alphaF()); m_doc->undoStack()->push(new SetElementShadowCmd(m_doc, m_elementId, sh)); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } }); } @@ -521,6 +584,19 @@ void RibbonFormatSection::buildStyleTab(SARibbonBar* ribbon) connect(m_copyStyleAct, &QAction::toggled, this, [this](bool on) { emit copyStyleModeToggled(on, m_elementId); }); + + QWidget::setTabOrder(m_fillBtn, m_strokeBtn); + QWidget::setTabOrder(m_strokeBtn, m_strokeWidth); + QWidget::setTabOrder(m_strokeWidth, m_spinTL); + QWidget::setTabOrder(m_spinTL, m_spinTR); + QWidget::setTabOrder(m_spinTR, m_spinBR); + QWidget::setTabOrder(m_spinBR, m_spinBL); + QWidget::setTabOrder(m_spinBL, m_shadowEnabled); + QWidget::setTabOrder(m_shadowEnabled, m_spinShadowOffX); + QWidget::setTabOrder(m_spinShadowOffX, m_spinShadowOffY); + QWidget::setTabOrder(m_spinShadowOffY, m_spinShadowBlur); + QWidget::setTabOrder(m_spinShadowBlur, m_shadowColorBtn); + QWidget::setTabOrder(m_shadowColorBtn, copyStyleBtn); } } @@ -711,6 +787,26 @@ void RibbonFormatSection::buildTextTab(SARibbonBar* ribbon) connect(m_charSpacing, QOverload::of(&QDoubleSpinBox::valueChanged), this, &RibbonFormatSection::onCharSpacingChanged); } + + QWidget::setTabOrder(m_fontFamily, m_fontSize); + QWidget::setTabOrder(m_fontSize, m_fontWeight); + QWidget::setTabOrder(m_fontWeight, m_italicBtn); + QWidget::setTabOrder(m_italicBtn, m_underlineBtn); + QWidget::setTabOrder(m_underlineBtn, m_strikeBtn); + QWidget::setTabOrder(m_strikeBtn, m_alignLeft); + QWidget::setTabOrder(m_alignLeft, m_alignCenter); + QWidget::setTabOrder(m_alignCenter, m_alignJustify); + QWidget::setTabOrder(m_alignJustify, m_alignRight); + QWidget::setTabOrder(m_alignRight, m_alignTop); + QWidget::setTabOrder(m_alignTop, m_alignMiddle); + QWidget::setTabOrder(m_alignMiddle, m_alignBottom); + QWidget::setTabOrder(m_alignBottom, m_autoScale); + QWidget::setTabOrder(m_autoScale, m_ellipsize); + QWidget::setTabOrder(m_ellipsize, m_wrap); + QWidget::setTabOrder(m_wrap, m_textTransform); + QWidget::setTabOrder(m_textTransform, m_textBtn); + QWidget::setTabOrder(m_textBtn, m_lineSpacing); + QWidget::setTabOrder(m_lineSpacing, m_charSpacing); } void RibbonFormatSection::buildImageTab(SARibbonBar* ribbon) @@ -753,6 +849,9 @@ void RibbonFormatSection::buildImageTab(SARibbonBar* ribbon) onImagePathChanged(); }); connect(m_scaleMode, QOverload::of(&QComboBox::currentIndexChanged), this, &RibbonFormatSection::onScaleModeChanged); + + QWidget::setTabOrder(m_imagePathEdit, browseBtn); + QWidget::setTabOrder(browseBtn, m_scaleMode); } } @@ -784,6 +883,9 @@ void RibbonFormatSection::openContentEditor(const QString& title) m_contentEdit = new QPlainTextEdit(m_contentDialog); l->addWidget(m_contentEdit); connect(m_contentEdit, &QPlainTextEdit::textChanged, this, &RibbonFormatSection::onContentChanged); + connect(m_contentDialog, &QDialog::finished, this, [this](int) { + if (m_focusBeforeContentDlg) m_focusBeforeContentDlg->setFocus(); + }); } m_contentDialog->setWindowTitle(title); if (!m_elementId.empty()) { @@ -797,8 +899,13 @@ void RibbonFormatSection::openContentEditor(const QString& title) QSignalBlocker blocker(m_contentEdit); m_contentEdit->setPlainText(text); m_contentSavedCursor = 0; - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } + m_focusBeforeContentDlg = QApplication::focusWidget(); m_contentDialog->show(); m_contentDialog->raise(); m_contentDialog->activateWindow(); @@ -859,7 +966,11 @@ void RibbonFormatSection::populateRefCombos() } m_parentCombo->setCurrentIndex(parentIdx); m_parentCombo->blockSignals(false); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } // ── onDocumentChanged ───────────────────────────────────────────────────────── @@ -871,7 +982,10 @@ void RibbonFormatSection::onDocumentChanged() const VisualElement* el = nullptr; try { el = &m_doc->getElement(m_elementId); - } catch (...) { + } catch (const std::runtime_error&) { + return; // stale element id — nothing to refresh + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); return; } @@ -911,7 +1025,10 @@ void RibbonFormatSection::onDocumentChanged() // ID field bool hasChildren = !el->GetChildren().empty(); m_idEdit->setReadOnly(hasChildren); - m_idEdit->setStyleSheet(hasChildren ? "QLineEdit { color: #888; font-style: italic; }" : ""); + m_idEdit->setStyleSheet(hasChildren + ? QString("QLineEdit { color: %1; font-style: italic; }") + .arg(m_idEdit->palette().color(QPalette::PlaceholderText).name()) + : QString()); m_idEdit->setText(QString::fromStdString(el->GetId())); // Fit-to-children + padding @@ -979,6 +1096,7 @@ void RibbonFormatSection::onDocumentChanged() if (const auto* ie = dynamic_cast(el)) { m_imagePathEdit->setText(QString::fromStdString(ie->imagePath)); + updateImagePathValidity(m_imagePathEdit->text()); m_scaleMode->setCurrentIndex(scaleModeToComboIndex(ie->imageScaleMode)); } @@ -1006,8 +1124,12 @@ void RibbonFormatSection::onXChanged(double v) try { Rectangle b = m_doc->getElement(m_elementId).GetBounds(); b.x = v; - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, ElemMergeTag::X)); - } catch (...) {} + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, mergeTag(ElemMergeTag::X))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onYChanged(double v) @@ -1016,8 +1138,12 @@ void RibbonFormatSection::onYChanged(double v) try { Rectangle b = m_doc->getElement(m_elementId).GetBounds(); b.y = v; - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, ElemMergeTag::Y)); - } catch (...) {} + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, mergeTag(ElemMergeTag::Y))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onWChanged(double v) @@ -1026,8 +1152,12 @@ void RibbonFormatSection::onWChanged(double v) try { Rectangle b = m_doc->getElement(m_elementId).GetBounds(); b.width = v; - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, ElemMergeTag::W)); - } catch (...) {} + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, mergeTag(ElemMergeTag::W))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onHChanged(double v) @@ -1036,14 +1166,18 @@ void RibbonFormatSection::onHChanged(double v) try { Rectangle b = m_doc->getElement(m_elementId).GetBounds(); b.height = v; - m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, ElemMergeTag::H)); - } catch (...) {} + m_doc->undoStack()->push(new SetElementBoundsCmd(m_doc, m_elementId, b, mergeTag(ElemMergeTag::H))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onRotChanged(double v) { if (m_updating || m_elementId.empty()) return; - m_doc->undoStack()->push(new SetElementRotationCmd(m_doc, m_elementId, static_cast(v))); + m_doc->undoStack()->push(new SetElementRotationCmd(m_doc, m_elementId, static_cast(v), mergeTag(ElemMergeTag::Rotation))); } void RibbonFormatSection::onZOrderChanged(int v) @@ -1051,7 +1185,7 @@ void RibbonFormatSection::onZOrderChanged(int v) if (m_updating || m_elementId.empty()) return; m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, v, - [](VisualElement& e) -> int& { return e.zOrder; }, "zOrder", ElemMergeTag::ZOrder)); + [](VisualElement& e) -> int& { return e.zOrder; }, "zOrder", mergeTag(ElemMergeTag::ZOrder))); } void RibbonFormatSection::onOpacityChanged(double v) @@ -1059,7 +1193,7 @@ void RibbonFormatSection::onOpacityChanged(double v) if (m_updating || m_elementId.empty()) return; m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, static_cast(v / 100.0), - [](VisualElement& e) -> float& { return e.opacity; }, "opacity", ElemMergeTag::Opacity)); + [](VisualElement& e) -> float& { return e.opacity; }, "opacity", mergeTag(ElemMergeTag::Opacity))); } void RibbonFormatSection::onFillPaintChanged(const Paint& p) @@ -1079,7 +1213,7 @@ void RibbonFormatSection::onStrokeWidthChanged(double v) if (m_updating || m_elementId.empty()) return; m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, static_cast(v), - [](VisualElement& e) -> float& { return e.strokeWidth; }, "strokeWidth", ElemMergeTag::StrokeW)); + [](VisualElement& e) -> float& { return e.strokeWidth; }, "strokeWidth", mergeTag(ElemMergeTag::StrokeW))); } void RibbonFormatSection::onCornerRadiusChanged() @@ -1098,10 +1232,26 @@ void RibbonFormatSection::onIdEditingFinished() if (m_updating || m_elementId.empty()) return; std::string newId = m_idEdit->text().toStdString(); if (m_elementId == newId) return; - const std::string oldId = m_elementId; - m_doc->applyMutation([oldId, newId](Title& t) { - try { t.GetById(oldId).SetId(newId); } catch (...) {} - }); + + if (newId.empty()) { + m_idEdit->setText(QString::fromStdString(m_elementId)); + QToolTip::showText(m_idEdit->mapToGlobal(QPoint(0, m_idEdit->height())), + tr("ID cannot be empty."), m_idEdit); + return; + } + + for (const auto& up : m_doc->title().elements) { + if (up && up->GetId() == newId) { + m_idEdit->setText(QString::fromStdString(m_elementId)); + QToolTip::showText(m_idEdit->mapToGlobal(QPoint(0, m_idEdit->height())), + tr("An element named \"%1\" already exists.") + .arg(QString::fromStdString(newId)), + m_idEdit); + return; + } + } + + m_doc->undoStack()->push(new SetElementIdCmd(m_doc, m_elementId, newId)); m_elementId = newId; emit elementIdChanged(m_elementId); } @@ -1147,8 +1297,12 @@ void RibbonFormatSection::onShearXChanged(double v) try { VisualElement& el = m_doc->getElement(m_elementId); m_doc->undoStack()->push(new SetElementShearCmd( - m_doc, m_elementId, static_cast(v), el.GetShearY(), ElemMergeTag::ShearX)); - } catch (...) {} + m_doc, m_elementId, static_cast(v), el.GetShearY(), mergeTag(ElemMergeTag::ShearX))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onShearYChanged(double v) @@ -1157,8 +1311,12 @@ void RibbonFormatSection::onShearYChanged(double v) try { VisualElement& el = m_doc->getElement(m_elementId); m_doc->undoStack()->push(new SetElementShearCmd( - m_doc, m_elementId, el.GetShearX(), static_cast(v), ElemMergeTag::ShearY)); - } catch (...) {} + m_doc, m_elementId, el.GetShearX(), static_cast(v), mergeTag(ElemMergeTag::ShearY))); + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onShadowChanged() @@ -1172,7 +1330,11 @@ void RibbonFormatSection::onShadowChanged() sh.blur = m_spinShadowBlur->value(); m_shadowControls->setEnabled(sh.enabled); m_doc->undoStack()->push(new SetElementShadowCmd(m_doc, m_elementId, sh)); - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } } void RibbonFormatSection::onFontFamilyChanged(int index) @@ -1216,7 +1378,7 @@ void RibbonFormatSection::onFontSizeChanged(double v) m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, static_cast(v), [](VisualElement& e) -> float& { return static_cast(e).font.size; }, - "font_size", ElemMergeTag::StrokeW + 10)); + "font_size", mergeTag(ElemMergeTag::StrokeW + 10))); } void RibbonFormatSection::onFontWeightChanged(int index) @@ -1319,7 +1481,7 @@ void RibbonFormatSection::onLineSpacingChanged(double v) m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, static_cast(v), [](VisualElement& e) -> float& { return static_cast(e).textStyle.lineSpacing; }, - "line_spacing", ElemMergeTag::LineSpacing)); + "line_spacing", mergeTag(ElemMergeTag::LineSpacing))); } void RibbonFormatSection::onCharSpacingChanged(double v) @@ -1328,7 +1490,7 @@ void RibbonFormatSection::onCharSpacingChanged(double v) m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, static_cast(v), [](VisualElement& e) -> float& { return static_cast(e).textStyle.letterSpacing; }, - "letter_spacing", ElemMergeTag::CharSpacing)); + "letter_spacing", mergeTag(ElemMergeTag::CharSpacing))); } void RibbonFormatSection::onContentChanged() @@ -1342,14 +1504,38 @@ void RibbonFormatSection::onContentChanged() m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, text, [](VisualElement& e) -> std::string& { return static_cast(e).text; }, - "text")); + "text", mergeTag(ElemMergeTag::TextContent))); } else if (dynamic_cast(&el)) { m_doc->undoStack()->push(new SetElementFieldCmd( m_doc, m_elementId, text, [](VisualElement& e) -> std::string& { return static_cast(e).text; }, - "text")); + "text", mergeTag(ElemMergeTag::TextContent))); } - } catch (...) {} + } catch (const std::runtime_error&) { + // expected: element id went stale (selection cleared/deleted) + } catch (const std::exception& e) { + qWarning() << "RibbonFormatSection: unexpected exception:" << e.what(); + } +} + +bool RibbonFormatSection::updateImagePathValidity(const QString& path) +{ + if (path.isEmpty()) { + m_imagePathEdit->setStyleSheet(QString()); + m_imagePathEdit->setToolTip(QString()); + return true; + } + QFileInfo fi(path); + const bool ok = fi.exists() && fi.isFile() && QImageReader(path).canRead(); + if (ok) { + m_imagePathEdit->setStyleSheet(QString()); + m_imagePathEdit->setToolTip(path); + } else { + // Semantic warning color (functional, kept even in the dark-only theme). + m_imagePathEdit->setStyleSheet(QStringLiteral("QLineEdit { border: 1px solid #c0392b; }")); + m_imagePathEdit->setToolTip(tr("Image file not found or unreadable:\n%1").arg(path)); + } + return ok; } void RibbonFormatSection::onImagePathChanged() @@ -1360,6 +1546,12 @@ void RibbonFormatSection::onImagePathChanged() m_doc, m_elementId, path, [](VisualElement& e) -> std::string& { return static_cast(e).imagePath; }, "image_path")); + + if (!updateImagePathValidity(m_imagePathEdit->text())) { + QToolTip::showText( + m_imagePathEdit->mapToGlobal(QPoint(0, m_imagePathEdit->height())), + m_imagePathEdit->toolTip(), m_imagePathEdit); + } } void RibbonFormatSection::onScaleModeChanged(int index) diff --git a/ui/widgets/RibbonFormatSection.h b/ui/widgets/RibbonFormatSection.h index 222fae0..cf55441 100644 --- a/ui/widgets/RibbonFormatSection.h +++ b/ui/widgets/RibbonFormatSection.h @@ -2,7 +2,9 @@ #include "engine/types.hpp" #include +#include #include +#include #include class TitleDocument; @@ -106,10 +108,24 @@ private slots: void populateRefCombos(); void loadFonts(); + // Updates m_imagePathEdit's warning visual state for the given path. + // Empty path = neutral (no styling). Non-empty but missing/unreadable = + // red border + warning tooltip. Valid = cleared styling, tooltip = the path. + // Returns true if the path is empty or valid, false if invalid. + bool updateImagePathValidity(const QString& path); + + // Folds m_mergeGen into `base` and restarts the idle timer. Used to bound + // how long consecutive spinbox/text edits merge into a single undo step: + // a pause > 600ms bumps m_mergeGen, starting a fresh undo entry. + int mergeTag(int base); + TitleDocument* m_doc{nullptr}; std::string m_elementId; bool m_updating{false}; + int m_mergeGen{0}; + QTimer* m_mergeIdleTimer{nullptr}; + SARibbonCategory* m_elemCategory{nullptr}; SARibbonCategory* m_styleCategory{nullptr}; SARibbonCategory* m_textCategory{nullptr}; @@ -202,6 +218,11 @@ private slots: QPlainTextEdit* m_contentEdit{nullptr}; int m_contentSavedCursor{0}; + // Focus restoration for non-modal Qt::Tool popup dialogs + QPointer m_focusBeforeFillGradDlg; + QPointer m_focusBeforeStrokeGradDlg; + QPointer m_focusBeforeContentDlg; + // Image tab QLineEdit* m_imagePathEdit{nullptr}; QComboBox* m_scaleMode{nullptr}; diff --git a/ui/widgets/ScrubRuler.cpp b/ui/widgets/ScrubRuler.cpp deleted file mode 100644 index f9c53c8..0000000 --- a/ui/widgets/ScrubRuler.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "ScrubRuler.h" -#include -#include -#include - -ScrubRuler::ScrubRuler(QWidget* parent) : QWidget{parent} -{ - setFixedHeight(20); - setCursor(Qt::SizeHorCursor); -} - -void ScrubRuler::setMaxDuration(float maxDuration) -{ - m_maxDuration = qMax(maxDuration, 0.01f); - update(); -} - -void ScrubRuler::setScrubTime(float t) -{ - m_scrubTime = qBound(0.0f, t, m_maxDuration); - update(); -} - -float ScrubRuler::timeAtX(int x) const -{ - return qBound(0.0f, float(x) / float(qMax(width(), 1)) * m_maxDuration, m_maxDuration); -} - -void ScrubRuler::paintEvent(QPaintEvent*) -{ - QPainter p(this); - const int w = width(); - const int h = height(); - - p.fillRect(rect(), QColor(22, 22, 22)); - - // --- ticks --- - // Choose a minor step so major ticks (every 5th) land ~50px apart. - static const float kSteps[] = {0.05f, 0.1f, 0.25f, 0.5f, 1.0f, 2.0f, 5.0f, 10.0f}; - const float pps = float(w) / m_maxDuration; - float minorStep = 10.0f; - for (float s : kSteps) { - if (pps * s * 5.0f >= 50.0f) { minorStep = s; break; } - } - - const int nSteps = int(m_maxDuration / minorStep) + 2; - for (int k = 0; k <= nSteps; ++k) { - const float t = k * minorStep; - if (t > m_maxDuration + 1e-4f) break; - const int x = int(t / m_maxDuration * w); - const bool isMajor = (k % 5 == 0); - - if (isMajor) { - p.setPen(QColor(130, 130, 130)); - p.drawLine(x, h - 9, x, h); - QFont f = p.font(); - f.setPointSize(7); - p.setFont(f); - p.drawText(QRect(x + 2, 0, 40, h - 7), - Qt::AlignLeft | Qt::AlignVCenter, - QString("%1s").arg(double(t), 0, 'f', t < 1.0f ? 2 : 1)); - } else { - p.setPen(QColor(65, 65, 65)); - p.drawLine(x, h - 4, x, h); - } - } - - // --- playhead --- - const int cx = int(m_scrubTime / m_maxDuration * w); - p.setPen(QPen(QColor(255, 200, 50), 2.5)); - p.drawLine(cx, 0, cx, h); - - p.setPen(Qt::NoPen); - p.setBrush(QColor(255, 200, 50)); - const QPoint tri[3] = { {cx - 6, 0}, {cx + 6, 0}, {cx, 10} }; - p.drawPolygon(tri, 3); -} - -void ScrubRuler::mousePressEvent(QMouseEvent* event) -{ - if (event->button() != Qt::LeftButton) return; - m_scrubTime = timeAtX(event->pos().x()); - update(); - emit scrubChanged(m_scrubTime); - event->accept(); -} - -void ScrubRuler::mouseMoveEvent(QMouseEvent* event) -{ - if (!(event->buttons() & Qt::LeftButton)) return; - m_scrubTime = timeAtX(event->pos().x()); - update(); - emit scrubChanged(m_scrubTime); - event->accept(); -} diff --git a/ui/widgets/ScrubRuler.h b/ui/widgets/ScrubRuler.h deleted file mode 100644 index 03c3e83..0000000 --- a/ui/widgets/ScrubRuler.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include - -class ScrubRuler : public QWidget { - Q_OBJECT -public: - explicit ScrubRuler(QWidget* parent = nullptr); - - void setMaxDuration(float maxDuration); - void setScrubTime(float t); - -signals: - void scrubChanged(float t); - -protected: - void paintEvent(QPaintEvent*) override; - void mousePressEvent(QMouseEvent*) override; - void mouseMoveEvent(QMouseEvent*) override; - -private: - float timeAtX(int x) const; - - float m_maxDuration{1.0f}; - float m_scrubTime{0.0f}; -}; diff --git a/ui/widgets/SelectionHandles.cpp b/ui/widgets/SelectionHandles.cpp index d398c14..c91c5fe 100644 --- a/ui/widgets/SelectionHandles.cpp +++ b/ui/widgets/SelectionHandles.cpp @@ -3,6 +3,9 @@ #include #include #include +#include + +#include // Handle order: TL=0, TC=1, TR=2, ML=3, MR=4, BL=5, BC=6, BR=7 @@ -37,37 +40,109 @@ static QPointF handleCenter(int idx, const QRectF& r) } } -QRectF SelectionHandles::handleRect(int handleIndex, const QRectF& elementInWidget) +// --- Transform-based (rotation/shear-aware) overloads --- + +QPointF SelectionHandles::handleCenterT(int idx, const QTransform& xf, const QRectF& localRect) +{ + if (idx == kRotateHandle) { + const QPointF topC = xf.map(QPointF(localRect.center().x(), localRect.top())); + const QPointF center = xf.map(localRect.center()); + QPointF up = topC - center; + const double len = std::hypot(up.x(), up.y()); + const QPointF n = (len > 1e-6) ? QPointF(up.x() / len, up.y() / len) : QPointF(0.0, -1.0); + return topC + n * kRotateGap; + } + + return xf.map(handleCenter(idx, localRect)); +} + +QRectF SelectionHandles::handleRect(int handleIndex, const QTransform& xf, const QRectF& localRect) { const double half = kHandleSize / 2.0; - QPointF c = handleCenter(handleIndex, elementInWidget); + const QPointF c = handleCenterT(handleIndex, xf, localRect); return {c.x() - half, c.y() - half, static_cast(kHandleSize), static_cast(kHandleSize)}; } -int SelectionHandles::hitTest(QPointF widgetPt, const QRectF& elementInWidget) +int SelectionHandles::hitTest(QPointF widgetPt, const QTransform& xf, const QRectF& localRect) { + int best = -1; + double bestDist = 0.0; + for (int i = 0; i < kHandleCount; ++i) { - if (handleRect(i, elementInWidget).contains(widgetPt)) - return i; + const QRectF r = handleRect(i, xf, localRect) + .adjusted(-kHitSlack, -kHitSlack, kHitSlack, kHitSlack); + if (!r.contains(widgetPt)) + continue; + const QPointF c = handleCenterT(i, xf, localRect); + const double dx = widgetPt.x() - c.x(); + const double dy = widgetPt.y() - c.y(); + const double d = dx * dx + dy * dy; + if (best < 0 || d < bestDist) { + best = i; + bestDist = d; + } } - return -1; + + // Rotate handle + { + const QPointF c = handleCenterT(kRotateHandle, xf, localRect); + const double dx = widgetPt.x() - c.x(); + const double dy = widgetPt.y() - c.y(); + const double d = dx * dx + dy * dy; + const double threshold = kRotateRadius + kHitSlack; + if (d <= threshold * threshold) { + if (best < 0 || d < bestDist) { + best = kRotateHandle; + bestDist = d; + } + } + } + + return best; } -void SelectionHandles::draw(QPainter& p, const QRectF& elementInWidget, int activeHandle, - QColor highlight) +void SelectionHandles::draw(QPainter& p, const QTransform& xf, const QRectF& localRect, + int activeHandle, QColor highlight) { p.save(); - p.setRenderHint(QPainter::Antialiasing, false); - // Selection border — shadow pass then white pass + // Selection border — mapped quad, drawn with AA so rotated edges aren't jagged. + p.setRenderHint(QPainter::Antialiasing, true); + + QPolygonF poly; + poly << xf.map(localRect.topLeft()) << xf.map(localRect.topRight()) + << xf.map(localRect.bottomRight()) << xf.map(localRect.bottomLeft()); + p.setBrush(Qt::NoBrush); p.setPen(QPen(QColor(0, 0, 0, 120), 3.0)); - p.drawRect(elementInWidget); + p.drawPolygon(poly); + p.setPen(QPen(Qt::white, 1.0)); + p.drawPolygon(poly); + + // Rotate handle connector + knob (AA on, matches the border style). + const QPointF topC = xf.map(QPointF(localRect.center().x(), localRect.top())); + const QPointF knob = handleCenterT(kRotateHandle, xf, localRect); + + p.setPen(QPen(QColor(0, 0, 0, 100), 3.0)); + p.drawLine(topC, knob); p.setPen(QPen(Qt::white, 1.0)); - p.drawRect(elementInWidget); + p.drawLine(topC, knob); + + { + const bool isActive = (activeHandle == kRotateHandle); + p.setPen(QPen(QColor(0, 0, 0, 100), 5.0)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(knob, kRotateRadius, kRotateRadius); + + // Green fill so the rotate knob reads distinctly from the resize handles. + p.setBrush(isActive ? QColor(120, 240, 150) : QColor(52, 199, 89)); + p.setPen(QPen(Qt::white, 1.5)); + p.drawEllipse(knob, kRotateRadius, kRotateRadius); + } - // Handles — two passes so the active handle is drawn on top + // Handles 0-7 — circles (AA on) to match the rotate knob. + p.setRenderHint(QPainter::Antialiasing, true); for (int pass = 0; pass < 2; ++pass) { for (int i = 0; i < kHandleCount; ++i) { const bool isActive = (i == activeHandle); @@ -76,17 +151,15 @@ void SelectionHandles::draw(QPainter& p, const QRectF& elementInWidget, int acti if (pass == 1 && !isActive) continue; - const QRectF r = handleRect(i, elementInWidget); + const QRectF r = handleRect(i, xf, localRect); - // Shadow: wide dark stroke so handles pop on light backgrounds p.setPen(QPen(QColor(0, 0, 0, 100), 5.0)); p.setBrush(Qt::NoBrush); - p.drawRect(r); + p.drawEllipse(r); - // Fill and border p.setBrush(isActive ? highlight : Qt::white); p.setPen(QPen(Qt::white, 1.5)); - p.drawRect(r); + p.drawEllipse(r); } } diff --git a/ui/widgets/SelectionHandles.h b/ui/widgets/SelectionHandles.h index d3c5913..1105fc7 100644 --- a/ui/widgets/SelectionHandles.h +++ b/ui/widgets/SelectionHandles.h @@ -3,6 +3,7 @@ #include #include #include +#include class QPainter; @@ -12,17 +13,28 @@ class QPainter; struct SelectionHandles { static constexpr int kHandleSize = 8; static constexpr int kHandleCount = 8; + static constexpr int kHitSlack = 4; // extra px around each handle for hit-testing + static constexpr int kRotateHandle = 8; // index of the rotate handle + static constexpr double kRotateGap = 24.0; // widget px from top edge to rotate knob + static constexpr double kRotateRadius = 6.0; - // Return the rect (in widget coordinates) for a given handle index, - // computed from the element's bounding rect already in widget coords. - static QRectF handleRect(int handleIndex, const QRectF& elementInWidget); + // --- Transform-based (rotation/shear-aware) overloads --- + // `xf` maps element-local coordinates to widget coordinates. `localRect` is the + // element's rect in local coordinates, i.e. QRectF(0, 0, width, height). - // Return handle index (0-7) if widgetPt hits a handle, -1 otherwise. - static int hitTest(QPointF widgetPt, const QRectF& elementInWidget); + // Widget-space center of handle `idx` (0-7 for the corner/edge handles, or + // kRotateHandle for the rotate knob), given the local->widget transform. + static QPointF handleCenterT(int idx, const QTransform& xf, const QRectF& localRect); - // Draw all 8 handles and the selection border rectangle on the painter. - // activeHandle: index of the handle currently being dragged (-1 = none). - // highlight: accent color for the active handle (pass palette().highlight().color()). - static void draw(QPainter& p, const QRectF& elementInWidget, int activeHandle = -1, - QColor highlight = QColor(0, 120, 215)); + // Widget-space rect for handle indices 0-7 only (rotate handle has no rect; + // use handleCenterT + kRotateRadius for its geometry). + static QRectF handleRect(int handleIndex, const QTransform& xf, const QRectF& localRect); + + // Return handle index (0-7 or kRotateHandle) if widgetPt hits a handle, -1 otherwise. + static int hitTest(QPointF widgetPt, const QTransform& xf, const QRectF& localRect); + + // Draw the selection border (as a rotated/sheared quad), the 8 corner/edge + // handles, and the rotate handle, all mapped through `xf`. + static void draw(QPainter& p, const QTransform& xf, const QRectF& localRect, + int activeHandle = -1, QColor highlight = QColor(0, 120, 215)); }; diff --git a/ui/widgets/TitleTreeModel.cpp b/ui/widgets/TitleTreeModel.cpp index 30fd308..8fc9c7f 100644 --- a/ui/widgets/TitleTreeModel.cpp +++ b/ui/widgets/TitleTreeModel.cpp @@ -5,6 +5,9 @@ #include #include +#include +#include + #include "engine/element_image.h" #include "engine/element_qr.h" #include "engine/element_text.h" @@ -13,13 +16,49 @@ #include "model/TitleDocument.h" #include "model/UndoCommands.h" +namespace { +// A dimmed variant of a themed icon, used for the "unlocked" affordance so it +// reads as a subtle hint rather than competing visually with the lock icon. +QIcon fadedIcon(Icons16 id, qreal opacity) +{ + const QIcon base = themedIcon(id); + const QPixmap src = base.pixmap(16, 16); + QPixmap faded(src.size()); + faded.fill(Qt::transparent); + QPainter p(&faded); + p.setOpacity(opacity); + p.drawPixmap(0, 0, src); + p.end(); + return QIcon(faded); +} +} // namespace + TitleTreeModel::TitleTreeModel(TitleDocument* doc, QObject* parent) : QAbstractItemModel(parent), m_doc(doc) { - connect(m_doc, &TitleDocument::documentChanged, this, [this]() { + connect(m_doc, &TitleDocument::documentChanged, this, &TitleTreeModel::onDocumentChanged); +} + +void TitleTreeModel::onDocumentChanged() +{ + if (m_suppressResets) { + m_resetPending = true; + return; + } + beginResetModel(); + endResetModel(); +} + +void TitleTreeModel::setResetsSuppressed(bool suppressed) +{ + if (m_suppressResets == suppressed) + return; + m_suppressResets = suppressed; + if (!suppressed && m_resetPending) { + m_resetPending = false; beginResetModel(); endResetModel(); - }); + } } // ── Tree helpers ────────────────────────────────────────────────────────────── @@ -143,7 +182,7 @@ int TitleTreeModel::rowCount(const QModelIndex& parent) const int TitleTreeModel::columnCount(const QModelIndex& /*parent*/) const { - return 1; + return 2; } QVariant TitleTreeModel::data(const QModelIndex& index, int role) const @@ -153,7 +192,21 @@ QVariant TitleTreeModel::data(const QModelIndex& index, int role) const quintptr id = index.internalId(); quintptr level = levelOf(id); - if (role == Qt::DisplayRole) { + // Column 1 is the lock column: decoration only, no text, on element rows. + if (index.column() == 1) { + if (role == Qt::DecorationRole && level == LEVEL_ELEMENT) { + int ei = eiOf(id); + const Title& t = m_doc->title(); + if (ei < 1 || ei >= static_cast(t.elements.size())) return {}; + const std::string elId = t.elements[ei]->GetId(); + if (m_doc->isElementLocked(elId)) + return themedIcon(Icons16::Action_Lock); + return fadedIcon(Icons16::Action_Unlock, 0.35); + } + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::EditRole) { if (level == LEVEL_SCENE) return m_doc->titleName(); @@ -187,16 +240,64 @@ QVariant TitleTreeModel::data(const QModelIndex& index, int role) const return {}; } +bool TitleTreeModel::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (!index.isValid() || role != Qt::EditRole) + return false; + + quintptr id = index.internalId(); + if (levelOf(id) != LEVEL_ELEMENT) + return false; + + int ei = eiOf(id); + const Title& t = m_doc->title(); + if (ei < 1 || ei >= static_cast(t.elements.size())) + return false; + + const std::string oldId = t.elements[ei]->GetId(); + const std::string newId = value.toString().trimmed().toStdString(); + + if (newId.empty()) + return false; + if (newId == oldId) + return true; + + for (const auto& up : t.elements) { + if (up && up->GetId() == newId) + return false; + } + + m_doc->undoStack()->push(new SetElementIdCmd(m_doc, oldId, newId)); + return true; +} + Qt::ItemFlags TitleTreeModel::flags(const QModelIndex& index) const { if (!index.isValid()) return Qt::ItemIsDropEnabled; quintptr id = index.internalId(); + + // Column 1 (lock column): clickable via the view, but not selectable, + // editable, or a drag/drop target — it's a standalone icon toggle. + if (index.column() == 1) { + if (levelOf(id) == LEVEL_ELEMENT) + return Qt::ItemIsEnabled; + return Qt::NoItemFlags; + } + Qt::ItemFlags f = Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDropEnabled; - if (levelOf(id) == LEVEL_ELEMENT) + if (levelOf(id) == LEVEL_ELEMENT) { f |= Qt::ItemIsDragEnabled; + int ei = eiOf(id); + const Title& t = m_doc->title(); + if (ei >= 1 && ei < static_cast(t.elements.size()) && + t.elements[ei]->GetChildren().empty()) { + f |= Qt::ItemIsEditable; + } + } + return f; } diff --git a/ui/widgets/TitleTreeModel.h b/ui/widgets/TitleTreeModel.h index 5fea854..c95985a 100644 --- a/ui/widgets/TitleTreeModel.h +++ b/ui/widgets/TitleTreeModel.h @@ -20,6 +20,7 @@ class TitleTreeModel : public QAbstractItemModel { int rowCount(const QModelIndex& parent = QModelIndex()) const override; int columnCount(const QModelIndex& parent = QModelIndex()) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; Qt::ItemFlags flags(const QModelIndex& index) const override; // Drag-and-drop @@ -33,8 +34,14 @@ class TitleTreeModel : public QAbstractItemModel { SelectionId selectionIdFor(const QModelIndex& index) const; QModelIndex indexForSelection(const SelectionId& sel) const; + void setResetsSuppressed(bool suppressed); + private: + void onDocumentChanged(); + TitleDocument* m_doc; + bool m_suppressResets{false}; + bool m_resetPending{false}; // Returns indices into title.elements of direct children of parentEi // (parentEi == -1 means root). Sorted descending by zOrder (highest = row 0). diff --git a/ui/widgets/TitleTreeView.cpp b/ui/widgets/TitleTreeView.cpp index 80aa627..893a693 100644 --- a/ui/widgets/TitleTreeView.cpp +++ b/ui/widgets/TitleTreeView.cpp @@ -1,5 +1,6 @@ #include "TitleTreeView.h" #include "TitleTreeModel.h" +#include "ZOrderOps.h" #include "engine/title.h" #include "model/EditorTitle.h" @@ -8,9 +9,13 @@ #include #include +#include #include #include #include +#include + +#include TitleTreeView::TitleTreeView(TitleDocument* doc, EditorTitle* editorTitle, QWidget* parent) : QTreeView(parent), m_doc(doc), m_editorTitle(editorTitle), @@ -22,46 +27,121 @@ TitleTreeView::TitleTreeView(TitleDocument* doc, EditorTitle* editorTitle, QWidg setAcceptDrops(true); setDragDropMode(QAbstractItemView::InternalMove); setDefaultDropAction(Qt::MoveAction); - setSelectionMode(QAbstractItemView::SingleSelection); + setSelectionMode(QAbstractItemView::ExtendedSelection); + setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); + + // Column 1 is the narrow, fixed-width lock column; column 0 (name) stretches. + header()->setStretchLastSection(false); + header()->setSectionResizeMode(0, QHeaderView::Stretch); + header()->setSectionResizeMode(1, QHeaderView::Fixed); + setColumnWidth(1, 28); expandAll(); - connect(m_model, &QAbstractItemModel::modelReset, this, [this]() { expandAll(); }); + connect(m_model, &QAbstractItemModel::modelReset, this, [this]() { + expandAll(); + onEditorSelectionSetChanged(); + }); connect(selectionModel(), &QItemSelectionModel::selectionChanged, this, &TitleTreeView::onViewSelectionChanged); - connect(m_editorTitle, &EditorTitle::selectionChanged, this, - &TitleTreeView::onEditorSelectionChanged); + connect(m_editorTitle, &EditorTitle::selectionSetChanged, this, + &TitleTreeView::onEditorSelectionSetChanged); +} + +void TitleTreeView::mousePressEvent(QMouseEvent* event) +{ + // A left-click in the lock column toggles that element's locked state + // WITHOUT disturbing the row selection. Column 1 is non-selectable, so + // letting the base view process the click would issue a ClearAndSelect + // against a non-selectable index and drop the current selection. + if (event->button() == Qt::LeftButton) { + const QModelIndex idx = indexAt(event->pos()); + if (idx.isValid() && idx.column() == 1) { + const SelectionId sid = m_model->selectionIdFor(idx); + if (sid.level == SelectionId::Level::Element) { + const Title& t = m_doc->title(); + if (sid.elementIndex >= 1 && sid.elementIndex < (int)t.elements.size()) { + const std::string id = t.elements[sid.elementIndex]->GetId(); + m_doc->setElementLocked(id, !m_doc->isElementLocked(id)); + } + } + event->accept(); + return; + } + } + QTreeView::mousePressEvent(event); +} + +void TitleTreeView::setResetsSuppressed(bool suppressed) +{ + m_model->setResetsSuppressed(suppressed); } -void TitleTreeView::onViewSelectionChanged(const QItemSelection& selected, +void TitleTreeView::onViewSelectionChanged(const QItemSelection& /*selected*/, const QItemSelection& /*deselected*/) { if (m_syncingSelection) return; m_syncingSelection = true; - QModelIndexList indexes = selected.indexes(); - if (indexes.isEmpty()) { - m_editorTitle->setSelection(SelectionId{}); + const QModelIndexList idxs = selectionModel()->selectedIndexes(); + std::vector elems; + for (const QModelIndex& idx : idxs) { + const SelectionId sid = m_model->selectionIdFor(idx); + if (sid.level == SelectionId::Level::Element) + elems.push_back(sid.elementIndex); + } + + if (elems.empty()) { + // No element rows selected: fall back to Title/None single behaviour. + if (idxs.isEmpty()) + m_editorTitle->setSelection(SelectionId{}); + else + m_editorTitle->setSelection(m_model->selectionIdFor(idxs.first())); } else { - SelectionId sid = m_model->selectionIdFor(indexes.first()); - m_editorTitle->setSelection(sid); + // Active = the current row if it is one of the selected elements, else the last. + int active = elems.back(); + const SelectionId curSid = m_model->selectionIdFor(currentIndex()); + if (curSid.level == SelectionId::Level::Element && + std::find(elems.begin(), elems.end(), curSid.elementIndex) != elems.end()) + active = curSid.elementIndex; + m_editorTitle->setMultiSelection(elems, active); } m_syncingSelection = false; } -void TitleTreeView::onEditorSelectionChanged(SelectionId id) +void TitleTreeView::onEditorSelectionSetChanged() { if (m_syncingSelection) return; m_syncingSelection = true; - QModelIndex idx = m_model->indexForSelection(id); - if (idx.isValid()) { - selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect); - scrollTo(idx); + const std::vector elems = m_editorTitle->selectedIndices(); + if (elems.empty()) { + // Title-level or None: preserve prior single-row behaviour. + const QModelIndex idx = m_model->indexForSelection(m_editorTitle->selection()); + if (idx.isValid()) { + selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect); + scrollTo(idx); + } else { + selectionModel()->clearSelection(); + } } else { - selectionModel()->clearSelection(); + QItemSelection newSel; + for (int ei : elems) { + const QModelIndex idx = m_model->indexForSelection({SelectionId::Level::Element, ei}); + if (idx.isValid()) newSel.select(idx, idx); + } + selectionModel()->select(newSel, QItemSelectionModel::ClearAndSelect); + // Set the current/anchor row to the active element and scroll it into view. + const SelectionId active = m_editorTitle->selection(); + if (active.level == SelectionId::Level::Element) { + const QModelIndex aidx = m_model->indexForSelection(active); + if (aidx.isValid()) { + selectionModel()->setCurrentIndex(aidx, QItemSelectionModel::NoUpdate); + scrollTo(aidx); + } + } } m_syncingSelection = false; @@ -123,16 +203,84 @@ void TitleTreeView::contextMenuEvent(QContextMenuEvent* event) menu.addSeparator(); - // ── Remove ─────────────────────────────────────────────────────────────── + // ── Order ──────────────────────────────────────────────────────────────── + { + // Sibling count computed the same way TitleTreeModel::childrenOf does: + // direct children of the same parent, VisualElements only. + int siblingCount = 0; + if (elementSelected && sel.elementIndex >= 1 && + sel.elementIndex < (int)m_doc->title().elements.size()) { + const IElement* activeEl = m_doc->title().elements[sel.elementIndex].get(); + if (const IElement* parentEl = activeEl->GetParent()) { + for (const IElement* child : parentEl->GetChildren()) + if (dynamic_cast(child)) + ++siblingCount; + } + } + const bool canReorder = elementSelected && siblingCount >= 2; + + QMenu* orderMenu = menu.addMenu("Order"); + orderMenu->setEnabled(canReorder); + + auto addOrderAction = [this, orderMenu](const QString& label, zorderops::ReorderOp op) { + QAction* a = orderMenu->addAction(label); + connect(a, &QAction::triggered, this, [this, op]() { + const SelectionId s = m_editorTitle->selection(); + if (s.level != SelectionId::Level::Element) return; + const Title& t = m_doc->title(); + if (s.elementIndex < 1 || s.elementIndex >= (int)t.elements.size()) return; + zorderops::applyReorder(m_doc, t.elements[s.elementIndex].get(), op); + }); + }; + addOrderAction("Bring to Front", zorderops::ReorderOp::ToFront); + addOrderAction("Bring Forward", zorderops::ReorderOp::Forward); + addOrderAction("Send Backward", zorderops::ReorderOp::Backward); + addOrderAction("Send to Back", zorderops::ReorderOp::ToBack); + } + + menu.addSeparator(); + + // ── Duplicate / Remove ─────────────────────────────────────────────────── + QAction* duplicateAction = menu.addAction("Duplicate"); + duplicateAction->setEnabled(elementSelected); + connect(duplicateAction, &QAction::triggered, this, [this]() { emit duplicateRequested(); }); + QAction* removeAction = menu.addAction("Remove"); removeAction->setEnabled(elementSelected); - connect(removeAction, &QAction::triggered, this, [this, sel]() { - if (sel.level != SelectionId::Level::Element) return; + connect(removeAction, &QAction::triggered, this, [this]() { + const std::vector indices = m_editorTitle->selectedIndices(); + if (indices.empty()) return; const Title& t = m_doc->title(); - if (sel.elementIndex < 1 || sel.elementIndex >= (int)t.elements.size()) return; - const std::string ei = t.elements[sel.elementIndex]->GetId(); - m_doc->undoStack()->push(new RemoveElementCmd(m_doc, ei)); + std::vector ids; + for (int i : indices) + if (i >= 1 && i < (int)t.elements.size()) + ids.push_back(t.elements[i]->GetId()); + if (ids.empty()) return; + if (ids.size() == 1) { + m_doc->undoStack()->push(new RemoveElementCmd(m_doc, ids[0])); + } else { + m_doc->undoStack()->beginMacro("Delete elements"); + for (const auto& id : ids) m_doc->undoStack()->push(new RemoveElementCmd(m_doc, id)); + m_doc->undoStack()->endMacro(); + } }); + menu.addSeparator(); + + // ── Clipboard ──────────────────────────────────────────────────────────── + QAction* cutAction = menu.addAction("Cut"); + cutAction->setEnabled(elementSelected); + connect(cutAction, &QAction::triggered, this, [this]() { emit cutRequested(); }); + + QAction* copyAction = menu.addAction("Copy"); + copyAction->setEnabled(elementSelected); + connect(copyAction, &QAction::triggered, this, [this]() { emit copyRequested(); }); + + QAction* pasteAction = menu.addAction("Paste"); + connect(pasteAction, &QAction::triggered, this, [this]() { emit pasteRequested(); }); + + QAction* pasteInPlaceAction = menu.addAction("Paste in Place"); + connect(pasteInPlaceAction, &QAction::triggered, this, [this]() { emit pasteInPlaceRequested(); }); + menu.exec(event->globalPos()); } diff --git a/ui/widgets/TitleTreeView.h b/ui/widgets/TitleTreeView.h index 7c17a3c..87be850 100644 --- a/ui/widgets/TitleTreeView.h +++ b/ui/widgets/TitleTreeView.h @@ -13,13 +13,23 @@ class TitleTreeView : public QTreeView { public: explicit TitleTreeView(TitleDocument* doc, EditorTitle* editorTitle, QWidget* parent = nullptr); + void setResetsSuppressed(bool suppressed); + +signals: + void duplicateRequested(); + void cutRequested(); + void copyRequested(); + void pasteRequested(); + void pasteInPlaceRequested(); + protected: bool event(QEvent* e) override; void contextMenuEvent(QContextMenuEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; private slots: void onViewSelectionChanged(const QItemSelection& selected, const QItemSelection& deselected); - void onEditorSelectionChanged(SelectionId id); + void onEditorSelectionSetChanged(); private: TitleDocument* m_doc; diff --git a/ui/widgets/ZOrderOps.h b/ui/widgets/ZOrderOps.h new file mode 100644 index 0000000..7e08c83 --- /dev/null +++ b/ui/widgets/ZOrderOps.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +#include +#include + +#include "engine/element.h" +#include "engine/visual_element.h" +#include "model/TitleDocument.h" +#include "model/UndoCommands.h" + +namespace zorderops { + +enum class ReorderOp { ToFront, Forward, Backward, ToBack }; + +inline QString labelFor(ReorderOp op) +{ + switch (op) { + case ReorderOp::ToFront: return QStringLiteral("Bring to Front"); + case ReorderOp::Forward: return QStringLiteral("Bring Forward"); + case ReorderOp::Backward: return QStringLiteral("Send Backward"); + case ReorderOp::ToBack: return QStringLiteral("Send to Back"); + } + return {}; +} + +// Applies a reorder to `target` among its siblings, pushing one undo macro of +// SetElementFieldCmd zOrder updates (or nothing, if the op is a no-op). +inline void applyReorder(TitleDocument* doc, const IElement* target, ReorderOp op) +{ + if (!doc || !target) return; + + IElement* parentEl = target->GetParent(); + if (!parentEl) return; + + // Gather target's siblings the SAME way TitleTreeModel::childrenOf does + // (keep sibling definition in sync with TitleTreeModel::childrenOf): + // direct children of the same parent, VisualElements only, sorted + // descending by zOrder so index 0 = front (topmost). + std::vector siblings; + for (const IElement* child : parentEl->GetChildren()) { + if (dynamic_cast(child)) + siblings.push_back(child); + } + std::stable_sort(siblings.begin(), siblings.end(), [](const IElement* a, const IElement* b) { + const auto* va = dynamic_cast(a); + const auto* vb = dynamic_cast(b); + int za = va ? va->zOrder : 0; + int zb = vb ? vb->zOrder : 0; + return za > zb; + }); + + const int n = static_cast(siblings.size()); + int r = -1; + for (int i = 0; i < n; ++i) + if (siblings[i] == target) { r = i; break; } + if (r < 0 || n < 2) return; + + int newRow = r; + switch (op) { + case ReorderOp::ToFront: newRow = 0; break; + case ReorderOp::ToBack: newRow = n - 1; break; + case ReorderOp::Forward: newRow = std::max(0, r - 1); break; + case ReorderOp::Backward: newRow = std::min(n - 1, r + 1); break; + } + if (newRow == r) return; + + siblings.erase(siblings.begin() + r); + siblings.insert(siblings.begin() + newRow, target); + + auto* macro = new QUndoCommand(labelFor(op)); + for (int k = 0; k < n; ++k) { + const auto* ve = dynamic_cast(siblings[k]); + if (!ve) continue; + const int newZ = n - 1 - k; + if (ve->zOrder != newZ) { + new SetElementFieldCmd( + doc, siblings[k]->GetId(), newZ, + [](VisualElement& e) -> int& { return e.zOrder; }, + "zOrder", -1, macro); + } + } + + if (macro->childCount() == 0) { delete macro; return; } + doc->undoStack()->push(macro); +} + +} // namespace zorderops