diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dac21a5a9..7ca06f10f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,10 +7,13 @@ run-name: ${{ github.event.release.tag_name || github.ref_name }} Web on: release: types: [published] + discussion: + types: [created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, unanswered] workflow_dispatch: permissions: contents: read + discussions: read pages: write id-token: write @@ -79,6 +82,58 @@ jobs: > dist/supporters.json || echo '[]' > dist/supporters.json echo "supporters: $(jq 'length' dist/supporters.json)" + # GitHub's Discussions API is authenticated GraphQL. Generate a public, + # token-free snapshot next to the web app; discussion activity (including + # pin/unpin) triggers this workflow so pinned entries stay at the top. + - name: Generate discussions.json + env: + GH_TOKEN: ${{ github.token }} + run: | + QUERY=' + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pinnedDiscussions(first: 10) { + nodes { + discussion { + number title url updatedAt + author { login } + } + } + } + discussions(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url updatedAt + author { login } + } + } + } + }' + gh api graphql \ + -f query="$QUERY" \ + -F owner="$GITHUB_REPOSITORY_OWNER" \ + -F name="${GITHUB_REPOSITORY#*/}" \ + | jq -c ' + .data.repository as $repo + | [$repo.pinnedDiscussions.nodes[].discussion.number] as $pinned + | ( + [$repo.pinnedDiscussions.nodes[].discussion + {pinned: true}] + + [ + $repo.discussions.nodes[] + | select((.number as $number | $pinned | index($number)) == null) + | . + {pinned: false} + ] + ) + | map({ + number, + title, + url, + author: (.author.login // ""), + updated_at: .updatedAt, + pinned + })' \ + > dist/discussions.json + echo "discussions: $(jq 'length' dist/discussions.json)" + - uses: actions/upload-pages-artifact@v3 with: path: dist diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b408576ea..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,4 +0,0 @@ -# Agent Instructions - -- Never run `cargo fmt`, `rustfmt`, or any automatic source-formatting command in this repository. -- Preserve the existing formatting and make only targeted edits. diff --git a/COMMANDS.md b/COMMANDS.md deleted file mode 100644 index 780c90ec9..000000000 --- a/COMMANDS.md +++ /dev/null @@ -1,371 +0,0 @@ -# Open CAD Studio β€” Command Reference - -Status of every standard CAD command in Open CAD Studio: - -- βœ… **Implemented** β€” fully working -- πŸ”Ά **Partial** β€” command is accepted but logic is a stub -- ❌ **Missing** β€” not yet implemented - ---- - -## Draw - -| Command | Alias | Description | Status | -|---|---|---|---| -| `LINE` | L | Straight line segment | βœ… | -| `PLINE` | PL | Polyline | βœ… | -| `ARC` | A | Arc | βœ… | -| `CIRCLE` | C | Circle | βœ… | -| `ELLIPSE` | EL | Ellipse | βœ… | -| `RECTANGLE` | REC | Rectangle | βœ… | -| `POLYGON` | POL | Regular polygon | βœ… | -| `XLINE` | XL | Infinite construction line | βœ… | -| `RAY` | β€” | One-way infinite line | βœ… | -| `SPLINE` | SPL | NURBS spline | βœ… | -| `SPLINEFIT` | FITSPLINE | Fit a spline through a polyline's points | βœ… | -| `MLINE` | ML | Multiline | βœ… | -| `POINT` | PO | Point | βœ… | -| `DONUT` | DO | Filled ring | βœ… | -| `HATCH` | H | Hatch fill | βœ… | -| `GRADIENT` | GD | Gradient fill | βœ… | -| `BOUNDARY` | BO | Boundary polyline / region | βœ… | -| `REVCLOUD` | β€” | Revision cloud | βœ… | -| `WIPEOUT` | WO | Wipeout mask | βœ… | -| `MTEXT` | MT | Multiline text | βœ… | -| `TEXT` | DT | Single-line text | βœ… | -| `TABLE` | β€” | Table entity | βœ… | -| `DIVIDE` | DIV | Divide entity into equal parts | βœ… | -| `MEASURE` | ME | Divide entity at measured intervals | βœ… | -| `3DPOLY` | β€” | 3D polyline | βœ… | -| `HELIX` | β€” | 3D helix | βœ… | -| `TRACE` | β€” | Thick 2D line (legacy) | βœ… | -| `SKETCH` | β€” | Freehand sketch | βœ… | -| `SOLID` | SO | Filled 2D shape (legacy) | βœ… | -| `MINSERT` | β€” | Matrix block insert | βœ… | -| `REGION` | REG | 2D closed region | βœ… | -| `FIELD` | β€” | Auto-updating text field | ❌ | - ---- - -## Modify - -| Command | Alias | Description | Status | -|---|---|---|---| -| `MOVE` | M | Move | βœ… | -| `COPY` | CO | Copy | βœ… | -| `ROTATE` | RO | Rotate | βœ… | -| `SCALE` | SC | Scale | βœ… | -| `MIRROR` | MI | Mirror | βœ… | -| `OFFSET` | O | Offset | βœ… | -| `TRIM` | TR | Trim | βœ… | -| `EXTEND` | EX | Extend | βœ… | -| `STRETCH` | S | Stretch | βœ… | -| `FILLET` | F | Fillet | βœ… | -| `CHAMFER` | CHA | Chamfer | βœ… | -| `ARRAY` | AR | Array | βœ… | -| `ARRAYRECT` | β€” | Rectangular array | βœ… | -| `ARRAYPOLAR` | β€” | Polar array | βœ… | -| `ARRAYPATH` | β€” | Path array | βœ… | -| `BREAK` | BR | Break entity | βœ… | -| `BREAKATPOINT` | β€” | Break at point | βœ… | -| `JOIN` | J | Join entities | βœ… | -| `EXPLODE` | X | Explode compound entity | βœ… | -| `ERASE` | E | Erase | βœ… | -| `LENGTHEN` | LEN | Lengthen / shorten | βœ… | -| `PEDIT` | PE | Edit polyline | βœ… | -| `SPLINEDIT` | SPE | Edit spline | βœ… | -| `MATCHPROP` | MA | Match properties | βœ… | -| `SCALETEXT` | β€” | Scale text objects | βœ… | -| `FLATTEN` | β€” | Flatten 3D to 2D | βœ… | -| `DRAWORDER` | DR | Draw order | βœ… | -| `ALIGN` | AL | Align | βœ… | -| `GROUP` | G | Group | βœ… | -| `UNGROUP` | UG | Ungroup | βœ… | -| `OVERKILL` | β€” | Remove duplicate geometry | πŸ”Ά | -| `3DMOVE` | β€” | 3D move | βœ… | -| `3DROTATE` | ROTATE3D | 3D rotate (cached solid) | βœ… | -| `3DMIRROR` | MIRROR3D | 3D mirror (cached solid) | βœ… | -| `3DALIGN` | ALIGN3D | 3D align (cached solid) | βœ… | -| `3DARRAY` | ARRAY3D | 3D array | βœ… | -| `SLICE` | SL | Slice solid with plane | βœ… | -| `SUBTRACT` | SU | Subtract solids | βœ… | -| `UNION` | UNI | Union solids | βœ… | -| `INTERSECT` | IN | Intersect solids | βœ… | -| `CHAMFERSOLID` | β€” | Chamfer solid edge | ❌ | -| `FILLETEDGE` | β€” | Fillet solid edge | ❌ | - ---- - -## Dimension - -| Command | Alias | Description | Status | -|---|---|---|---| -| `DIMLINEAR` | DLI | Linear dimension | βœ… | -| `DIMALIGNED` | DAL | Aligned dimension | βœ… | -| `DIMANGULAR` | DAN | Angular dimension | βœ… | -| `DIMRADIUS` | DRA | Radius dimension | βœ… | -| `DIMDIAMETER` | DDI | Diameter dimension | βœ… | -| `DIMORDINATE` | DOR | Ordinate dimension | βœ… | -| `DIMCONTINUE` | DCO | Continue dimension | βœ… | -| `DIMBASELINE` | DBA | Baseline dimension | βœ… | -| `QDIM` | β€” | Quick dimension | βœ… | -| `DIMEDIT` | DED | Edit dimension text / position | βœ… | -| `DIMTEDIT` | DIMTED | Move dimension text | βœ… | -| `DIMBREAK` | DBR | Break dimension line | βœ… | -| `DIMSPACE` | DSPACE | Adjust spacing between dimensions | βœ… | -| `DIMJOGLINE` | DJL | Jog line in dimension | βœ… | -| `TOLERANCE` | TOL | Geometric tolerance | βœ… | -| `LEADER` | LE | Leader line (legacy) | βœ… | -| `MLEADER` | MLD | Multileader | βœ… | -| `MLEADERADD` | MLA | Add leader segment | βœ… | -| `MLEADERREMOVE` | MLR | Remove leader segment | βœ… | -| `MLEADERALIGN` | MLAL | Align multileaders | βœ… | -| `MLEADERCOLLECT` | MLC | Collect multileaders | βœ… | -| `DIMJOGGED` | DJO | Jogged radius dimension | βœ… | -| `DIMCENTER` | DCE | Center mark | βœ… | -| `CENTERLINE` | β€” | Center line | βœ… | -| `CENTERMARK` | β€” | Center mark on arc/circle | βœ… | -| `QLEADER` | QL | Quick leader (legacy) | βœ… | - ---- - -## Text & Table - -| Command | Alias | Description | Status | -|---|---|---|---| -| `STYLE` | ST | Text style manager | βœ… | -| `DDEDIT` | ED | Edit text | βœ… | -| `FIND` | β€” | Find and replace text | βœ… | -| `TABLESTYLE` | TS | Table style manager | βœ… | -| `DATALINK` | β€” | Link table to external spreadsheet (CSV) | βœ… | -| `ARCTEXT` | β€” | Text along an arc | βœ… | -| `TORIENT` | β€” | Orient text for readability | βœ… | -| `DATAEXTRACTION` | β€” | Data extraction wizard | πŸ”Ά | -| `FIELD` | β€” | Auto-updating text field | ❌ | -| `SPELL` | SP | Spell check | ❌ | - ---- - -## Layer - -| Command | Alias | Description | Status | -|---|---|---|---| -| `LAYER` | LA | Layer manager | βœ… | -| `LAYOFF` | β€” | Turn layer off | βœ… | -| `LAYON` | β€” | Turn layer on | βœ… | -| `LAYFRZ` | β€” | Freeze layer | βœ… | -| `LAYTHW` | β€” | Thaw layer | βœ… | -| `LAYLCK` | β€” | Lock layer | βœ… | -| `LAYULK` | β€” | Unlock layer | βœ… | -| `LAYMCUR` | β€” | Make object's layer current | βœ… | -| `LAYMATCH` | β€” | Match layer of selected object | βœ… | -| `VPLAYER` | β€” | Viewport layer control | βœ… | -| `LINETYPE` | LT | Linetype manager | βœ… | -| `LTSCALE` | β€” | Global linetype scale | βœ… | -| `LAYISO` | β€” | Isolate layer | βœ… | -| `LAYUNISO` | β€” | End layer isolation | βœ… | -| `LAYDEL` | β€” | Delete layer | βœ… | -| `LAYMRG` | β€” | Merge layers | βœ… | -| `LAYERSTATE` | LAS | Save / restore layer states | βœ… | -| `LAYWALK` | β€” | Walk through layers | ❌ | -| `LAYLOCKFADECTL` | β€” | Locked layer fading control | ❌ | - ---- - -## Block & Reference - -| Command | Alias | Description | Status | -|---|---|---|---| -| `BLOCK` | B | Define block | βœ… | -| `INSERT` | I | Insert block | βœ… | -| `MINSERT` | β€” | Matrix block insert | βœ… | -| `WBLOCK` | W | Write block to file | βœ… | -| `XATTACH` | XA | Attach external reference | βœ… | -| `XREF` | XR | External reference manager | βœ… | -| `XRELOAD` | β€” | Reload external reference | βœ… | -| `XCLIP` | XC | Clip external reference | βœ… | -| `REFEDIT` | β€” | Edit reference in-place | βœ… | -| `REFCLOSE` | β€” | Close reference edit | βœ… | -| `BEDIT` | BE | Block editor | βœ… | -| `BASE` | β€” | Set drawing base point | βœ… | -| `NCOPY` | β€” | Copy nested objects out of a block | βœ… | -| `ATTDEF` | ATT | Define attribute | βœ… | -| `ATTEDIT` | ATE | Edit attribute | βœ… | -| `ATTEXT` | β€” | Extract attributes (legacy) | βœ… | -| `ATTSYNC` | β€” | Synchronize attribute definitions | βœ… | -| `BLOCKPALETTE` | β€” | Multi-view block palette (command-line list) | πŸ”Ά | -| `ATTMAN` | `BATTMAN` | Block attribute manager (opens the attribute editor) | βœ… | -| `XBIND` | XB | Bind xref elements to drawing | ❌ | -| `XOPEN` | β€” | Open xref for editing | βœ… | -| `BSAVE` | β€” | Save block in editor | ❌ | -| `BCLOSE` | β€” | Close block editor | ❌ | - ---- - -## 3D Modeling - -| Command | Alias | Description | Status | -|---|---|---|---| -| `BOX` | β€” | Box solid | βœ… | -| `SPHERE` | β€” | Sphere solid | βœ… | -| `CYLINDER` | β€” | Cylinder solid | βœ… | -| `CONE` | β€” | Cone solid | βœ… | -| `WEDGE` | β€” | Wedge solid | βœ… | -| `TORUS` | β€” | Torus solid | βœ… | -| `PYRAMID` | PYR | Pyramid solid | βœ… | -| `POLYSOLID` | β€” | Wall-like solid | βœ… | -| `EXTRUDE` | EXT | Extrude profile | βœ… | -| `PRESSPULL` | β€” | Push / pull a closed boundary | βœ… | -| `THICKEN` | β€” | Thicken a closed profile to a solid | βœ… | -| `REVOLVE` | REV | Revolve profile around axis | βœ… | -| `SWEEP` | β€” | Sweep profile along path | βœ… | -| `LOFT` | β€” | Loft between profiles | βœ… | -| `MASSPROP` | β€” | Mass properties | βœ… | -| `EXPORTSTEP` | β€” | Export to STEP | βœ… | -| `EXPORTSTL` | β€” | Export to STL | βœ… | -| `SLICE` | SL | Slice solid with plane | βœ… | -| `SUBTRACT` | SU | Subtract solids | βœ… | -| `UNION` | UNI | Union solids | βœ… | -| `INTERSECT` | IN | Intersect solids | βœ… | -| `INTERFERE` | INF | Interference solid from overlap | βœ… | -| `SECTION` | β€” | Cross-section outline of a solid | βœ… | -| `CONVTOSOLID` | β€” | Convert to solid | ❌ | -| `CONVTOSURFACE` | β€” | Convert to surface | βœ… | -| `SECTIONPLANE` | β€” | Section plane object | ❌ | -| `FLATSHOT` | β€” | 2D view from 3D | βœ… | - ---- - -## View & Navigation - -| Command | Alias | Description | Status | -|---|---|---|---| -| `ZOOM` | Z | Zoom | βœ… | -| `LIMITS` | β€” | Drawing/grid limits and point checking | βœ… | -| `PAN` | P | Pan | βœ… | -| `ORBIT` | 3DO | 3D orbit | βœ… | -| `VPORTS` | β€” | Viewport configuration | βœ… | -| `VPJOIN` | β€” | Join viewports | βœ… | -| `SYNCPVIEWPORTS` | VPSYNC | Sync viewport display settings | βœ… | -| `MSPACE` | MS | Switch to model space | βœ… | -| `PSPACE` | PS | Switch to paper space | βœ… | -| `MVIEW` | MV | Model view in layout | βœ… | -| `UCSICON` | β€” | Toggle UCS icon | βœ… | -| `VIEW` | V | Named views manager | βœ… | -| `PLAN` | β€” | Switch to plan view | βœ… | -| `NAVVCUBE` | β€” | Toggle ViewCube | πŸ”Ά | -| `NAVBAR` | β€” | Toggle navigation bar | πŸ”Ά | -| `TOOLPALETTES` | β€” | Tool palettes panel | πŸ”Ά | -| `PROPERTIES` | PR | Properties palette | πŸ”Ά | -| `SHEETSET` | SSM | Sheet set manager | πŸ”Ά | -| `FILETAB` | β€” | Toggle file tabs | πŸ”Ά | -| `LAYOUTTAB` | β€” | Toggle layout tabs | πŸ”Ά | -| `DVIEW` | DV | Dynamic view (legacy) | ❌ | -| `NAVSWHEEL` | β€” | Steering wheel | ❌ | -| `RENDER` | RR | Render | ❌ | -| `RENDERPRESETS` | β€” | Render presets | ❌ | -| `LIGHT` | β€” | Add scene light | ❌ | -| `SUNPROPERTIES` | β€” | Sun light settings | ❌ | -| `MATERIALS` | MAT | Material editor | ❌ | -| `VISUALSTYLES` | β€” | Apply a built-in visual style (no custom-style manager) | πŸ”Ά | -| `HIDE` | HI | Hidden-line regeneration | βœ… | -| `VPMAX` | β€” | Maximize viewport | ❌ | -| `VPMIN` | β€” | Restore viewport | ❌ | - ---- - -## Inquiry - -| Command | Alias | Description | Status | -|---|---|---|---| -| `AREA` | β€” | Calculate area | βœ… | -| `MASSPROP` | β€” | Mass properties | βœ… | -| `QSELECT` | β€” | Quick select | βœ… | -| `STATUS` | β€” | Drawing status | βœ… | -| `COUNT` | β€” | Count objects | βœ… | -| `DIST` | DI | Distance between two points | βœ… | -| `ID` | β€” | Point coordinate | βœ… | -| `LIST` | LI | List object data | βœ… | -| `DBLIST` | β€” | List all objects | βœ… | -| `MEASUREGEOM` | β€” | Measure distance / angle / area | βœ… | -| `CAL` | β€” | Command-line calculator | βœ… | -| `QUICKCALC` | QC | Quick calculator | βœ… | - ---- - -## File & Plot - -| Command | Alias | Description | Status | -|---|---|---|---| -| `NEW` | β€” | New drawing | βœ… | -| `OPEN` | β€” | Open drawing | βœ… | -| `SAVE` | β€” | Save | βœ… | -| `SAVEAS` | β€” | Save as | βœ… | -| `QSAVE` | β€” | Quick save | βœ… | -| `SAVEALL` | β€” | Save all open drawings | βœ… | -| `PLOT` | β€” | Print / plot | βœ… | -| `EXPORT [path]` | β€” | Export to PDF (save dialog, or straight to `path`) | βœ… | -| `EXPORTPDF [path]` | β€” | Export to PDF (save dialog, or straight to `path`) | βœ… | -| `PAGESETUP` | β€” | Page setup | βœ… | -| `PLOTSTYLE` | β€” | Plot style | βœ… | -| `PURGE` | PU | Purge unused items | βœ… | -| `QUIT` | β€” | Exit application | βœ… | -| `RECOVER` | β€” | Recover damaged drawing | ❌ | -| `CLOSE` | β€” | Close drawing | βœ… | -| `ARCHIVE` | β€” | Package drawing + references into a folder | βœ… | -| `ETRANSMIT` | β€” | Transmittal package (folder) | βœ… | - ---- - -## Manage & Customize - -| Command | Alias | Description | Status | -|---|---|---|---| -| `RENAME` | β€” | Rename named objects | βœ… | -| `LINETYPE` | LT | Linetype manager | βœ… | -| `PLOTSTYLEEDITOR` | β€” | Plot style editor | βœ… | -| `MLEADERSTYLE` | β€” | Multileader style manager | βœ… | -| `DIMSTYLE` | D | Dimension style manager | βœ… | -| `CUI` | β€” | Customize user interface | βœ… | -| `CUIIMPORT` | β€” | Import customization file | βœ… | -| `CUIEXPORT` | β€” | Export customization file | βœ… | -| `ALIASEDIT` | β€” | Edit command aliases | βœ… | -| `OPTIONS` | OP | Application settings (drafting) | βœ… | -| `OBJECTSCALE` | β€” | Mark objects annotative | βœ… | -| `SCRIPT` | SCR | Run script file | βœ… | -| `AUDIT` | β€” | Audit drawing integrity | πŸ”Ά | -| `OVERKILL` | β€” | Remove duplicate geometry | πŸ”Ά | -| `FINDNONPURGEABLE` | β€” | Find non-purgeable items | πŸ”Ά | -| `XBIND` | β€” | Bind xref elements | ❌ | -| `HYPERLINK` | β€” | Insert hyperlink | βœ… | -| `DBCONNECT` | β€” | Connect to external database | ❌ | -| `APPLOAD` | β€” | Load application (LISP / ARX) | ❌ | -| `NETLOAD` | β€” | Load .NET plug-in | ❌ | -| `ACTRECORD` | β€” | Record action macro | ❌ | -| `ACTMANAGER` | β€” | Action macro manager | ❌ | - ---- - -## Summary - -| Category | Total | βœ… Done | πŸ”Ά Partial | ❌ Missing | -|---|---|---|---|---| -| Draw | 32 | 31 | 0 | 1 | -| Modify | 42 | 39 | 1 | 2 | -| Dimension | 26 | 26 | 0 | 0 | -| Text & Table | 10 | 7 | 1 | 2 | -| Layer | 19 | 17 | 0 | 2 | -| Block & Reference | 23 | 18 | 2 | 3 | -| 3D Modeling | 27 | 25 | 0 | 2 | -| View & Navigation | 33 | 16 | 8 | 9 | -| Inquiry | 12 | 12 | 0 | 0 | -| File & Plot | 17 | 16 | 0 | 1 | -| Manage & Customize | 22 | 13 | 3 | 6 | -| **Total** | **263** | **220** | **15** | **28** | - -> Counts include commands listed under more than one category (e.g. `SLICE`, `HELIX`, -> `MINSERT`, `SUBTRACT`/`UNION`/`INTERSECT` appear in both their 2D and 3D groups). -> -> Still out of scope: `POINTCLOUDATTACH`/`RECAP` (proprietary binary point-cloud parser -> plus a GPU point renderer) and `UNDERLAYLAYERS`/`UOSNAP` (PDF/DWF internal-layer -> parsing) β€” each needs an external dependency and real test files. diff --git a/Cargo.lock b/Cargo.lock index d7a22fa2e..9f8404156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "OpenCADStudio" -version = "0.8.8" +version = "0.8.9" dependencies = [ "acadrust", "bincode", @@ -29,6 +29,7 @@ dependencies = [ "getrandom 0.3.4", "glam 0.33.2", "iced", + "iced_aw", "image", "inventory", "js-sys", @@ -2184,6 +2185,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "iced_aw" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b7f923642b024c415150b70ef84ee5c11626e3b0af73b954d817d573bcd0a4" +dependencies = [ + "cfg-if", + "chrono", + "iced_core", + "iced_fonts", + "iced_widget", + "num-format", + "num-traits", + "web-time", +] + [[package]] name = "iced_core" version = "0.14.0" @@ -2213,6 +2230,29 @@ dependencies = [ "log", ] +[[package]] +name = "iced_fonts" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214cff7c8499e328774216690e58e315a1a5f8f6fdd1035aed6298e62ffc4c1d" +dependencies = [ + "iced_core", + "iced_fonts_macros", + "iced_widget", +] + +[[package]] +name = "iced_fonts_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef5125e110cb19cd1910a28298661c98c5d9ab02eef43594968352940e8752e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "ttf-parser", +] + [[package]] name = "iced_futures" version = "0.14.0" @@ -3243,6 +3283,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" diff --git a/Cargo.toml b/Cargo.toml index 1ed48982f..165613abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenCADStudio" -version = "0.8.8" +version = "0.8.9" edition = "2021" build = "build.rs" @@ -48,6 +48,7 @@ serde = { version = "1", features = ["derive"] } # the binary self-contained on every platform. lzma-sys = { version = "0.1", features = ["static"], optional = true } iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol"] } +iced_aw = { version = "0.14.1", features = ["menu"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } truck-modeling = "0.6" diff --git a/HEADER_AUDIT.md b/HEADER_AUDIT.md deleted file mode 100644 index 77c405d99..000000000 --- a/HEADER_AUDIT.md +++ /dev/null @@ -1,28 +0,0 @@ -# HeaderVariables Audit - -`acadrust::document::HeaderVariables` β€” 232 total. - -## Pass (no matching feature) - -- **ISOLINES** β€” contour line count on curved surfaces (no curved - wireframe generator yet) -- **surface_u_density / surface_v_density / surface_type** β€” defaults - for SURFACE / REVSURF / EDGESURF commands (those commands don't - exist yet) -- **lens_length / camera_height / camera_display** β€” perspective - camera (no perspective viewport yet) -- **north_direction / latitude / longitude / timezone** β€” sun / - geographic data (no sun-shadow renderer) -- **LIMMIN/LIMMAX** β€” drawing limits (no bounded-grid mode) -- **INSBASE** β€” drawing's "save as block" anchor (no WBLOCK / "save - selection as block" flow) - -### Remaining (intentionally not surfaced) -- ~70 `dim_*` defaults β€” render-time `DimStyle` reference is already - authoritative; these only seed a new DimStyle when a fresh DIMSTYLE - NEW flow is added. -- `*_control_handle`, `*_dict_handle` β€” internal table-navigation - handles; direct `document.layers` / `document.dim_styles` / etc. - accessors bypass them. acadrust read/write preserves them. -- AutoCAD UI prefs (`blip_mode`, `drag_mode`, `pick_style`, etc.) β€” - H7CAD has its own UI prefs and ignores AutoCAD's. Round-tripped. diff --git a/STATUSBAR.md b/STATUSBAR.md deleted file mode 100644 index 3db384418..000000000 --- a/STATUSBAR.md +++ /dev/null @@ -1,71 +0,0 @@ -# Open CAD Studio β€” Status Bar Items - -Tracks the status-bar toggle pills, the customization menu, and which -items are implemented, deferred, or out of scope. - -The customization menu (the `≑` handle at the bar's far right) lists every -pill with a check mark; toggling a row shows/hides that pill and the choice -persists across sessions. - -- Pill definitions & visibility: [`src/ui/statusbar_config.rs`](src/ui/statusbar_config.rs) -- Bar layout & pills: [`src/ui/statusbar.rs`](src/ui/statusbar.rs) -- Customization menu: [`src/ui/statusbar_menu.rs`](src/ui/statusbar_menu.rs) - -## Implemented - -| Item | Pill | Source | -|------|------|--------| -| Coordinates (live cursor X,Y,Z) | `Coords` | [statusbar.rs](src/ui/statusbar.rs) `format_coords` | -| Snap Mode | `SNAP` | pre-existing | -| Grid | `GRID` | pre-existing | -| Ortho Mode | `ORTHO` | pre-existing | -| Polar Tracking | `POLAR` | pre-existing | -| Dynamic Input | `DYN` | pre-existing | -| Object Snap Tracking | `OTRACK` | pre-existing | -| 2D Object Snap | `OSNAP` | pre-existing | -| Lineweight display | `LWT` | pre-existing | -| Model / Paper space | `Space` | pre-existing | -| Annotation scale | `Scale` | [scale_popup.rs](src/ui/scale_popup.rs) | -| Drawing units (INSUNITS) | `Units` | [units_popup.rs](src/ui/units_popup.rs) | -| Transparency display | `TPY` | [uniforms.rs](src/scene/pipeline/uniforms.rs) + [wire.wgsl](src/shaders/wire.wgsl) | -| Isolate / Hide objects | `Isolate` | [isolate_popup.rs](src/ui/isolate_popup.rs); commands `ISOLATEOBJECTS` / `HIDEOBJECTS` / `UNISOLATEOBJECTS` | -| Quick Properties | `QP` | [properties.rs](src/ui/properties.rs) `quick_view` | -| Selection Filtering | `FILTER` | [selection_filter_popup.rs](src/ui/selection_filter_popup.rs) | -| Selection Cycling | `SC` | [cycle_popup.rs](src/ui/cycle_popup.rs) β€” pick list + hover highlight | -| Clean Screen | `CleanScreen` | hides ribbon + side panels | -| Viewport count (app-specific) | `Vp` | β€” | - -## Remaining β€” annotative engine - -These are not single pills; they need a real annotative-objects subsystem: -a per-entity annotative flag, the list of scales each object supports, and a -**separate representation (size / position) per scale**, with render-time -selection by the current annotation scale. - -| Item | Needs | -|------|-------| -| Annotation Visibility | hide annotative objects whose scale list omits the current scale | -| AutoScale | add the current scale's representation automatically when the scale changes | -| Annotation Monitor | warn on annotative inconsistencies | - -The pieces that exist today (`annotation_scale`, dimstyle / multileader -annotative flags) do not yet include the per-scale representation system. -Best tackled as a separate, staged effort (start with Visibility). - -## Dropped β€” no fit in this architecture - -| Item | Why | -|------|-----| -| Lock UI | panels are separate OS windows; there is no dock layout to lock | -| Graphics Performance | a GPU-tuning dialog with no meaningful equivalent here | - -## Out of scope β€” large standalone engines - -Each is a product-level subsystem, not a status pill: - -- Infer Constraints (parametric constraint solver) -- Isometric Drafting (isometric drawing mode) -- 3D Object Snap -- Dynamic UCS (auto-align UCS to the face under the cursor) -- Gizmo (3D move/rotate manipulator) -- Workspace Switching (multiple ribbon workspaces) diff --git a/assets/icons/ui/dirty_dot.svg b/assets/icons/ui/dirty_dot.svg new file mode 100644 index 000000000..2af2700e8 --- /dev/null +++ b/assets/icons/ui/dirty_dot.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html index bafd6c253..1bcf5c3bd 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,7 @@ file) so CAD text renders non-Latin scripts on the web. (#141) --> +
diff --git a/src/app/config.rs b/src/app/config.rs index a1e3544bf..237071e88 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -18,6 +18,8 @@ use crate::ui::window::plot::PlotDialogState; pub struct AppConfig { /// Input modes, backup, plugin lists, viewport background colours, … pub settings: UserSettings, + /// Iced theme selection and the six base colours used by a custom theme. + pub theme: UiThemeConfig, /// Recent-files list + retained count. pub recent: RecentConfig, /// Last selected section on the tabbed Start page. @@ -35,6 +37,7 @@ impl Default for AppConfig { fn default() -> Self { Self { settings: UserSettings::default(), + theme: UiThemeConfig::default(), recent: RecentConfig::default(), start: StartConfig::default(), statusbar: StatusBarConfig::default(), @@ -44,6 +47,136 @@ impl Default for AppConfig { } } +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UiThemeConfig { + pub name: String, + pub palette: UiThemePalette, +} + +impl Default for UiThemeConfig { + fn default() -> Self { + let theme = iced::Theme::Oxocarbon; + Self { + name: theme.to_string(), + palette: UiThemePalette::from_iced(theme.palette()), + } + } +} + +impl UiThemeConfig { + pub fn to_iced(&self) -> iced::Theme { + if self.name == "Custom" { + iced::Theme::custom("Custom", self.palette.to_iced()) + } else { + builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon) + } + } +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UiThemePalette { + pub background: [u8; 3], + pub text: [u8; 3], + pub primary: [u8; 3], + pub success: [u8; 3], + pub warning: [u8; 3], + pub danger: [u8; 3], +} + +impl Default for UiThemePalette { + fn default() -> Self { + Self::from_iced(iced::Theme::Oxocarbon.palette()) + } +} + +impl UiThemePalette { + pub fn from_iced(palette: iced::theme::Palette) -> Self { + Self { + background: color_to_rgb(palette.background), + text: color_to_rgb(palette.text), + primary: color_to_rgb(palette.primary), + success: color_to_rgb(palette.success), + warning: color_to_rgb(palette.warning), + danger: color_to_rgb(palette.danger), + } + } + + pub fn to_iced(self) -> iced::theme::Palette { + iced::theme::Palette { + background: rgb_to_color(self.background), + text: rgb_to_color(self.text), + primary: rgb_to_color(self.primary), + success: rgb_to_color(self.success), + warning: rgb_to_color(self.warning), + danger: rgb_to_color(self.danger), + } + } + + pub fn hex_values(self) -> [String; 6] { + [ + rgb_to_hex(self.background), + rgb_to_hex(self.text), + rgb_to_hex(self.primary), + rgb_to_hex(self.success), + rgb_to_hex(self.warning), + rgb_to_hex(self.danger), + ] + } + + pub fn set_hex(&mut self, index: usize, value: &str) -> bool { + let Some(rgb) = parse_hex(value) else { + return false; + }; + match index { + 0 => self.background = rgb, + 1 => self.text = rgb, + 2 => self.primary = rgb, + 3 => self.success = rgb, + 4 => self.warning = rgb, + 5 => self.danger = rgb, + _ => return false, + } + true + } +} + +pub fn builtin_theme(name: &str) -> Option { + iced::Theme::ALL + .iter() + .find(|theme| theme.to_string() == name) + .cloned() +} + +fn color_to_rgb(color: iced::Color) -> [u8; 3] { + [ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ] +} + +fn rgb_to_color(rgb: [u8; 3]) -> iced::Color { + iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]) +} + +fn rgb_to_hex(rgb: [u8; 3]) -> String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} + +fn parse_hex(value: &str) -> Option<[u8; 3]> { + let value = value.trim().strip_prefix('#').unwrap_or(value.trim()); + if value.len() != 6 { + return None; + } + Some([ + u8::from_str_radix(&value[0..2], 16).ok()?, + u8::from_str_radix(&value[2..4], 16).ok()?, + u8::from_str_radix(&value[4..6], 16).ok()?, + ]) +} + #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RecentConfig { diff --git a/src/app/mod.rs b/src/app/mod.rs index 247cd32d2..7edbb0709 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ mod alias; #[cfg(not(target_arch = "wasm32"))] mod automation; -mod config; +pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; mod command_driver; @@ -226,6 +226,7 @@ pub enum StartSection { Videos, #[default] Welcome, + Discussions, Supporters, } @@ -259,6 +260,10 @@ pub(super) struct OpenCADStudio { video_thumbs: std::collections::HashMap, /// True while the boot-time playlist fetch is still in flight. videos_loading: bool, + /// GitHub Discussions shown on the Start page, with pinned entries first. + discussions: Vec, + /// True while the boot-time Discussions refresh is still in flight. + discussions_loading: bool, /// Block references whose properties panel shows per-axis Scale X/Y/Z even /// though the three factors are currently equal β€” the user unchecked the /// "Uniform scale" box for them (#427). Keyed by entity handle. @@ -266,6 +271,9 @@ pub(super) struct OpenCADStudio { /// Which Start-page section is shown when the page is too narrow for all /// three side by side and falls back to a tab bar. start_section: StartSection, + /// Widest natural single-row width of the Start-page action buttons, + /// measured by `WrapFlow` so side lists collapse before those buttons wrap. + start_action_w: std::sync::Arc, /// When the window is too narrow the properties panel collapses to a /// vertical bar; this is the user's toggle to expand it back out. props_expanded: bool, @@ -295,6 +303,9 @@ pub(super) struct OpenCADStudio { isolate_popup_open: bool, /// True while the selection-filter type picker is open. selection_filter_popup_open: bool, + /// Hide a status-menu tooltip after its root is clicked; reset when the + /// pointer leaves the root for the opened menu. + status_menu_tooltip_hidden: bool, /// Clean-screen mode: hide ribbon and side panels for a full canvas. clean_screen: bool, /// Quick Properties: show a compact floating property panel on selection. @@ -570,10 +581,6 @@ pub(super) struct OpenCADStudio { mtext_editor: Option, /// Open in-place single-line TEXT editor (plain text-entry box), if any. text_inline: Option, - /// Which layout tab has its context menu open (None = closed). - layout_context_menu: Option, - /// Which drawing tab has its context menu open (None = closed). - doc_tab_context_menu: Option, /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas /// point it opened at, or `None` when closed (#337). snap_override_popup: Option, @@ -689,6 +696,8 @@ pub(super) struct OpenCADStudio { // ── Color Scheme ────────────────────────────────────────────────────── active_theme: Theme, + ui_theme: config::UiThemeConfig, + theme_color_inputs: [String; 6], // ── Keyboard Shortcut Editor ────────────────────────────────────────── /// User-defined function-key overrides: "F3" β†’ command string. @@ -1475,6 +1484,10 @@ pub enum Message { OptionsOpen, /// Set the default type/version used when first saving a new drawing. DefaultSaveFormatChanged(String), + /// Select one of Iced's built-in themes or the editable Custom theme. + OptionsThemeChanged(String), + /// Edit one of Custom theme's six base colours as #RRGGBB. + OptionsThemeColorChanged(usize, String), ClearScene, SetWireframe(bool), /// Set the active tab's render mode (one of acadrust's seven visual @@ -1512,9 +1525,6 @@ pub enum Message { }, /// Close the given tab index. TabClose(usize), - /// Open/close the right-click menu for a drawing tab. - DocTabContextMenu(usize), - DocTabContextMenuClose, /// Save every drawing that already has a file path. DocTabSaveAll, /// Close every non-Start drawing tab. @@ -1965,10 +1975,6 @@ pub enum Message { LayoutRenameCommit, /// Cancel an in-progress rename (Escape). LayoutRenameCancel, - /// Open the right-click context menu for the given layout tab. - LayoutContextMenu(String), - /// Close the layout context menu. - LayoutContextMenuClose, // ── Layout Manager Panel ──────────────────────────────────────────── LayoutManagerOpen, #[allow(dead_code)] @@ -2058,6 +2064,8 @@ pub enum Message { PatronsFetched(Result, String>), /// Tutorial-playlist videos fetched at boot for the Start page. VideosFetched(Result, String>), + /// GitHub Discussions fetched at boot for the Start page. + DiscussionsFetched(Result, String>), /// Recent-file DWG preview thumbnails decoded on a background thread. RecentThumbsLoaded( Vec<(std::path::PathBuf, Option)>, @@ -2177,6 +2185,8 @@ pub enum Message { OsWindowClosed(window::Id), /// No-op β€” used as a fallback when a TabEvent has no host mapping. Noop, + /// Suppress menu-root tooltips between clicking the root and leaving it. + StatusMenuTooltipHidden(bool), /// GitHub releases API returned a result. `Some(version)` means a /// newer release exists; we open the update-notice window. UpdateCheckResult(Option), @@ -2483,8 +2493,11 @@ impl OpenCADStudio { videos: Vec::new(), video_thumbs: std::collections::HashMap::new(), videos_loading: false, + discussions: Vec::new(), + discussions_loading: false, props_asym_scale: std::collections::HashSet::new(), start_section: StartSection::default(), + start_action_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), props_expanded: false, history_content: iced::widget::text_editor::Content::new(), status_bar: StatusBar::new(), @@ -2501,6 +2514,7 @@ impl OpenCADStudio { units_popup_open: false, isolate_popup_open: false, selection_filter_popup_open: false, + status_menu_tooltip_hidden: false, statusbar_config: crate::ui::statusbar::statusbar_config::StatusBarConfig::default(), last_saved_config: None, otrack_active: None, @@ -2593,8 +2607,6 @@ impl OpenCADStudio { cont_anchor: None, mtext_editor: None, text_inline: None, - layout_context_menu: None, - doc_tab_context_menu: None, snap_override_popup: None, axis_lock_dir: None, layout_rename_state: None, @@ -2631,8 +2643,10 @@ impl OpenCADStudio { default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), // Plot style active_plot_style: None, - // Color scheme (default: dark CAD-style) - active_theme: Theme::Dark, + // Color scheme (default: Oxocarbon) + active_theme: Theme::Oxocarbon, + ui_theme: config::UiThemeConfig::default(), + theme_color_inputs: config::UiThemePalette::default().hex_values(), // Keyboard shortcuts shortcut_overrides: rustc_hash::FxHashMap::default(), // Command aliases (populated from ocad.pgp just after construction) @@ -2946,6 +2960,26 @@ impl OpenCADStudio { }; #[cfg(target_arch = "wasm32")] let videos_fetch = Task::none(); + // GitHub Discussions: seed from the last successful fetch, then refresh + // the public feed and pinned section on a background thread. + #[cfg(not(target_arch = "wasm32"))] + let discussions_fetch = { + s.discussions = crate::discussions::load_cached(); + s.discussions_loading = true; + let (tx, rx) = iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let _ = tx.send(crate::discussions::fetch_discussions()); + }); + Task::perform( + async move { + rx.await + .unwrap_or_else(|_| Err("discussion fetch thread died".into())) + }, + Message::DiscussionsFetched, + ) + }; + #[cfg(target_arch = "wasm32")] + let discussions_fetch = Task::none(); // Recent-file thumbnails: decoded off-thread β€” parsing every recent // DWG's preview on the boot path held the first frame back. let thumbs_fetch = s.refresh_recent_thumbs(); @@ -2960,6 +2994,7 @@ impl OpenCADStudio { assoc_prompt, patrons_fetch, videos_fetch, + discussions_fetch, thumbs_fetch, ]), ) @@ -2979,7 +3014,12 @@ impl OpenCADStudio { crate::patreon::fetch_patrons_web(), Message::PatronsFetched, ); - (s, Task::batch([focus, patrons])) + s.discussions_loading = true; + let discussions = Task::perform( + crate::discussions::fetch_discussions_web(), + Message::DiscussionsFetched, + ); + (s, Task::batch([focus, patrons, discussions])) } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 176214e1b..4376ad2ae 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -561,7 +561,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.refresh_properties(); return Task::none(); } - // Cancel layout rename / context menus first, then fall through. + // Cancel layout rename first, then fall through. let i_e = self.active_tab; if self.qselect.take().is_some() { return Task::none(); @@ -573,10 +573,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } } - if self.layout_rename_state.take().is_some() - || self.layout_context_menu.take().is_some() - || self.doc_tab_context_menu.take().is_some() - { + if self.layout_rename_state.take().is_some() { return Task::none(); } // Typed text on the command line cancels first β€” one diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 29102e900..ebe9e0b87 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -320,6 +320,7 @@ impl OpenCADStudio { pub(in crate::app) fn current_config(&self) -> crate::app::config::AppConfig { crate::app::config::AppConfig { settings: self.current_settings(), + theme: self.ui_theme.clone(), recent: crate::app::config::RecentConfig { files: self .recent_files @@ -342,6 +343,9 @@ impl OpenCADStudio { /// Distribute a loaded config into live app state (called once at startup). pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) { self.apply_settings(&cfg.settings); + self.ui_theme = cfg.theme.clone(); + self.active_theme = self.ui_theme.to_iced(); + self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.recent_files = cfg .recent .files diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9a8e832ca..ed11ad25b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -930,9 +930,7 @@ impl OpenCADStudio { } Message::TabSwitch(idx) => { - self.doc_tab_context_menu = None; self.layout_list_open = false; - self.layout_context_menu = None; self.layout_rename_state = None; if idx < self.tabs.len() { if idx != self.active_tab { @@ -991,35 +989,14 @@ impl OpenCADStudio { if let Some(index) = self.tabs.iter().position(|tab| tab.id == active_id) { self.active_tab = index; } - self.doc_tab_context_menu = None; Task::none() } - Message::TabClose(idx) => { - self.doc_tab_context_menu = None; - self.on_tab_close(idx) - } + Message::TabClose(idx) => self.on_tab_close(idx), - Message::DocTabContextMenu(idx) => { - if self.tabs.get(idx).is_some_and(|tab| !tab.is_start) { - self.layout_context_menu = None; - self.doc_tab_context_menu = Some(idx); - } - Task::none() - } - - Message::DocTabContextMenuClose => { - self.doc_tab_context_menu = None; - Task::none() - } - - Message::DocTabSaveAll => { - self.doc_tab_context_menu = None; - self.dispatch_command("SAVEALL") - } + Message::DocTabSaveAll => self.dispatch_command("SAVEALL"), Message::DocTabCloseAll => { - self.doc_tab_context_menu = None; let ids = self .tabs .iter() @@ -1030,7 +1007,6 @@ impl OpenCADStudio { } Message::DocTabCloseOthers(idx) => { - self.doc_tab_context_menu = None; let Some(keep_id) = self.tabs.get(idx).filter(|tab| !tab.is_start).map(|t| t.id) else { return Task::none(); @@ -1046,7 +1022,6 @@ impl OpenCADStudio { } Message::DocTabCopyFullPath(idx) => { - self.doc_tab_context_menu = None; #[cfg(not(target_arch = "wasm32"))] { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) @@ -1078,7 +1053,6 @@ impl OpenCADStudio { } Message::DocTabOpenFileLocation(idx) => { - self.doc_tab_context_menu = None; #[cfg(not(target_arch = "wasm32"))] { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) @@ -2098,12 +2072,9 @@ impl OpenCADStudio { Task::none() } Message::TogglePolarPopup => { - self.polar_popup_open ^= true; - if self.polar_popup_open { - // Start the custom field empty each time; picking a preset or - // typing a value is what actually enables polar tracking. - self.polar_custom_input.clear(); - } + // MenuBar owns its open state. Reset only the transient field + // whenever the caret starts a fresh interaction. + self.polar_custom_input.clear(); Task::none() } Message::ClosePolarPopup => { @@ -3386,7 +3357,6 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYOUT REORDER"); self.tabs[i].scene.set_layout_tab_order(&paper); self.tabs[i].dirty = true; - self.layout_context_menu = None; Task::none() } @@ -3396,7 +3366,6 @@ impl OpenCADStudio { let i = self.active_tab; self.push_undo_snapshot(i, "LAYOUT DEL"); if self.tabs[i].scene.delete_layout(&name) { - self.layout_context_menu = None; self.layout_rename_state = None; self.command_line .push_output(&format!("Layout \"{name}\" silindi")); @@ -3408,7 +3377,6 @@ impl OpenCADStudio { Message::LayoutRenameStart(name) => { if name != "Model" { self.layout_rename_state = Some((name.clone(), name)); - self.layout_context_menu = None; // Focus the inline field so the user types into it // directly instead of the command line (issue #86). return iced::widget::operation::focus(iced::widget::Id::new( @@ -3433,26 +3401,6 @@ impl OpenCADStudio { Task::none() } - Message::LayoutContextMenu(name) => { - // No menu on the transient BEDIT block tab: Rename/Delete are - // layout operations and don't apply to it. - let is_block_tab = self.tabs[self.active_tab] - .block_edit - .as_ref() - .map(|be| be.block_name == name) - .unwrap_or(false); - if name != "Model" && !is_block_tab { - self.doc_tab_context_menu = None; - self.layout_context_menu = Some(name); - } - Task::none() - } - - Message::LayoutContextMenuClose => { - self.layout_context_menu = None; - Task::none() - } - // ── Layout Manager Panel ────────────────────────────────────────── Message::LayoutManagerOpen => { let i = self.active_tab; @@ -3603,7 +3551,12 @@ impl OpenCADStudio { } Message::SetTheme(theme) => { + self.ui_theme.name = theme.to_string(); + self.ui_theme.palette = + crate::app::config::UiThemePalette::from_iced(theme.palette()); + self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.active_theme = theme; + self.persist_settings_if_changed(); Task::none() } @@ -3672,6 +3625,37 @@ impl OpenCADStudio { Message::DefaultSaveFormatChanged(format) => { self.default_save_format = crate::io::canonical_save_format(&format).to_string(); + self.persist_settings_if_changed(); + Task::none() + } + + Message::OptionsThemeChanged(name) => { + self.ui_theme.name = name; + if let Some(theme) = + crate::app::config::builtin_theme(&self.ui_theme.name) + { + self.ui_theme.palette = + crate::app::config::UiThemePalette::from_iced(theme.palette()); + self.theme_color_inputs = self.ui_theme.palette.hex_values(); + self.active_theme = theme; + } else { + self.ui_theme.name = "Custom".to_string(); + self.active_theme = self.ui_theme.to_iced(); + } + self.persist_settings_if_changed(); + Task::none() + } + + Message::OptionsThemeColorChanged(index, value) => { + if index >= self.theme_color_inputs.len() { + return Task::none(); + } + self.theme_color_inputs[index] = value.clone(); + if self.ui_theme.palette.set_hex(index, &value) { + self.ui_theme.name = "Custom".to_string(); + self.active_theme = self.ui_theme.to_iced(); + self.persist_settings_if_changed(); + } Task::none() } @@ -3975,6 +3959,16 @@ impl OpenCADStudio { self.videos_loading = false; Task::none() } + Message::DiscussionsFetched(Ok(discussions)) => { + self.discussions_loading = false; + self.discussions = discussions; + Task::none() + } + // Offline: keep the native cache (web leaves the panel empty). + Message::DiscussionsFetched(Err(_)) => { + self.discussions_loading = false; + Task::none() + } Message::RecentThumbsLoaded(thumbs) => { for (path, handle) in thumbs { self.recent_thumbs.insert(path, handle); @@ -4169,6 +4163,13 @@ impl OpenCADStudio { } Message::Noop => Task::none(), + Message::StatusMenuTooltipHidden(hidden) => { + self.status_menu_tooltip_hidden = hidden; + if hidden { + self.polar_custom_input.clear(); + } + Task::none() + } // ── Unsaved-changes dialog ──────────────────────────────────── Message::UnsavedDialogCancel => { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 102d0faaf..ff2ae6c88 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3702,9 +3702,8 @@ impl OpenCADStudio { self.tabs[i].scene.sync_camera_to_document(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; let sync_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; - // Cancel any pending rename/context-menu and active viewport when switching. + // Cancel any pending rename and active viewport when switching. self.layout_rename_state = None; - self.layout_context_menu = None; self.tabs[i].scene.active_viewport = None; let perf_phase = Instant::now(); self.tabs[i].scene.set_current_layout(name.clone()); diff --git a/src/app/view/controls.rs b/src/app/view/controls.rs index 3aec2fbf0..43af88c64 100644 --- a/src/app/view/controls.rs +++ b/src/app/view/controls.rs @@ -4,7 +4,7 @@ use super::super::Message; use iced::widget::{ button, container, mouse_area, row, }; -use iced::{Background, Border, Color, Element, Theme}; +use iced::{Background, Border, Element, Theme}; pub(super) fn viewport_controls<'a>( render_mode: acadrust::entities::ViewportRenderMode, @@ -23,64 +23,58 @@ pub(super) fn viewport_controls<'a>( RenderModeChoice(M::FlatShadedWithEdges), RenderModeChoice(M::GouraudShadedWithEdges), ]; - let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; - let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }; - let green = Color { r: 0.36, g: 0.80, b: 0.45, a: 1.0 }; - let red = Color { r: 0.92, g: 0.38, b: 0.38, a: 1.0 }; - - // Fixed-colour icon button (close = red); colour stays on hover. - let tinted_btn = move |bytes: &'static [u8], color: Color, msg: Message| { - button(crate::ui::icons::tinted(bytes, 15.0, color)) + let danger_btn = move |bytes: &'static [u8], msg: Message| { + button(crate::ui::icons::themed_danger(bytes, 15.0)) .on_press(msg) .padding([4, 6]) - .style(move |_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match status { + .style(move |theme: &Theme, status| iced::widget::button::Style { + background: matches!( + status, iced::widget::button::Status::Hovered - | iced::widget::button::Status::Pressed => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 0.9, - }, - _ => Color::TRANSPARENT, - })), + | iced::widget::button::Status::Pressed + ) + .then_some(Background::Color( + theme.extended_palette().danger.weak.color + )), border: Border { radius: 3.0.into(), ..Default::default() }, - text_color: color, + text_color: theme.extended_palette().danger.base.color, ..Default::default() }) }; // Borderless icon button; an `active` toggle gets an accent tint + fill. let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| { - let tint = if active { accent } else { light }; - button(crate::ui::icons::tinted(bytes, 15.0, tint)) + let icon = if active { + crate::ui::icons::themed_primary(bytes, 15.0) + } else { + crate::ui::icons::themed(bytes, 15.0) + }; + button(icon) .on_press(msg) .padding([4, 6]) - .style(move |_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match (active, status) { - (_, iced::widget::button::Status::Hovered) => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 0.9, - }, - (true, _) => Color { - r: 0.16, - g: 0.22, - b: 0.32, - a: 0.9, - }, - (false, _) => Color::TRANSPARENT, - })), + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match (active, status) { + (_, iced::widget::button::Status::Hovered) => { + Some(palette.background.strong) + } + (true, _) => Some(palette.primary.weak), + (false, _) => None, + }; + iced::widget::button::Style { + background: pair.map(|p| Background::Color(p.color)), border: Border { radius: 3.0.into(), ..Default::default() }, - text_color: tint, + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text), ..Default::default() + } }) }; @@ -92,27 +86,27 @@ pub(super) fn viewport_controls<'a>( ) .text_size(11) .padding([4, 6]) - .style(move |_: &Theme, _| iced::widget::pick_list::Style { - background: Background::Color(Color::TRANSPARENT), + .style(move |theme: &Theme, _| { + let text = theme.extended_palette().background.base.text; + iced::widget::pick_list::Style { + background: Background::Color(iced::Color::TRANSPARENT), border: Border { radius: 3.0.into(), ..Default::default() }, - text_color: light, - placeholder_color: light, - handle_color: light, + text_color: text, + placeholder_color: text.scale_alpha(0.68), + handle_color: text, + } }); // Thin vertical divider between control groups. let sep = || { - container(iced::widget::Space::new().width(1.0).height(16.0)).style(|_: &Theme| { + container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| { iced::widget::container::Style { - background: Some(Background::Color(Color { - r: 0.45, - g: 0.45, - b: 0.45, - a: 0.7, - })), + background: Some(Background::Color( + theme.extended_palette().background.neutral.color.scale_alpha(0.7) + )), ..Default::default() } }) @@ -139,7 +133,7 @@ pub(super) fn viewport_controls<'a>( // would only fire on release). Placed just left of Close. if tile_count > 1 { let drag = mouse_area( - container(crate::ui::icons::tinted(crate::ui::icons::MOVE, 15.0, green)) + container(crate::ui::icons::themed_success(crate::ui::icons::MOVE, 15.0)) .padding([4, 6]) .style(|_: &Theme| iced::widget::container::Style { border: Border { @@ -155,30 +149,25 @@ pub(super) fn viewport_controls<'a>( .push(sep()) .push(drag) .push(sep()) - .push(tinted_btn(crate::ui::icons::CLOSE, red, Message::CloseModelViewport)); + .push(danger_btn(crate::ui::icons::CLOSE, Message::CloseModelViewport)); } } container(bar) .padding(2) - .style(|_: &Theme| iced::widget::container::Style { - background: Some(Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.75, - })), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + iced::widget::container::Style { + background: Some(Background::Color( + palette.background.weak.color.scale_alpha(0.92) + )), border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } }) .into() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 66673a065..59e3d7c16 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -13,6 +13,7 @@ use iced::widget::{ }; use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; +use iced_aw::ContextMenu; mod controls; mod modal; @@ -21,8 +22,8 @@ mod viewcube; use controls::{dyn_component_value, viewport_controls}; use overlay::{ - doc_tab_context_menu_overlay, layout_context_menu_overlay, mtext_editor_overlay, - position_canvas_overlay, qselect_overlay, text_inline_overlay, viewport_context_menu_overlay, + mtext_editor_overlay, position_canvas_overlay, qselect_overlay, text_inline_overlay, + viewport_context_menu_overlay, }; use viewcube::{viewcube_nav_controls, viewcube_ucs_picker, UCS_PICKER_W}; @@ -80,6 +81,13 @@ impl OpenCADStudio { pub fn view_main(&self) -> Element<'_, Message> { let i = self.active_tab; let tab = &self.tabs[i]; + let theme_text = self.active_theme.extended_palette().background.base.text; + let viewcube_text_color = [ + theme_text.r, + theme_text.g, + theme_text.b, + theme_text.a, + ]; let is_paper = tab.scene.current_layout != "Model"; // Adaptive corner widgets: the ViewCube shows only while the active // viewport is wide enough to hold it *beside* the render-mode bar, whose @@ -120,11 +128,14 @@ impl OpenCADStudio { &self.videos, self.videos_loading, &self.video_thumbs, + &self.discussions, + self.discussions_loading, &self.recent_files, &self.recent_thumbs, self.recent_limit, &self.recent_limit_input, size.width, + self.start_action_w.clone(), self.start_section, ) }) @@ -134,6 +145,7 @@ impl OpenCADStudio { &tab.scene, viewcube_visible, tab.render_mode, + viewcube_text_color, )) .width(Fill) .height(Fill) @@ -169,6 +181,7 @@ impl OpenCADStudio { show_viewcube, render_mode, idx, + viewcube_text_color, )) .width(Fill) .height(Fill), @@ -742,18 +755,12 @@ impl OpenCADStudio { ]) .report_width0(self.render_bar_w.clone()) .into(); - // Position the bar at the active model tile's top-left corner so it - // follows the active panel in a tiled layout (full canvas when a - // single tile fills the window). Leading Spaces offset it. - let bar_layer = column![ - Space::new().height(iced::Length::Fixed(rect.y.max(0.0))), - row![ - Space::new().width(iced::Length::Fixed(rect.x.max(0.0))), - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ], - ] - .width(Fill) - .height(Fill); + // Pin the bar to the active model tile's top-left corner so it + // follows the active panel in a tiled layout. + let bar_layer = crate::ui::pin_at( + iced::Point::new(rect.x, rect.y), + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ); viewport_stack = viewport_stack.push(bar_layer); } @@ -802,12 +809,7 @@ impl OpenCADStudio { }, ..Default::default() }); - let border_layer = column![ - Space::new().height(iced::Length::Fixed(y)), - row![Space::new().width(iced::Length::Fixed(x)), border_frame,], - ] - .width(Fill) - .height(Fill); + let border_layer = crate::ui::pin_at(iced::Point::new(x, y), border_frame); viewport_stack = viewport_stack.push(border_layer); let vp_mode = tab @@ -833,15 +835,10 @@ impl OpenCADStudio { ]) .report_width0(self.render_bar_w.clone()) .into(); - let picker_layer = column![ - Space::new().height(iced::Length::Fixed(y + 4.0)), - row![ - Space::new().width(iced::Length::Fixed(x + 4.0)), - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ], - ] - .width(Fill) - .height(Fill); + let picker_layer = crate::ui::pin_at( + iced::Point::new(x + 4.0, y + 4.0), + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ); viewport_stack = viewport_stack.push(picker_layer); // Hide the ViewCube first β€” before the render bar β€” when they collide. @@ -849,15 +846,10 @@ impl OpenCADStudio { let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let controls = column![ - Space::new().height(iced::Length::Fixed(cube_y)), - row![ - Space::new().width(iced::Length::Fixed(cube_x)), - viewcube_nav_controls(), - ], - ] - .width(Fill) - .height(Fill); + let controls = crate::ui::pin_at( + iced::Point::new(cube_x, cube_y), + viewcube_nav_controls(), + ); viewport_stack = viewport_stack.push(controls); let ucs_current = tab @@ -873,16 +865,13 @@ impl OpenCADStudio { .map(|u| u.name.clone()) .filter(|n| !n.is_empty()) .collect(); - let picker = column![ - Space::new().height(iced::Length::Fixed(cube_y + VIEWCUBE_HIT_SIZE + 6.0)), - row![ - Space::new() - .width(iced::Length::Fixed(cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5)), - iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), - ], - ] - .width(Fill) - .height(Fill); + let picker = crate::ui::pin_at( + iced::Point::new( + cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, + cube_y + VIEWCUBE_HIT_SIZE + 6.0, + ), + iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), + ); viewport_stack = viewport_stack.push(picker); } } @@ -897,15 +886,10 @@ impl OpenCADStudio { let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); // Cube hit area + nav controls (home / roll / nudge) as one layer. - let controls = column![ - Space::new().height(iced::Length::Fixed(cube_y)), - row![ - Space::new().width(iced::Length::Fixed(cube_x)), - viewcube_nav_controls(), - ], - ] - .width(Fill) - .height(Fill); + let controls = crate::ui::pin_at( + iced::Point::new(cube_x, cube_y), + viewcube_nav_controls(), + ); viewport_stack = viewport_stack.push(controls); // WCS / named-UCS selector under the cube. @@ -922,15 +906,13 @@ impl OpenCADStudio { .map(|u| u.name.clone()) .filter(|n| !n.is_empty()) .collect(); - let picker = column![ - Space::new().height(iced::Length::Fixed(cube_y + VIEWCUBE_HIT_SIZE + 6.0)), - row![ - Space::new().width(iced::Length::Fixed(cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5)), - iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), - ], - ] - .width(Fill) - .height(Fill); + let picker = crate::ui::pin_at( + iced::Point::new( + cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, + cube_y + VIEWCUBE_HIT_SIZE + 6.0, + ), + iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), + ); viewport_stack = viewport_stack.push(picker); } @@ -960,56 +942,47 @@ impl OpenCADStudio { for (idx, item) in popup.items.iter().enumerate() { let is_sel = idx == popup.selected; let label = item.label; - let btn = button(text(label).size(12).color(Color::WHITE)) + let btn = button(text(label).size(12)) .on_press(Message::GripMenuPick(idx)) .padding([3, 10]) .width(Fill) - .style(move |_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match (is_sel, status) { - (true, _) => Color { - r: 0.20, - g: 0.45, - b: 0.95, - a: 1.0, - }, - (_, iced::widget::button::Status::Hovered) => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match (is_sel, status) { + (true, _) => Some(palette.primary.strong), + (_, iced::widget::button::Status::Hovered) => { + Some(palette.background.strong) + } + _ => None, + }; + iced::widget::button::Style { + background: pair.map(|p| Background::Color(p.color)), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, - text_color: Color::WHITE, + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text), ..Default::default() + } }); col = col.push(btn); } let menu_panel = container(col) .padding(2) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.95, - })), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: Color { - r: 0.40, - g: 0.40, - b: 0.40, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }); // Offset the menu by 12 px so the cursor doesn't land on // the first item immediately, matching the right-click @@ -1035,14 +1008,14 @@ impl OpenCADStudio { for (idx, name) in popup.items.iter().enumerate() { let is_cur = popup.current == Some(idx); let mark: Element<'_, Message> = if is_cur { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, Color::WHITE) + crate::ui::icons::themed_check_cell(true) } else { Space::new().width(11).into() }; let btn = button( row![ container(mark).width(16), - text(name).size(12).color(Color::WHITE), + text(name).size(12), ] .spacing(2) .align_y(iced::Center), @@ -1050,47 +1023,39 @@ impl OpenCADStudio { .on_press(Message::VisibilityPick(idx)) .padding([3, 10]) .width(Fill) - .style(move |_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match status { - iced::widget::button::Status::Hovered => Color { - r: 0.20, - g: 0.45, - b: 0.95, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + iced::widget::button::Style { + background: matches!( + status, + iced::widget::button::Status::Hovered + ) + .then_some(Background::Color(palette.primary.weak.color)), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, - text_color: Color::WHITE, + text_color: palette.background.base.text, ..Default::default() + } }); col = col.push(btn); } let panel = container(iced::widget::scrollable(col).height(iced::Length::Shrink)) .max_height(360.0) .padding(2) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.95, - })), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: Color { - r: 0.40, - g: 0.40, - b: 0.40, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }); let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); viewport_stack = @@ -1157,42 +1122,27 @@ impl OpenCADStudio { } else { trace }; - let perf_button_style = |_: &Theme, status: button::Status| button::Style { - background: Some(Background::Color(if matches!(status, button::Status::Hovered) { - Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, - } + let perf_button_style = |theme: &Theme, status: button::Status| { + let palette = theme.extended_palette(); + let pair = if matches!(status, button::Status::Hovered) { + palette.background.strong } else { - Color { - r: 0.14, - g: 0.14, - b: 0.14, - a: 1.0, - } - })), - text_color: Color::WHITE, + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }; let copy_btn = button( row![ - crate::ui::icons::tinted( - crate::ui::icons::COPY, - 11.0, - Color::from_rgb(0.7, 0.85, 1.0), - ), + crate::ui::icons::themed_primary(crate::ui::icons::COPY, 11.0), text("Copy").size(11), ] .spacing(4) @@ -1203,11 +1153,7 @@ impl OpenCADStudio { .padding([2, 6]); let clear_btn = button( row![ - crate::ui::icons::tinted( - crate::ui::icons::TRASH, - 11.0, - Color::from_rgb(1.0, 0.55, 0.55), - ), + crate::ui::icons::themed_danger(crate::ui::icons::TRASH, 11.0), text("Clear").size(11), ] .spacing(4) @@ -1217,20 +1163,24 @@ impl OpenCADStudio { .style(perf_button_style) .padding([2, 6]); let header = row![ - text("PERF").size(12).color(Color::from_rgb(0.6, 1.0, 0.6)), + text("PERF").size(12).style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().success.base.color), + }), Space::new().width(iced::Length::Fill), copy_btn, clear_btn, ] .spacing(6) .align_y(iced::Center); - let log = scrollable(text(trace).size(11).color(Color::from_rgb(0.8, 0.9, 0.8))) + let log = scrollable(text(trace).size(11)) .height(iced::Length::Fixed(220.0)) .width(iced::Length::Fill); let panel = container( column![ header, - text(summary).size(11).color(Color::from_rgb(0.6, 1.0, 0.6)), + text(summary).size(11).style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().success.base.color), + }), log, ] .spacing(5), @@ -1429,13 +1379,26 @@ impl OpenCADStudio { if let Some(be) = &tab.block_edit { displayed_layouts.push(be.block_name.clone()); } + let status_menu_data = crate::ui::statusbar::StatusMenuData { + layout_names: layout_names.clone(), + polar_custom_input: &self.polar_custom_input, + scale_is_model: is_model, + scale_list: tab.scene.scale_list(), + has_selection: !tab.scene.selected.is_empty(), + selection_types: tab + .scene + .entity_type_names_in_layout() + .into_iter() + .map(|name| name.to_string()) + .collect(), + selection_filter: &tab.scene.selection_filter, + tooltip_hidden: self.status_menu_tooltip_hidden, + }; self.status_bar.view( &self.snapper, - self.snap_popup_open, self.ortho_mode, self.polar_mode, self.polar_increment_deg, - self.polar_popup_open, self.dyn_input, self.snapper.otrack_enabled, displayed_layouts, @@ -1451,7 +1414,6 @@ impl OpenCADStudio { tab.scene.active_viewport.is_some(), self.show_layout_tabs, tab.scene.annotation_scale, - self.scale_popup_open, scale_pill_enabled, tab.scene.document.header.lineweight_display, cursor_coord, @@ -1460,127 +1422,27 @@ impl OpenCADStudio { picking, self.clean_screen, tab.scene.document.header.insertion_units, - self.units_popup_open, tab.scene.is_isolation_active(), tab.scene.transparency_display, self.quick_properties, tab.scene.selection_filter_active(), self.selection_cycling, &self.statusbar_config, + status_menu_data, ) }) .width(Fill) .height(Fill) }) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.11, - g: 0.11, - b: 0.11, - a: 1.0, - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) .height(Fill); - let win = self.win_size; - let sb_pill = crate::ui::wrap_bar::dropdown_bounds; - - let snap_layer: Element<'_, Message> = if self.snap_popup_open { - crate::ui::popup::snap_popup::snap_popup_overlay( - &self.snapper, - sb_pill(crate::ui::statusbar::SB_OSNAP_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let scale_layer: Element<'_, Message> = if self.scale_popup_open { - let is_model = tab.scene.current_layout == "Model"; - crate::ui::popup::scale_popup::scale_popup_overlay( - is_model, - tab.scene.annotation_scale, - tab.scene.first_viewport_scale(), - tab.scene.scale_list(), - sb_pill(crate::ui::statusbar::SB_SCALE_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let polar_layer: Element<'_, Message> = if self.polar_popup_open { - crate::ui::popup::polar_popup::polar_popup_overlay( - self.polar_increment_deg, - &self.polar_custom_input, - sb_pill(crate::ui::statusbar::SB_POLAR_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let statusbar_menu_layer: Element<'_, Message> = if self.statusbar_menu_open { - crate::ui::statusbar::statusbar_menu::statusbar_menu_overlay( - &self.statusbar_config, - sb_pill(crate::ui::statusbar::SB_MENU_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let layout_list_layer: Element<'_, Message> = if self.layout_list_open && !tab.is_start { - crate::ui::statusbar::statusbar_menu::layout_list_overlay( - &tab.scene.layout_names(), - &tab.scene.current_layout, - sb_pill(crate::ui::statusbar::SB_LAYOUTLIST_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let units_layer: Element<'_, Message> = if self.units_popup_open { - crate::ui::popup::units_popup::units_popup_overlay( - tab.scene.document.header.insertion_units, - sb_pill(crate::ui::statusbar::SB_UNITS_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let isolate_layer: Element<'_, Message> = if self.isolate_popup_open { - crate::ui::popup::isolate_popup::isolate_popup_overlay( - !tab.scene.selected.is_empty(), - tab.scene.is_isolation_active(), - sb_pill(crate::ui::statusbar::SB_ISOLATE_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let sel_filter_layer: Element<'_, Message> = if self.selection_filter_popup_open { - let types: Vec = tab - .scene - .entity_type_names_in_layout() - .into_iter() - .map(|s| s.to_string()) - .collect(); - crate::ui::popup::selection_filter_popup::selection_filter_popup_overlay( - types, - &tab.scene.selection_filter, - sb_pill(crate::ui::statusbar::SB_FILTER_ID), - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - let dropdown_layer: Element<'_, Message> = self .ribbon .dropdown_overlay( @@ -1590,33 +1452,6 @@ impl OpenCADStudio { ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); - let layout_ctx_layer: Element<'_, Message> = if let Some(name) = &self.layout_context_menu { - layout_context_menu_overlay(name, win) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - - let doc_tab_ctx_layer: Element<'_, Message> = - if let Some(idx) = self.doc_tab_context_menu { - if let Some(context_tab) = self.tabs.get(idx) { - let has_other_drawings = self - .tabs - .iter() - .enumerate() - .any(|(other_idx, tab)| other_idx != idx && !tab.is_start); - doc_tab_context_menu_overlay( - idx, - context_tab.current_path.as_deref(), - has_other_drawings, - win, - ) - } else { - iced::widget::Space::new().width(0).height(0).into() - } - } else { - iced::widget::Space::new().width(0).height(0).into() - }; - let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup { overlay::snap_override_overlay(pos) @@ -1640,17 +1475,7 @@ impl OpenCADStudio { let composed = stack![ main_ui, - snap_layer, - scale_layer, - polar_layer, - statusbar_menu_layer, - layout_list_layer, - units_layer, - isolate_layer, - sel_filter_layer, dropdown_layer, - layout_ctx_layer, - doc_tab_ctx_layer, qselect_layer, snap_override_layer, open_progress_layer, @@ -2041,57 +1866,54 @@ impl OpenCADStudio { // ── Document tab bar ─────────────────────────────────────────────────────── -pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> { - const BAR_BG: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, - }; - const TAB_ACTIVE: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }; - const TAB_HOVER: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, - }; - const TAB_INACTIVE: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, - }; - const ACCENT: Color = Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, - }; - const TEXT_ACTIVE: Color = Color::WHITE; - const TEXT_INACTIVE: Color = Color { - r: 0.60, - g: 0.60, - b: 0.60, - a: 1.0, - }; - const CLOSE_HOVER: Color = Color { - r: 0.70, - g: 0.22, - b: 0.22, - a: 1.0, - }; - const BORDER_COLOR: Color = Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, +/// Right-click actions for a drawing tab. `ContextMenu` owns opening, +/// cursor-relative placement, boundary clamping, and dismissal. +fn doc_tab_context_menu( + tab_idx: usize, + has_current_path: bool, + has_other_drawings: bool, +) -> Element<'static, Message> { + const MENU_W: f32 = 210.0; + + let item = |label: &'static str, msg: Option| { + let mut item = button(text(label).size(12)) + .style(button::subtle) + .padding([4, 12]) + .width(Fill); + if let Some(msg) = msg { + item = item.on_press(msg); + } + item }; + let native_path_actions = cfg!(not(target_arch = "wasm32")) && has_current_path; + container( + column![ + item("Save All", Some(Message::DocTabSaveAll)), + item("Close All", Some(Message::DocTabCloseAll)), + item( + "Close All Other Drawings", + has_other_drawings.then_some(Message::DocTabCloseOthers(tab_idx)), + ), + item( + "Copy Full File Path", + native_path_actions.then_some(Message::DocTabCopyFullPath(tab_idx)), + ), + item( + "Open File Location", + native_path_actions.then_some(Message::DocTabOpenFileLocation(tab_idx)), + ), + ] + .spacing(0) + .width(MENU_W), + ) + .style(container::bordered_box) + .padding([4, 0]) + .width(iced::Length::Fixed(MENU_W)) + .into() +} + +pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> { // Document tabs live in a flex-wrap flow so they spill onto lower rows when // there are more tabs than the width can hold on one line. let mut items: Vec> = Vec::new(); @@ -2107,16 +1929,7 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24); let title_inner: Element<'_, Message> = if tab.dirty { row![ - crate::ui::icons::tinted( - crate::ui::icons::DOT, - 7.0, - Color { - r: 0.90, - g: 0.75, - b: 0.30, - a: 1.0, - }, - ), + crate::ui::icons::themed_warning(crate::ui::icons::DIRTY_DOT, 14.0), text(name).size(12), ] .spacing(5) @@ -2128,29 +1941,27 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let title_btn = button(title_inner) .on_press(Message::TabSwitch(idx)) - .padding([5, 12]) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => TAB_ACTIVE, - (false, button::Status::Hovered) => TAB_HOVER, - _ => TAB_INACTIVE, - })), - text_color: if is_active { - TEXT_ACTIVE - } else { - TEXT_INACTIVE - }, - border: Border { - color: if is_active { - ACCENT + .height(Fill) + .padding([4, 12]) + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let background = match (is_active, status) { + (false, button::Status::Hovered) => { + Some(Background::Color(palette.background.weak.color)) + } + _ => None, + }; + button::Style { + background, + text_color: if is_active { + palette.primary.weak.text } else { - Color::TRANSPARENT + palette.background.base.text.scale_alpha(0.72) }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 0.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: false, + } }); let title_btn: Element<'_, Message> = if tab.is_start { title_btn.into() @@ -2167,90 +1978,83 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let row_inner: Row<'_, Message> = if tab.is_start { row![title_btn].spacing(0).align_y(iced::Center) } else { - let close_btn = button(crate::ui::icons::tinted( - crate::ui::icons::CLOSE, - 10.0, - Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, - }, - )) - .on_press(Message::TabClose(idx)) - .padding([3, 5]) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => CLOSE_HOVER, - _ => { - if is_active { - TAB_ACTIVE - } else { - TAB_INACTIVE - } - } - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - }); - row![title_btn, close_btn].spacing(0).align_y(iced::Center) + let close_btn = button(text("Γ—").size(12)) + .on_press(Message::TabClose(idx)) + .height(Fill) + .padding([4, 8]) + .style(button::subtle); + row![title_btn, close_btn] + .spacing(0) + .height(Fill) + .align_y(iced::Center) }; - let tab_container = container(row_inner).style(move |_: &Theme| container::Style { - border: Border { - color: if is_active { - BORDER_COLOR - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 0.0.into(), - }, - ..Default::default() - }); + let tab_container = container(row_inner) + .height(iced::Length::Fixed(23.0)) + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(if is_active { + palette.primary.weak.color + } else { + palette.background.base.color + })), + border: Border { + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 0.0.into(), + }, + ..Default::default() + } + }); let tab_element: Element<'_, Message> = if tab.is_start { tab_container.into() } else { + let has_current_path = tab.current_path.is_some(); + let has_other_drawings = drag_targets.len() > 1; + let tab_target: Element<'_, Message> = if let Some(path) = &tab.current_path { + iced::widget::tooltip( + tab_container, + container(text(path.to_string_lossy().into_owned()).size(11)) + .style(container::bordered_box) + .padding([4, 8]), + iced::widget::tooltip::Position::Bottom, + ) + .gap(4) + .into() + } else { + tab_container.into() + }; crate::ui::wrap_bar::PosReport::owned( format!("DOC_TAB:{idx}"), - mouse_area(tab_container).on_right_press(Message::DocTabContextMenu(idx)), + ContextMenu::new(tab_target, move || { + doc_tab_context_menu(idx, has_current_path, has_other_drawings) + }), ) .into() }; items.push(tab_element); } - let new_btn = button(text("+").size(14).color(Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, - })) - .on_press(Message::TabNew) - .padding([4, 10]) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => TAB_HOVER, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 0.0.into(), - ..Default::default() - }, - ..Default::default() - }); + let new_btn = button(text("+").size(14)) + .on_press(Message::TabNew) + .padding([4, 10]) + .style(button::subtle); items.push(new_btn.into()); container(WrapFlow::new(items).spacing_x(0.0).row_h(30.0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BAR_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), border: Border { - color: BORDER_COLOR, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -2279,21 +2083,17 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele // fixed Start tab (`DocumentTab::is_start`). English-only by design β€” this // is the public welcome screen and stays consistent across locales. // -// The page picks up the application icon's red-brown (#B03020) as a tint so -// it visually belongs to OpenCADStudio without overpowering the dark workspace. +fn start_muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} -const BRAND: Color = Color { - r: 0.690, - g: 0.188, - b: 0.125, - a: 1.0, -}; // #B03020 -const BRAND_DARK: Color = Color { - r: 0.45, - g: 0.12, - b: 0.08, - a: 1.0, -}; +fn start_primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + } +} /// Transparent input layer for one Model pane: a `mouse_area` filling the pane /// that emits pane-tagged viewport events (`idx` = the pane's tile index). The @@ -2315,7 +2115,6 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { /// Canvas that draws a label rotated 90Β° (for a collapsed panel's bar). struct VBarLabel { text: String, - color: Color, } impl canvas::Program for VBarLabel { @@ -2325,7 +2124,7 @@ impl canvas::Program for VBarLabel { &self, _state: &(), renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: iced::Rectangle, _cursor: iced::advanced::mouse::Cursor, ) -> Vec { @@ -2336,7 +2135,7 @@ impl canvas::Program for VBarLabel { frame.fill_text(canvas::Text { content: self.text.clone(), position: iced::Point::ORIGIN, - color: self.color, + color: theme.extended_palette().background.base.text.scale_alpha(0.72), size: iced::Pixels(13.0), align_x: iced::advanced::text::Alignment::Center, align_y: iced::alignment::Vertical::Center, @@ -2351,28 +2150,8 @@ impl canvas::Program for VBarLabel { /// A collapsed panel rendered as a tall narrow bar with its name written along /// it, rotated 90Β°. Pressing it emits `on_press`. pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Message> { - const LABEL: Color = Color { - r: 0.72, - g: 0.72, - b: 0.74, - a: 1.0, - }; - const BAR_BG: Color = Color { - r: 0.13, - g: 0.13, - b: 0.14, - a: 1.0, - }; - const BAR_BORDER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.24, - a: 1.0, - }; - let label = canvas(VBarLabel { text: name.to_string(), - color: LABEL, }) .width(Fill) .height(Fill); @@ -2381,10 +2160,12 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes container(label) .width(iced::Length::Fixed(26.0)) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BAR_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), border: Border { - color: BAR_BORDER, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -2401,6 +2182,8 @@ pub(super) fn start_page_view<'a>( videos: &'a [crate::videos::VideoEntry], videos_loading: bool, video_thumbs: &'a std::collections::HashMap, + discussions: &'a [crate::discussions::DiscussionEntry], + discussions_loading: bool, recents: &'a [std::path::PathBuf], thumbs: &'a std::collections::HashMap< std::path::PathBuf, @@ -2409,70 +2192,41 @@ pub(super) fn start_page_view<'a>( recent_limit: usize, recent_limit_input: &'a str, avail_w: f32, + action_width_out: std::sync::Arc, active: super::StartSection, ) -> Element<'a, Message> { - const TEXT: Color = Color { - r: 0.94, - g: 0.93, - b: 0.92, - a: 1.0, - }; - const CARD_BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.13, - a: 1.0, - }; - const CARD_BORDER: Color = Color { - r: 0.20, - g: 0.20, - b: 0.22, - a: 1.0, - }; - - let headline = text("Open CAD Studio").size(40).color(BRAND); + let headline = text("Open CAD Studio").size(40).style(start_primary_style); // Plain outlined button (Open / New / Help / Contribute). let outline_btn = |label: &'static str, msg: Message| { - button(text(label).size(14).color(TEXT)) + button(text(label).size(14)) .on_press(msg) .padding([10, 22]) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }, - _ => Color { - r: 0.13, - g: 0.13, - b: 0.15, - a: 1.0, - }, - })), - text_color: TEXT, + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered => palette.background.strong, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: Color { - r: 0.30, - g: 0.30, - b: 0.33, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() + } }) }; - // Donate β€” the prominent call-to-action. Solid brand fill, white text. + // Donate β€” the prominent call-to-action, using the theme's danger role. let donate_btn = { button( row![ - crate::ui::icons::tinted(crate::ui::icons::HEART, 14.0, Color::WHITE), - text("Donate").size(14).color(Color::WHITE), + crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 14.0), + text("Donate").size(14), ] .spacing(5) .align_y(iced::Center), @@ -2482,29 +2236,7 @@ pub(super) fn start_page_view<'a>( event: crate::modules::ModuleEvent::Command("DONATE".to_string()), }) .padding([12, 28]) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => BRAND_DARK, - _ => BRAND, - })), - text_color: Color::WHITE, - border: Border { - color: BRAND_DARK, - width: 1.0, - radius: 6.0.into(), - }, - shadow: iced::Shadow { - color: Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.4, - }, - offset: iced::Vector::new(0.0, 2.0), - blur_radius: 6.0, - }, - ..Default::default() - }) + .style(button::danger) }; let primary_row = WrapFlow::new(vec![ @@ -2513,7 +2245,8 @@ pub(super) fn start_page_view<'a>( donate_btn.into(), ]) .spacing_x(12.0) - .row_h(48.0); + .row_h(48.0) + .report_natural_width(action_width_out.clone()); #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut secondary_items: Vec> = vec![ @@ -2532,80 +2265,28 @@ pub(super) fn start_page_view<'a>( // link to the web version. #[cfg(not(target_arch = "wasm32"))] { - // Bright ribbon blue (matches the active-tool accent), filled. + // Filled with the active theme's primary colour. secondary_items.push( - button(text("OCS Web").size(14).color(Color::WHITE)) + button(text("OCS Web").size(14)) .on_press(Message::RibbonToolClick { tool_id: "WEBVERSION".to_string(), event: crate::modules::ModuleEvent::Command("WEBVERSION".to_string()), }) .padding([10, 22]) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.15, - g: 0.45, - b: 0.78, - a: 1.0, - }, - _ => Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, - }, - })), - text_color: Color::WHITE, - border: Border { - color: Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, - }, - width: 1.0, - radius: 6.0.into(), - }, - ..Default::default() - }) + .style(button::primary) .into(), ); } let secondary_row = WrapFlow::new(secondary_items) .spacing_x(12.0) - .row_h(44.0); - - // Buttons sit on a transparent container with a large, brand-tinted - // ambient shadow (offset = 0, big blur) β€” produces a soft halo behind - // the action row, matching the Thunderbird coloured-glow look against - // the dark page. - let primary_glow = container(primary_row) - .padding(iced::Padding { - top: 4.0, - right: 8.0, - bottom: 4.0, - left: 8.0, - }) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - shadow: iced::Shadow { - color: Color { - r: BRAND.r, - g: BRAND.g, - b: BRAND.b, - a: 0.45, - }, - offset: iced::Vector::ZERO, - blur_radius: 80.0, - }, - ..Default::default() - }); + .row_h(44.0) + .report_natural_width(action_width_out.clone()); let content = column![ Space::new().height(iced::Length::Fixed(28.0)), container(headline).center_x(Fill), Space::new().height(iced::Length::Fixed(22.0)), - container(primary_glow).center_x(Fill), + container(primary_row).center_x(Fill), Space::new().height(iced::Length::Fixed(10.0)), container(secondary_row).center_x(Fill), Space::new().height(Fill), @@ -2614,34 +2295,27 @@ pub(super) fn start_page_view<'a>( .width(Fill) .height(Fill); - // Page background reverts to plain dark β€” the glow alone provides the - // brand colour cue, the rest of the page stays neutral so it reads as - // "workspace area" not "advertising banner". - const PAGE_BG: Color = Color { - r: 0.08, - g: 0.08, - b: 0.085, - a: 1.0, - }; // Collapse side panels one at a time as width shrinks: Tutorials first, - // then Supporters, and Recent Documents last. The previous all-or-nothing - // threshold reserved a videos-sized empty margin on both sides of the - // centred Welcome block and hid every panel around 1664 px. + // then Discussions, Supporters, and Recent Documents last. #[derive(Clone, Copy, PartialEq, Eq)] enum StartLayout { AllPanels, WithoutVideos, + WithoutVideosAndDiscussions, RecentAndWelcome, Compact, } - let recent_w = 280.0f32; - let videos_w = 300.0f32; - let sup_w = 240.0f32; - let welcome_wide_min = 360.0f32; + let panel_w = 280.0f32; + const VIDEO_PANEL_PADDING: f32 = 16.0; + const VIDEO_SCROLL_GUTTER: f32 = 14.0; + let measured_action_w = f32::from_bits( + action_width_out.load(std::sync::atomic::Ordering::Relaxed) + ); + let welcome_wide_min = measured_action_w.max(360.0); let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding - let panel_widths = [recent_w, videos_w, sup_w]; - let mut panel_visible = [true, true, true]; - let required_width = |visible: &[bool; 3]| { + let panel_widths = [panel_w; 4]; + let mut panel_visible = [true, true, true, true]; + let required_width = |visible: &[bool; 4]| { let visible_panels = visible.iter().filter(|&&shown| shown).count(); welcome_wide_min + panel_widths @@ -2653,16 +2327,17 @@ pub(super) fn start_page_view<'a>( }; // Re-measure after every collapse. There are no independent breakpoints: // the available width and the panels' preferred widths decide the state. - for panel in [1usize, 2, 0] { + for panel in [1usize, 2, 3, 0] { if required_width(&panel_visible) <= avail { break; } panel_visible[panel] = false; } let start_layout = match panel_visible { - [true, true, true] => StartLayout::AllPanels, - [true, false, true] => StartLayout::WithoutVideos, - [true, false, false] => StartLayout::RecentAndWelcome, + [true, true, true, true] => StartLayout::AllPanels, + [true, false, true, true] => StartLayout::WithoutVideos, + [true, false, false, true] => StartLayout::WithoutVideosAndDiscussions, + [true, false, false, false] => StartLayout::RecentAndWelcome, _ => StartLayout::Compact, }; @@ -2674,7 +2349,8 @@ pub(super) fn start_page_view<'a>( match start_layout { StartLayout::AllPanels | StartLayout::WithoutVideos - | StartLayout::RecentAndWelcome => iced::Length::Fixed(recent_w), + | StartLayout::WithoutVideosAndDiscussions + | StartLayout::RecentAndWelcome => iced::Length::Fixed(panel_w), StartLayout::Compact => iced::Length::Fill, }, ); @@ -2683,20 +2359,16 @@ pub(super) fn start_page_view<'a>( // Tutorial-videos rail: the official playlist, fetched at boot (cached on // disk) β€” thumbnail card + title per video, click opens the browser. let videos_panel: Element<'a, Message> = { - const NAME_COLOR: Color = Color { - r: 0.80, - g: 0.80, - b: 0.82, - a: 1.0, - }; - // Inner width = panel 300 βˆ’ padding 2Γ—16 βˆ’ scrollbar gutter 14. - const THUMB_H: f32 = (300.0 - 32.0 - 14.0) * 9.0 / 16.0; - let mut list = column![text("Tutorials").size(15).color(TEXT)] + // Derive the 16:9 cover box from the actual shared list width so the + // whole thumbnail remains visible when that width changes. + let thumb_h = + (panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0; + let mut list = column![text("Tutorials").size(15)] .spacing(10) .width(Fill) // Keep the scrollbar off the thumbnails. .padding(iced::Padding { - right: 14.0, + right: VIDEO_SCROLL_GUTTER, ..iced::Padding::ZERO }); for v in videos { @@ -2706,14 +2378,14 @@ pub(super) fn start_page_view<'a>( container( iced::widget::image(handle.clone()) .width(Fill) - .height(iced::Length::Fixed(THUMB_H)) - .content_fit(iced::ContentFit::Cover), + .height(iced::Length::Fixed(thumb_h)) + .content_fit(iced::ContentFit::Contain), ) .width(Fill) - .height(iced::Length::Fixed(THUMB_H)) - .style(|_: &Theme| container::Style { + .height(iced::Length::Fixed(thumb_h)) + .style(|theme: &Theme| container::Style { border: Border { - color: CARD_BORDER, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 6.0.into(), }, @@ -2722,7 +2394,7 @@ pub(super) fn start_page_view<'a>( .clip(true), ); } - card = card.push(text(v.title.clone()).size(12).color(NAME_COLOR)); + card = card.push(text(v.title.clone()).size(12).style(start_muted_style)); list = list.push( mouse_area(card) .interaction(iced::mouse::Interaction::Pointer) @@ -2735,28 +2407,25 @@ pub(super) fn start_page_view<'a>( } else { "Videos load from the internet." }; - list = list.push(text(note).size(12).color(NAME_COLOR)); + list = list.push(text(note).size(12).style(start_muted_style)); } let playlist_btn = mouse_area( - container( - text("Open playlist on YouTube").size(12).color(Color::WHITE), - ) + container(text("Open playlist on YouTube").size(12)) .padding([6, 10]) .width(Fill) .center_x(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.85, - g: 0.15, - b: 0.15, - a: 1.0, - })), + .style(|theme: &Theme| { + let pair = theme.extended_palette().danger.base; + container::Style { + background: Some(Background::Color(pair.color)), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into(), }, + text_color: Some(pair.text), ..Default::default() + } }), ) .interaction(iced::mouse::Interaction::Pointer) @@ -2767,21 +2436,144 @@ pub(super) fn start_page_view<'a>( playlist_btn, ]) .width(match start_layout { - StartLayout::AllPanels => iced::Length::Fixed(videos_w), + StartLayout::AllPanels => iced::Length::Fixed(panel_w), StartLayout::WithoutVideos + | StartLayout::WithoutVideosAndDiscussions + | StartLayout::RecentAndWelcome + | StartLayout::Compact => iced::Length::Fill, + }) + .height(Fill) + .padding(VIDEO_PANEL_PADDING) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into() + }; + + // GitHub Discussions rail. Native builds refresh from GitHub's public feed; + // web builds read the CI-generated snapshot. Both sources mark pinned + // discussions and sort them before the rest of the list. + let discussions_panel: Element<'a, Message> = { + let mut list = column![text("Discussions").size(15)] + .spacing(8) + .width(Fill); + for discussion in discussions { + let mut meta = iced::widget::row![ + text(format!("#{}", discussion.number)) + .size(10) + .style(start_muted_style), + ] + .spacing(6) + .align_y(iced::Center); + if discussion.pinned { + meta = meta.push( + text("Pinned") + .size(10) + .style(start_primary_style), + ); + } + if !discussion.author.is_empty() { + meta = meta.push( + text(format!("@{}", discussion.author)) + .size(10) + .style(start_muted_style), + ); + } + let card = container( + column![ + text(discussion.title.clone()).size(12), + meta, + ] + .spacing(4), + ) + .padding([8, 10]) + .width(Fill) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.42), + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); + list = list.push( + mouse_area(card) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl(discussion.url.clone())), + ); + } + if discussions.is_empty() { + let note = if discussions_loading { + "Loading discussions…" + } else { + "Discussions load from GitHub." + }; + list = list.push(text(note).size(12).style(start_muted_style)); + } + let open_btn = mouse_area( + container(text("Open Discussions on GitHub").size(12)) + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|theme: &Theme| { + let pair = theme.extended_palette().primary.base; + container::Style { + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() + } + }), + ) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl( + crate::discussions::DISCUSSIONS_URL.to_string(), + )); + container(column![ + iced::widget::scrollable(list).height(Fill), + Space::new().height(iced::Length::Fixed(12.0)), + open_btn, + ]) + .width(match start_layout { + StartLayout::AllPanels | StartLayout::WithoutVideos => { + iced::Length::Fixed(panel_w) + } + StartLayout::WithoutVideosAndDiscussions | StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, }) .height(Fill) .padding(16) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD_BG)), - border: Border { - color: CARD_BORDER, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } }) .into() }; @@ -2790,14 +2582,8 @@ pub(super) fn start_page_view<'a>( // (no token configured / offline) only the "Support on Patreon" button // shows, so the rail always invites support. let supporters: Element<'a, Message> = { - const NAME_COLOR: Color = Color { - r: 0.78, - g: 0.78, - b: 0.80, - a: 1.0, - }; let mut list = column![ - text("Supporters").size(15).color(TEXT), + text("Supporters").size(15), Space::new().height(iced::Length::Fixed(12.0)), ] .spacing(6) @@ -2808,8 +2594,8 @@ pub(super) fn start_page_view<'a>( let amount = format!("${:.2}", *cents as f64 / 100.0); list = list.push( iced::widget::row![ - text(name).size(12).color(NAME_COLOR).width(Fill), - text(amount).size(12).color(NAME_COLOR), + text(name).size(12).style(start_muted_style).width(Fill), + text(amount).size(12).style(start_muted_style), ] .spacing(6), ); @@ -2817,8 +2603,8 @@ pub(super) fn start_page_view<'a>( let support_btn = mouse_area( container( iced::widget::row![ - crate::ui::icons::tinted(crate::ui::icons::HEART, 13.0, Color::WHITE), - text("Support on Patreon").size(12).color(Color::WHITE), + crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 13.0), + text("Support on Patreon").size(12), ] .spacing(6) .align_y(iced::Center), @@ -2826,19 +2612,18 @@ pub(super) fn start_page_view<'a>( .padding([6, 10]) .width(Fill) .center_x(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.90, - g: 0.28, - b: 0.30, - a: 1.0, - })), + .style(|theme: &Theme| { + let pair = theme.extended_palette().danger.base; + container::Style { + background: Some(Background::Color(pair.color)), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into(), }, + text_color: Some(pair.text), ..Default::default() + } }), ) .interaction(iced::mouse::Interaction::Pointer) @@ -2851,22 +2636,27 @@ pub(super) fn start_page_view<'a>( support_btn, ]) .width(match start_layout { - StartLayout::AllPanels | StartLayout::WithoutVideos => { - iced::Length::Fixed(sup_w) + StartLayout::AllPanels + | StartLayout::WithoutVideos + | StartLayout::WithoutVideosAndDiscussions => { + iced::Length::Fixed(panel_w) } StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, }) .height(Fill) .padding(20) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: CARD_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 8.0.into(), }, ..Default::default() + } }) .into() }; @@ -2876,12 +2666,19 @@ pub(super) fn start_page_view<'a>( recent, videos_panel, welcome, + discussions_panel, supporters, ] .spacing(16) .height(Fill) .into(), StartLayout::WithoutVideos => { + iced::widget::row![recent, welcome, discussions_panel, supporters] + .spacing(16) + .height(Fill) + .into() + } + StartLayout::WithoutVideosAndDiscussions => { iced::widget::row![recent, welcome, supporters] .spacing(16) .height(Fill) @@ -2897,44 +2694,38 @@ pub(super) fn start_page_view<'a>( button(text(label).size(14)) .on_press(Message::StartSectionSelect(section)) .padding([8, 18]) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }, - (false, button::Status::Hovered) => Color { - r: 0.14, - g: 0.14, - b: 0.16, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - text_color: if is_active { - TEXT - } else { - Color { - r: 0.62, - g: 0.62, - b: 0.62, - a: 1.0, + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match (is_active, status) { + (true, _) => Some(palette.primary.weak), + (false, button::Status::Hovered) => { + Some(palette.background.strong) } - }, + _ => None, + }; + button::Style { + background: pair.map(|p| Background::Color(p.color)), + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text.scale_alpha(0.68)), border: Border { - color: if is_active { BRAND } else { Color::TRANSPARENT }, + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, width: if is_active { 1.0 } else { 0.0 }, radius: 6.0.into(), }, ..Default::default() + } }) }; let tab_bar = WrapFlow::new(vec![ tab_btn("Recent Files", super::StartSection::Recent).into(), tab_btn("Videos", super::StartSection::Videos).into(), tab_btn("Welcome", super::StartSection::Welcome).into(), + tab_btn("Discussions", super::StartSection::Discussions).into(), tab_btn("Supporters", super::StartSection::Supporters).into(), ]) .spacing_x(6.0) @@ -2951,6 +2742,11 @@ pub(super) fn start_page_view<'a>( .center_x(Fill) .into(), super::StartSection::Welcome => welcome.into(), + super::StartSection::Discussions => container(discussions_panel) + .width(Fill) + .height(Fill) + .center_x(Fill) + .into(), super::StartSection::Supporters => container(supporters) .width(Fill) .height(Fill) @@ -2969,8 +2765,10 @@ pub(super) fn start_page_view<'a>( }; container(body) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PAGE_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .padding(iced::Padding { @@ -3000,44 +2798,16 @@ pub(super) fn recent_files_panel<'a>( limit_input: &'a str, width: iced::Length, ) -> Element<'a, Message> { - // Card chrome matches the Supporters rail (the canonical start-page card). - const PANEL_BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.13, - a: 1.0, - }; - const PANEL_BORDER: Color = Color { - r: 0.20, - g: 0.20, - b: 0.22, - a: 1.0, - }; - const ITEM_HOVER: Color = Color { - r: 0.16, - g: 0.16, - b: 0.18, - a: 1.0, - }; - const TEXT: Color = Color { - r: 0.94, - g: 0.93, - b: 0.92, - a: 1.0, - }; - const MUTED: Color = Color { - r: 0.60, - g: 0.60, - b: 0.62, - a: 1.0, - }; - // Title mirrors the Supporters rail: size 15 in the bright text colour, // followed by a 12px gap before the content. - let title = text("Recent Documents").size(15).color(TEXT); + let title = text("Recent Documents").size(15); let body: Element<'a, Message> = if recents.is_empty() { - container(text("Files you open will show up here.").size(12).color(MUTED)) + container( + text("Files you open will show up here.") + .size(12) + .style(start_muted_style) + ) .height(Fill) .into() } else { @@ -3078,11 +2848,10 @@ pub(super) fn recent_files_panel<'a>( thumb, column![ text(crate::ui::text_util::elide(&name, 28)) - .size(12) - .color(TEXT), + .size(12), text(crate::ui::text_util::elide(&dir, 38)) .size(10) - .color(MUTED), + .style(start_muted_style), ] .spacing(2), ] @@ -3092,41 +2861,42 @@ pub(super) fn recent_files_panel<'a>( .on_press(Message::OpenRecent(path_for_open)) .padding([6, 12]) .width(Fill) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT, + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + button::Style { + background: matches!(status, button::Status::Hovered).then_some( + Background::Color(palette.background.strong.color) + ), + text_color: palette.background.base.text, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, ..Default::default() + } }); let path_for_remove = path.clone(); - let remove_btn = button(crate::ui::icons::tinted(crate::ui::icons::CLOSE, 11.0, MUTED)) + let remove_btn = button(crate::ui::icons::themed_secondary( + crate::ui::icons::CLOSE, + 11.0, + )) .on_press(Message::RecentRemove(path_for_remove)) .padding([4, 8]) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.45, - g: 0.15, - b: 0.15, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - text_color: MUTED, + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + button::Style { + background: matches!(status, button::Status::Hovered) + .then_some(Background::Color(palette.danger.weak.color)), + text_color: palette.background.base.text.scale_alpha(0.68), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 3.0.into(), }, ..Default::default() + } }); col = col.push(row![open_btn, remove_btn].spacing(0).align_y(iced::Center)); @@ -3139,18 +2909,20 @@ pub(super) fn recent_files_panel<'a>( // update handler clamps to [MIN, MAX] and persists (see `set_recent_limit`), // so an over-max entry snaps to the max. const STEP: usize = 5; - let step_style = |_: &Theme, status: button::Status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT, + let step_style = |theme: &Theme, status: button::Status| { + let palette = theme.extended_palette(); + button::Style { + background: matches!(status, button::Status::Hovered).then_some( + Background::Color(palette.background.strong.color) + ), + text_color: palette.background.base.text, border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } }; // +/- step from whatever is currently shown in the box (mid-edit included). let shown = limit_input.parse::().unwrap_or(limit); @@ -3161,19 +2933,19 @@ pub(super) fn recent_files_panel<'a>( .padding([2, 6]) .width(iced::Length::Fixed(46.0)); let limit_row = row![ - text("Keep recent files").size(11).color(MUTED).width(Fill), - button(crate::ui::icons::tinted(crate::ui::icons::MINUS, 11.0, TEXT)) + text("Keep recent files").size(11).style(start_muted_style).width(Fill), + button(crate::ui::icons::themed(crate::ui::icons::MINUS, 11.0)) .on_press(Message::SetRecentLimit(shown.saturating_sub(STEP))) .padding([3, 6]) .style(step_style), count_box, - button(crate::ui::icons::tinted(crate::ui::icons::PLUS, 11.0, TEXT)) + button(crate::ui::icons::themed(crate::ui::icons::PLUS, 11.0)) .on_press(Message::SetRecentLimit(shown + STEP)) .padding([3, 6]) .style(step_style), text(format!("/ {}", super::recent::RECENT_MAX)) .size(11) - .color(MUTED), + .style(start_muted_style), ] .spacing(6) .align_y(iced::Center); @@ -3191,14 +2963,17 @@ pub(super) fn recent_files_panel<'a>( .width(width) .height(Fill) .padding(20) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 8.0.into(), }, ..Default::default() + } }) .into() } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 45a2eea02..9e4f6b049 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -1,6 +1,6 @@ use super::super::{Message, OpenCADStudio}; use iced::widget::{button, column, container, pick_list, row, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Element, Fill, Theme}; impl OpenCADStudio { /// Title shown in the active modal's title bar, left of the move/close @@ -63,9 +63,13 @@ impl OpenCADStudio { sized(crate::ui::window::alias_editor::view_window(&self.alias_editor_rows), 480, 520) } super::super::ModalKind::Options => sized( - crate::ui::window::options::view_window(&self.default_save_format), - 480, - 190, + crate::ui::window::options::view_window( + &self.default_save_format, + &self.ui_theme, + &self.theme_color_inputs, + ), + 520, + 500, ), super::super::ModalKind::PluginManager => sized( crate::ui::window::plugin_manager::view_window( @@ -712,125 +716,56 @@ impl OpenCADStudio { } } +fn dialog_button( + label: &'static str, + message: Message, + style: fn(&Theme, button::Status) -> button::Style, +) -> Element<'static, Message> { + button(text(label).size(13)) + .on_press(message) + .style(style) + .padding([6, 18]) + .into() +} + +fn dialog_body_style(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: Some(palette.background.base.text), + ..Default::default() + } +} + +fn dialog_muted_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + /// Compact Save-As options dialog: pick the format/version and a default file /// name. The destination folder and overwrite confirmation come from the /// native OS save dialog (native) or the browser download (web) that follows. fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, Message> { - const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.17, - a: 1.0, - }; - const BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.36, - a: 1.0, - }; - const TEXT: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const DIM: Color = Color { - r: 0.58, - g: 0.58, - b: 0.62, - a: 1.0, - }; - const BTN_OK: Color = Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 1.0, - }; - const BTN_HOV: Color = Color { - r: 0.26, - g: 0.55, - b: 0.92, - a: 1.0, - }; - const BTN_GREY: Color = Color { - r: 0.26, - g: 0.26, - b: 0.29, - a: 1.0, - }; - const BTN_GHOV: Color = Color { - r: 0.34, - g: 0.34, - b: 0.38, - a: 1.0, - }; - - let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| { - button(text(lbl).size(12).color(TEXT)) - .on_press(msg) - .style(move |_: &Theme, st| button::Style { - background: Some(Background::Color( - if matches!(st, button::Status::Hovered | button::Status::Pressed) { - hov - } else { - base - }, - )), - text_color: TEXT, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) - .padding([4, 12]) - }; - let sel_fmt = crate::io::SAVE_FORMAT_OPTIONS .iter() .copied() .find(|&s| s == format); - let label = |s: &'static str| text(s).size(11).color(DIM); + let label = |s: &'static str| text(s).size(11).style(dialog_muted_text_style); let mut items: Vec> = Vec::new(); - items.push(text("Save Drawing As").size(14).color(TEXT).into()); + items.push(text("Save Drawing As").size(14).into()); items.push(Space::new().height(12).into()); // Web has no native file dialog, so the file name is typed here. On native // the OS save dialog collects the name, so this field is omitted. #[cfg(target_arch = "wasm32")] { - const INPUT_BG: Color = Color { - r: 0.10, - g: 0.10, - b: 0.12, - a: 1.0, - }; - let input_sty = - |_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style { - background: Background::Color(INPUT_BG), - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 0.45, - }, - }; items.push( row![ label("File name:").width(70), iced::widget::text_input("drawing.dwg", filename) .on_input(Message::SaveDialogFilenameChanged) - .style(input_sty) .size(13) .padding([5, 8]) .width(Fill), @@ -860,9 +795,9 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, items.push( row![ Space::new().width(Fill), - btn("Save as...", Message::SaveDialogConfirm, BTN_OK, BTN_HOV), + dialog_button("Save as...", Message::SaveDialogConfirm, button::primary), Space::new().width(8), - btn("Cancel", Message::SaveDialogCancel, BTN_GREY, BTN_GHOV), + dialog_button("Cancel", Message::SaveDialogCancel, button::secondary), ] .into(), ); @@ -870,10 +805,7 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, let body = column(items).spacing(0); container(body) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .padding([14, 16]) .width(Fill) .height(Fill) @@ -881,89 +813,23 @@ fn save_as_dialog_window<'a>(filename: &'a str, format: &'a str) -> Element<'a, } fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { - const BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }; - const BORDER_COL: Color = Color { - r: 0.38, - g: 0.38, - b: 0.42, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const BTN_SAVE: Color = Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 1.0, - }; - const BTN_HOVER: Color = Color { - r: 0.26, - g: 0.55, - b: 0.92, - a: 1.0, - }; - const BTN_DISC: Color = Color { - r: 0.28, - g: 0.28, - b: 0.30, - a: 1.0, - }; - const BTN_DHOV: Color = Color { - r: 0.36, - g: 0.36, - b: 0.40, - a: 1.0, - }; - let body_text = format!("Do you want to save changes to \"{}\"?", name); - let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hov, - _ => base, - })), - text_color: TEXT_COL, - border: Border { - color: BORDER_COL, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([6, 18]) - }; - container( column![ - text(body_text).size(13).color(TEXT_COL), + text(body_text).size(13), iced::widget::Space::new().height(20), row![ - btn("Save", Message::UnsavedDialogSave, BTN_SAVE, BTN_HOVER), + dialog_button("Save", Message::UnsavedDialogSave, button::primary), iced::widget::Space::new().width(8), - btn("Discard", Message::UnsavedDialogDiscard, BTN_DISC, BTN_DHOV), + dialog_button("Discard", Message::UnsavedDialogDiscard, button::danger), iced::widget::Space::new().width(8), - btn("Cancel", Message::UnsavedDialogCancel, BTN_DISC, BTN_DHOV), + dialog_button("Cancel", Message::UnsavedDialogCancel, button::secondary), ], ] .spacing(0), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .center(Fill) .padding([24, 28]) .into() @@ -971,55 +837,6 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { #[cfg(not(target_arch = "wasm32"))] fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Message> { - const BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }; - const BORDER_COL: Color = Color { - r: 0.38, - g: 0.38, - b: 0.42, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const DIM_COL: Color = Color { - r: 0.62, - g: 0.62, - b: 0.66, - a: 1.0, - }; - const BTN_PRIMARY: Color = Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 1.0, - }; - const BTN_PRIMARY_HOVER: Color = Color { - r: 0.26, - g: 0.55, - b: 0.92, - a: 1.0, - }; - const BTN_SECONDARY: Color = Color { - r: 0.28, - g: 0.28, - b: 0.30, - a: 1.0, - }; - const BTN_SECONDARY_HOVER: Color = Color { - r: 0.36, - g: 0.36, - b: 0.40, - a: 1.0, - }; - let file_name = std::path::Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -1027,70 +844,45 @@ fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Messag let heading = format!("\"{file_name}\" could not be saved."); let path_line = format!("Path: {path}"); let details = format!("Details: {error}"); - let btn = |label: &'static str, msg: Message, base: Color, hover: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hover, - _ => base, - })), - text_color: TEXT_COL, - border: Border { - color: BORDER_COL, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) - .padding([6, 18]) - }; container( column![ - text(heading).size(14).color(TEXT_COL), + text(heading).size(14), Space::new().height(8), text( "The file is open or being used by another application. \ Close it there and retry, or save this drawing under a different name." ) .size(13) - .color(TEXT_COL) .width(Fill), Space::new().height(12), - text(path_line).size(11).color(DIM_COL).width(Fill), + text(path_line).size(11).style(dialog_muted_text_style).width(Fill), Space::new().height(4), - text(details).size(11).color(DIM_COL).width(Fill), + text(details).size(11).style(dialog_muted_text_style).width(Fill), Space::new().height(18), row![ - btn( + dialog_button( "Retry", Message::SaveFileInUseRetry, - BTN_PRIMARY, - BTN_PRIMARY_HOVER + button::primary ), Space::new().width(8), - btn( + dialog_button( "Save As", Message::SaveFileInUseSaveAs, - BTN_SECONDARY, - BTN_SECONDARY_HOVER + button::secondary ), Space::new().width(8), - btn( + dialog_button( "Cancel", Message::SaveFileInUseCancel, - BTN_SECONDARY, - BTN_SECONDARY_HOVER + button::secondary ), ], ] .spacing(0), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .padding([18, 20]) .width(Fill) .height(Fill) @@ -1099,142 +891,55 @@ fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Messag #[cfg(not(target_arch = "wasm32"))] fn external_change_dialog_window(path: &str) -> Element<'static, Message> { - const BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }; - const BORDER_COL: Color = Color { - r: 0.38, - g: 0.38, - b: 0.42, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const DIM_COL: Color = Color { - r: 0.62, - g: 0.62, - b: 0.66, - a: 1.0, - }; - const BTN_PRIMARY: Color = Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 1.0, - }; - const BTN_PRIMARY_HOVER: Color = Color { - r: 0.26, - g: 0.55, - b: 0.92, - a: 1.0, - }; - const BTN_SECONDARY: Color = Color { - r: 0.28, - g: 0.28, - b: 0.30, - a: 1.0, - }; - const BTN_SECONDARY_HOVER: Color = Color { - r: 0.36, - g: 0.36, - b: 0.40, - a: 1.0, - }; - const BTN_DANGER: Color = Color { - r: 0.62, - g: 0.31, - b: 0.14, - a: 1.0, - }; - const BTN_DANGER_HOVER: Color = Color { - r: 0.78, - g: 0.39, - b: 0.17, - a: 1.0, - }; - let file_name = std::path::Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "Drawing".to_string()); let heading = format!("\"{file_name}\" was changed by another application."); let path_line = format!("Path: {path}"); - let btn = |label: &'static str, msg: Message, base: Color, hover: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hover, - _ => base, - })), - text_color: TEXT_COL, - border: Border { - color: BORDER_COL, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) - .padding([6, 14]) - }; container( column![ - text(heading).size(14).color(TEXT_COL), + text(heading).size(14), Space::new().height(8), text( "Saving now could destroy those external changes. Reload the disk copy, \ save your local work elsewhere, or explicitly overwrite it." ) .size(13) - .color(TEXT_COL) .width(Fill), Space::new().height(12), - text(path_line).size(11).color(DIM_COL).width(Fill), + text(path_line).size(11).style(dialog_muted_text_style).width(Fill), Space::new().height(18), row![ - btn( + dialog_button( "Reload from Disk", Message::ExternalChangeReload, - BTN_PRIMARY, - BTN_PRIMARY_HOVER + button::primary ), Space::new().width(8), - btn( + dialog_button( "Save As", Message::ExternalChangeSaveAs, - BTN_SECONDARY, - BTN_SECONDARY_HOVER + button::secondary ), Space::new().width(8), - btn( + dialog_button( "Overwrite", Message::ExternalChangeOverwrite, - BTN_DANGER, - BTN_DANGER_HOVER + button::danger ), Space::new().width(8), - btn( + dialog_button( "Cancel", Message::ExternalChangeCancel, - BTN_SECONDARY, - BTN_SECONDARY_HOVER + button::secondary ), ], ] .spacing(0), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .padding([18, 20]) .width(Fill) .height(Fill) @@ -1246,58 +951,31 @@ fn external_change_dialog_window(path: &str) -> Element<'static, Message> { /// bytes, so saving to a different version or to DXF would drop them. Offers to /// save in the source version (keep them) or proceed (drop them). fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Element<'static, Message> { - const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; - const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; - const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; - const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 }; - const BTN_HOVER: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 }; - const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; - const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; - let body_text = format!( "This drawing contains {count} AEC/Civil objects that \"{target}\" \ cannot store, so they will not be saved.\n\n\ To keep them, save in the source version ({src_version})." ); - let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hov, - _ => base, - })), - text_color: TEXT_COL, - border: Border { - color: BORDER_COL, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([6, 14]) - }; - container( column![ - text(body_text).size(13).color(TEXT_COL), + text(body_text).size(13), iced::widget::Space::new().height(20), row![ - btn("Save in source version", Message::AecDropSameVersion, BTN_SAVE, BTN_HOVER), + dialog_button( + "Save in source version", + Message::AecDropSameVersion, + button::primary + ), iced::widget::Space::new().width(8), - btn("Save anyway", Message::AecDropProceed, BTN_DISC, BTN_DHOV), + dialog_button("Save anyway", Message::AecDropProceed, button::warning), iced::widget::Space::new().width(8), - btn("Back", Message::AecDropBack, BTN_DISC, BTN_DHOV), + dialog_button("Back", Message::AecDropBack, button::secondary), ], ] .spacing(0), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .center(Fill) .padding([24, 28]) .into() @@ -1308,14 +986,6 @@ fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Elem /// Confirm deleting layer(s) that still have objects on them. "Delete Objects" /// erases them and removes the layers; "Cancel" leaves everything. fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'static, Message> { - const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 }; - const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 }; - const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; - const BTN_DEL: Color = Color { r: 0.72, g: 0.26, b: 0.24, a: 1.0 }; - const BTN_DEL_HOV: Color = Color { r: 0.84, g: 0.32, b: 0.30, a: 1.0 }; - const BTN_CANCEL: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 }; - const BTN_CANCEL_HOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 }; - let obj = if count == 1 { "object" } else { "objects" }; let subject = if names.len() == 1 { format!("Layer \"{}\"", names[0]) @@ -1328,38 +998,23 @@ fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'stati if count == 1 { "that object" } else { "those objects" } ); - let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hov, - _ => base, - })), - text_color: TEXT_COL, - border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() }, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([6, 18]) - }; - container( column![ - text(body_text).size(13).color(TEXT_COL), + text(body_text).size(13), iced::widget::Space::new().height(20), row![ - btn("Delete Objects", Message::LayerDeleteConfirm, BTN_DEL, BTN_DEL_HOV), + dialog_button( + "Delete Objects", + Message::LayerDeleteConfirm, + button::danger + ), iced::widget::Space::new().width(8), - btn("Cancel", Message::CloseModal, BTN_CANCEL, BTN_CANCEL_HOV), + dialog_button("Cancel", Message::CloseModal, button::secondary), ], ] .spacing(0), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .center(Fill) .padding([24, 28]) .into() @@ -1370,99 +1025,30 @@ fn layer_delete_warning_window(names: &[String], count: usize) -> Element<'stati /// just dismisses. Either answer flips the persisted `default_assoc_prompted` /// flag so the dialog never reappears. fn default_assoc_dialog_window() -> Element<'static, Message> { - const BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.20, - a: 1.0, - }; - const BORDER_COL: Color = Color { - r: 0.38, - g: 0.38, - b: 0.42, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const DIM_COL: Color = Color { - r: 0.62, - g: 0.62, - b: 0.66, - a: 1.0, - }; - const BTN_YES: Color = Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 1.0, - }; - const BTN_YHOV: Color = Color { - r: 0.26, - g: 0.55, - b: 0.92, - a: 1.0, - }; - const BTN_NO: Color = Color { - r: 0.28, - g: 0.28, - b: 0.30, - a: 1.0, - }; - const BTN_NHOV: Color = Color { - r: 0.36, - g: 0.36, - b: 0.40, - a: 1.0, - }; - - let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { - button(text(label).size(13).color(TEXT_COL)) - .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => hov, - _ => base, - })), - text_color: TEXT_COL, - border: Border { - color: BORDER_COL, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([6, 18]) - }; - container( column![ text("Make Open CAD Studio your default CAD app?") - .size(15) - .color(TEXT_COL), + .size(15), iced::widget::Space::new().height(10), text("Open .dwg and .dxf drawings in Open CAD Studio by default. You can change this later in your system settings.") .size(12) - .color(DIM_COL), + .style(dialog_muted_text_style), iced::widget::Space::new().height(22), row![ iced::widget::Space::new().width(Fill), - btn("Not now", Message::AssocPromptNo, BTN_NO, BTN_NHOV), + dialog_button("Not now", Message::AssocPromptNo, button::secondary), iced::widget::Space::new().width(8), - btn("Yes, set as default", Message::AssocPromptYes, BTN_YES, BTN_YHOV), + dialog_button( + "Yes, set as default", + Message::AssocPromptYes, + button::primary + ), ] .align_y(iced::Center), ] .spacing(0), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(dialog_body_style) .center(Fill) .padding([24, 28]) .into() diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index c96449016..a9ba0d98b 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -9,18 +9,7 @@ pub(super) fn position_canvas_overlay<'a>( anchor: iced::Point, panel: Element<'a, Message>, ) -> Element<'a, Message> { - let ax = anchor.x.max(0.0); - let ay = anchor.y.max(0.0); - column![ - Space::new().height(iced::Length::Fixed(ay)), - row![ - Space::new().width(iced::Length::Fixed(ax)), - iced::widget::opaque(panel), - ], - ] - .width(Fill) - .height(Fill) - .into() + crate::ui::pin_at(anchor, iced::widget::opaque(panel)) } // ── In-place MText editor overlay ─────────────────────────────────────────── @@ -37,19 +26,6 @@ pub(super) fn text_inline_overlay( ed: &super::super::text_inline::TextInlineState, canvas: (f32, f32), ) -> Element<'_, Message> { - const PANEL_BG: Color = Color { - r: 0.16, - g: 0.16, - b: 0.16, - a: 0.98, - }; - const BORDER: Color = Color { - r: 0.40, - g: 0.40, - b: 0.40, - a: 1.0, - }; - let field = text_input("Text", &ed.value) .id(iced::widget::Id::new(TEXT_INLINE_ID)) .on_input(Message::TextInlineInput) @@ -59,14 +35,17 @@ pub(super) fn text_inline_overlay( .width(iced::Length::Fixed(240.0)); let panel = container(field) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 5.0.into(), }, ..Default::default() + } }) .padding(4); @@ -206,7 +185,7 @@ impl iced::widget::canvas::Program for MTextPreview { &self, _state: &MTextPreviewState, renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: iced::Rectangle, _cursor: iced::mouse::Cursor, ) -> Vec { @@ -232,12 +211,7 @@ impl iced::widget::canvas::Program for MTextPreview { ); frame.fill( &rect, - Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 0.45, - }, + theme.extended_palette().primary.base.color.scale_alpha(0.45), ); } } @@ -273,12 +247,7 @@ impl iced::widget::canvas::Program for MTextPreview { frame.stroke( &path, Stroke::default() - .with_color(Color { - r: 0.95, - g: 0.95, - b: 0.55, - a: 1.0, - }) + .with_color(theme.extended_palette().warning.base.color) .with_width(1.5), ); } else if collapsed { @@ -302,12 +271,7 @@ impl iced::widget::canvas::Program for MTextPreview { frame.stroke( &path, Stroke::default() - .with_color(Color { - r: 0.95, - g: 0.95, - b: 0.55, - a: 1.0, - }) + .with_color(theme.extended_palette().warning.base.color) .with_width(1.5), ); } @@ -355,59 +319,34 @@ pub(super) fn mtext_editor_overlay<'a>( modal_resize: iced::Vector, ) -> Element<'a, Message> { use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; - use iced::widget::{canvas, svg}; + use iced::widget::canvas; - const BORDER: Color = Color { - r: 0.40, - g: 0.40, - b: 0.40, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }; - const FIELD_BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, - }; - - let btn_style = |_: &Theme, status: button::Status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.28, - g: 0.40, - b: 0.55, - a: 1.0, - }, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT_COL, + let btn_style = |theme: &Theme, status: button::Status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.strong, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, shadow: iced::Shadow::default(), snap: false, + } }; let icon_btn = move |bytes: &'static [u8], msg: Message| -> Element<'static, Message> { - button(svg(svg::Handle::from_memory(bytes)).width(18).height(18)) + button(crate::ui::icons::themed(bytes, 18.0)) .on_press(msg) .padding(3) .style(btn_style) .into() }; - let lbl = |s: &'static str| text(s).size(11).color(TEXT_COL); + let lbl = |s: &'static str| text(s).size(11); let small_input = |placeholder: &'static str, val: &str, on: fn(String) -> Message, @@ -651,14 +590,17 @@ pub(super) fn mtext_editor_overlay<'a>( .width(Fill) .height(Fill), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(FIELD_BG)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }) .padding(2) .width(Fill) @@ -676,8 +618,10 @@ pub(super) fn mtext_editor_overlay<'a>( ] .align_y(iced::Alignment::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(crate::ui::style::style_manager::TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -711,132 +655,6 @@ pub(super) fn mtext_editor_overlay<'a>( ) } -// ── Drawing-tab right-click context menu ─────────────────────────────────── - -/// Right-click menu for a drawing tab. The scope deliberately contains only -/// the actions approved in issue #493. -pub(super) fn doc_tab_context_menu_overlay( - tab_idx: usize, - current_path: Option<&std::path::Path>, - has_other_drawings: bool, - win: (f32, f32), -) -> Element<'_, Message> { - const MENU_W: f32 = 210.0; - const MENU_H: f32 = 136.0; - const MENU_BG: Color = Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, - }; - const MENU_BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }; - const ITEM_HOVER: Color = Color { - r: 0.25, - g: 0.45, - b: 0.70, - a: 1.0, - }; - const TEXT_COLOR: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }; - const DISABLED_TEXT: Color = Color { - r: 0.43, - g: 0.43, - b: 0.43, - a: 1.0, - }; - - let item = |label: &'static str, msg: Option| { - let enabled = msg.is_some(); - let mut item = button(text(label).size(12).color(if enabled { - TEXT_COLOR - } else { - DISABLED_TEXT - })) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(if enabled { - match status { - button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, - _ => Color::TRANSPARENT, - } - } else { - Color::TRANSPARENT - })), - text_color: if enabled { TEXT_COLOR } else { DISABLED_TEXT }, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([4, 12]) - .width(Fill); - if let Some(msg) = msg { - item = item.on_press(msg); - } - item - }; - - let native_path_actions = cfg!(not(target_arch = "wasm32")) && current_path.is_some(); - let menu = container( - column![ - item("Save All", Some(Message::DocTabSaveAll)), - item("Close All", Some(Message::DocTabCloseAll)), - item( - "Close All Other Drawings", - has_other_drawings.then_some(Message::DocTabCloseOthers(tab_idx)), - ), - item( - "Copy Full File Path", - native_path_actions.then_some(Message::DocTabCopyFullPath(tab_idx)), - ), - item( - "Open File Location", - native_path_actions.then_some(Message::DocTabOpenFileLocation(tab_idx)), - ), - ] - .spacing(0) - .width(MENU_W), - ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(MENU_BG)), - border: Border { - color: MENU_BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) - .padding([4, 0]) - .width(iced::Length::Fixed(MENU_W)); - - let catcher = mouse_area( - container(Space::new().width(Fill).height(Fill)) - .width(Fill) - .height(Fill), - ) - .on_press(Message::DocTabContextMenuClose) - .on_right_press(Message::DocTabContextMenuClose); - - let bounds = crate::ui::wrap_bar::dropdown_bounds(&format!("DOC_TAB:{tab_idx}")); - let pos = bounds - .map(|b| { - iced::Point::new( - b.x.clamp(0.0, (win.0 - MENU_W).max(0.0)), - (b.y + b.height).clamp(0.0, (win.1 - MENU_H).max(0.0)), - ) - }) - .unwrap_or(iced::Point::new(4.0, 30.0)); - - stack![catcher, position_canvas_overlay(pos, menu.into())].into() -} - // ── Viewport right-click context menu ────────────────────────────────────── pub(super) fn viewport_context_menu_overlay( @@ -847,50 +665,10 @@ pub(super) fn viewport_context_menu_overlay( last_cmds: Vec, draworder_open: bool, ) -> Element<'static, Message> { - const MENU_BG: Color = Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, - }; - const MENU_BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }; - const ITEM_HOVER: Color = Color { - r: 0.25, - g: 0.45, - b: 0.70, - a: 1.0, - }; - const TEXT_COL: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }; - const SEP_COL: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }; - let item = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12).color(TEXT_COL)) + button(text(label).size(12)) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT_COL, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - }) + .style(button::subtle) .padding([4, 12]) .width(Fill) .into() @@ -898,8 +676,10 @@ pub(super) fn viewport_context_menu_overlay( let sep = || -> Element<'static, Message> { container(iced::widget::Space::new().width(Fill).height(1)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(SEP_COL)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color, + )), ..Default::default() }) .width(Fill) @@ -910,18 +690,9 @@ pub(super) fn viewport_context_menu_overlay( // Indented variant for sub-menu rows (e.g. Draw Order children). let subitem = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12).color(TEXT_COL)) + button(text(label).size(12)) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT_COL, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - }) + .style(button::subtle) .padding(iced::Padding { top: 4.0, right: 12.0, @@ -964,30 +735,21 @@ pub(super) fn viewport_context_menu_overlay( )); items.push(sep()); let do_caret = if draworder_open { - crate::ui::icons::arrow_down(9.0, TEXT_COL) + crate::ui::icons::themed_arrow_down(9.0) } else { - crate::ui::icons::arrow_right(9.0, TEXT_COL) + crate::ui::icons::themed_arrow_right(9.0) }; items.push( button( row![ - text("Draw Order").size(12).color(TEXT_COL), + text("Draw Order").size(12), iced::widget::Space::new().width(Fill), do_caret, ] .align_y(iced::Center), ) .on_press(Message::DrawOrderSubmenuToggle) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT_COL, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - }) + .style(button::subtle) .padding([4, 12]) .width(Fill) .into(), @@ -1046,123 +808,23 @@ pub(super) fn viewport_context_menu_overlay( let menu_col = column(items).spacing(0).width(iced::Length::Fixed(180.0)); let menu = container(menu_col) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(MENU_BG)), - border: Border { - color: MENU_BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) + .style(container::bordered_box) .padding([4, 0]) .width(iced::Length::Fixed(180.0)); position_canvas_overlay(pos, menu.into()) } -/// A small right-click context menu rendered above the status bar. -/// The `name` is the layout tab that was right-clicked. -pub(super) fn layout_context_menu_overlay(name: &str, win: (f32, f32)) -> Element<'_, Message> { - const MENU_BG: Color = Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, - }; - const MENU_BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }; - const ITEM_HOVER: Color = Color { - r: 0.25, - g: 0.45, - b: 0.70, - a: 1.0, - }; - const TEXT_COLOR: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }; - - let item = |label: &'static str, msg: Message| { - button(text(label).size(12).color(TEXT_COLOR)) - .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, - _ => Color::TRANSPARENT, - })), - text_color: TEXT_COLOR, - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([4, 12]) - .width(Fill) - }; - - let rename_name = name.to_string(); - let delete_name = name.to_string(); - - let menu = container( - column![ - item("Rename", Message::LayoutRenameStart(rename_name)), - item("Delete", Message::LayoutDelete(delete_name)), - ] - .spacing(0) - .width(160), - ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(MENU_BG)), - border: Border { - color: MENU_BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) - .padding([4, 0]); - - // Click-catcher fills the whole screen to close the menu when clicking outside. - let catcher = mouse_area( - container(iced::widget::Space::new().width(Fill).height(Fill)) - .width(Fill) - .height(Fill), - ) - .on_press(Message::LayoutContextMenuClose) - .on_right_press(Message::LayoutContextMenuClose); - - // Anchor the menu above the status bar next to the right-clicked tab β€” - // its bounds were recorded by the tab's PosReport wrapper (#428). A - // missing report (shouldn't happen) falls back to the left edge. - let pill = crate::ui::wrap_bar::dropdown_bounds(&format!("SB_LAYOUT_TAB:{name}")); - let positioned = - crate::ui::popup::position_statusbar_popup(menu.into(), pill, win, 160.0, false); - - stack![catcher, positioned].into() -} - /// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of /// snap ICONS only β€” the names show as hover tooltips. Picking one applies /// that snap to just the next point pick. pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Message> { - const PANEL_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 0.98 }; - const PANEL_BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; - const ICON_COLOR: Color = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; - const HOVER: Color = Color { r: 0.25, g: 0.45, b: 0.70, a: 1.0 }; - const TIP_BG: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 0.98 }; const COLS: usize = 4; let cell = |snap_type: crate::snap::SnapType, label: &'static str| -> Element<'static, Message> { - let icon = container(crate::ui::icons::tinted::( + let icon = container(crate::ui::icons::themed::( crate::ui::icons::osnap(snap_type), 16.0, - ICON_COLOR, )) .width(26) .height(26) @@ -1170,26 +832,34 @@ pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Messag .align_y(iced::Center); let btn = button(icon) .on_press(Message::SnapOverridePick(snap_type)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => HOVER, - _ => Color::TRANSPARENT, - })), + .style(|theme: &Theme, status| button::Style { + background: matches!( + status, + button::Status::Hovered | button::Status::Pressed + ) + .then_some(Background::Color( + theme.extended_palette().primary.weak.color + )), border: Border::default(), + text_color: theme.extended_palette().background.base.text, ..Default::default() }) .padding(2); iced::widget::tooltip( btn, - container(text(label).size(11).color(Color::WHITE)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TIP_BG)), + container(text(label).size(11)) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.strong.color)), border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, + text_color: Some(palette.background.strong.text), ..Default::default() + } }) .padding([2, 6]), iced::widget::tooltip::Position::Bottom, @@ -1207,14 +877,17 @@ pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Messag } let panel = container(grid) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } }) .padding(4); @@ -1247,49 +920,6 @@ pub(super) fn qselect_overlay<'a>( properties: &[(String, String)], ) -> Element<'a, Message> { use iced::widget::{checkbox, pick_list}; - const BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 0.98, - }; - const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }; - const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }; - const BTN_OK: Color = Color { - r: 0.22, - g: 0.42, - b: 0.68, - a: 1.0, - }; - const BTN_OK_HOV: Color = Color { - r: 0.30, - g: 0.52, - b: 0.80, - a: 1.0, - }; - const BTN_BG: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }; - const BTN_HOV: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }; - let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().cloned()); @@ -1334,28 +964,33 @@ pub(super) fn qselect_overlay<'a>( let label = |s: &'static str| { text(s) .size(12) - .color(TEXT) .width(iced::Length::Fixed(90.0)) }; - let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| { - button(text(lbl).size(12).color(TEXT)) + let btn = |lbl: &'static str, msg: Message, primary: bool| { + button(text(lbl).size(12)) .on_press(msg) - .style(move |_: &Theme, st| button::Style { - background: Some(Background::Color( - if matches!(st, button::Status::Hovered | button::Status::Pressed) { - hov - } else { - base - }, - )), - text_color: TEXT, + .style(move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match ( + primary, + matches!(st, button::Status::Hovered | button::Status::Pressed), + ) { + (true, true) => palette.primary.strong, + (true, false) => palette.primary.base, + (false, true) => palette.background.strong, + (false, false) => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } }) .padding([4, 14]) }; @@ -1366,7 +1001,7 @@ pub(super) fn qselect_overlay<'a>( } let panel_body = column![ - text("Quick Select").size(14).color(TEXT), + text("Quick Select").size(14), Space::new().height(10), row![ label("Object type:"), @@ -1421,15 +1056,15 @@ pub(super) fn qselect_overlay<'a>( .on_toggle(Message::QSelectSetAppend) .size(14), Space::new().width(6), - text("Append to current selection").size(12).color(TEXT), + text("Append to current selection").size(12), ] .align_y(iced::Alignment::Center), Space::new().height(14), row![ Space::new().width(Fill), - btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV), + btn("Cancel", Message::QSelectClose, false), Space::new().width(8), - btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV), + btn("Apply", Message::QSelectApply, true), ] .align_y(iced::Alignment::Center), ] @@ -1438,14 +1073,17 @@ pub(super) fn qselect_overlay<'a>( let panel = container(panel_body) .padding(16) .width(iced::Length::Fixed(400.0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() + } }); // Outside-click catcher β€” fills the whole screen, sits below the diff --git a/src/app/view/viewcube.rs b/src/app/view/viewcube.rs index 495519fd5..25d0640a7 100644 --- a/src/app/view/viewcube.rs +++ b/src/app/view/viewcube.rs @@ -1,10 +1,9 @@ use super::super::Message; use crate::scene::{VIEWCUBE_PX, VIEWCUBE_REGION_PX}; use iced::widget::{ - button, column, container, mouse_area, pick_list, row, stack, - Space, + button, container, mouse_area, pick_list, stack, Space, }; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Border, Element, Theme}; // ── Render-mode picker ────────────────────────────────────────────────────── @@ -15,15 +14,9 @@ use iced::{Background, Border, Color, Element, Fill, Theme}; /// state and emit `ToggleGrid` / `ToggleGridSnap`. // ── ViewCube navigation controls (home / roll / nudge / UCS) ─────────────── -/// Place `el` at pixel offset (x, y) inside a Fill layer (top-left origin). +/// Place `el` at pixel offset (x, y) inside a fill layer (top-left origin). fn vc_place<'a>(x: f32, y: f32, el: Element<'a, Message>) -> Element<'a, Message> { - column![ - Space::new().height(iced::Length::Fixed(y.max(0.0))), - row![Space::new().width(iced::Length::Fixed(x.max(0.0))), el], - ] - .width(Fill) - .height(Fill) - .into() + crate::ui::pin_at(iced::Point::new(x, y), el) } /// Borderless square icon button used by the ViewCube nav controls. @@ -37,20 +30,19 @@ fn vc_btn<'a>(content: Element<'a, Message>, size: f32, msg: Message) -> Element ) .padding(0) .on_press(msg) - .style(|_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match status { - iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed => Color { - r: 0.45, - g: 0.62, - b: 0.95, - a: 0.30, - }, - _ => Color::TRANSPARENT, - })), + .style(|theme: &Theme, status| iced::widget::button::Style { + background: matches!( + status, + iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed + ) + .then_some(Background::Color( + theme.extended_palette().primary.weak.color + )), border: Border { radius: 3.0.into(), ..Default::default() }, + text_color: theme.extended_palette().background.base.text, ..Default::default() }) .into() @@ -61,12 +53,6 @@ fn vc_btn<'a>(content: Element<'a, Message>, size: f32, msg: Message) -> Element pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { use crate::scene::NudgeDir; use crate::ui::icons; - let tint = Color { - r: 0.86, - g: 0.89, - b: 0.96, - a: 1.0, - }; let r = VIEWCUBE_REGION_PX; let c = r * 0.5; let cube_half = VIEWCUBE_PX as f32 * 0.36; // VIEWCUBE_PX * VIEWCUBE_SCALE @@ -101,23 +87,23 @@ pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { vc_place( 3.0, 3.0, - vc_btn(icons::home(13.0, tint), BTN, Message::ViewCubeHome) + vc_btn(icons::themed_home(13.0), BTN, Message::ViewCubeHome) ), vc_place( rax, ray, - vc_btn(icons::undo(12.0, tint), BTN, Message::ViewCubeRoll(false)) + vc_btn(icons::themed_undo(12.0, true), BTN, Message::ViewCubeRoll(false)) ), vc_place( rbx, rby, - vc_btn(icons::redo(12.0, tint), BTN, Message::ViewCubeRoll(true)) + vc_btn(icons::themed_redo(12.0, true), BTN, Message::ViewCubeRoll(true)) ), vc_place( tux, tuy, vc_btn( - icons::arrow_down(8.0, tint), + icons::themed_arrow_down(8.0), TRI, Message::ViewCubeNudge(NudgeDir::Up) ) @@ -126,7 +112,7 @@ pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { tdx, tdy, vc_btn( - icons::arrow_up(8.0, tint), + icons::themed_arrow_up(8.0), TRI, Message::ViewCubeNudge(NudgeDir::Down) ) @@ -135,7 +121,7 @@ pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { tlx, tly, vc_btn( - icons::arrow_right(8.0, tint), + icons::themed_arrow_right(8.0), TRI, Message::ViewCubeNudge(NudgeDir::Left) ) @@ -144,7 +130,7 @@ pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> { trx, try_, vc_btn( - icons::arrow_left(8.0, tint), + icons::themed_arrow_left(8.0), TRI, Message::ViewCubeNudge(NudgeDir::Right) ) @@ -162,12 +148,6 @@ pub(super) const UCS_PICKER_W: f32 = 84.0; /// The WCS / named-UCS selector shown under the cube. pub(super) fn viewcube_ucs_picker<'a>(current: String, names: Vec) -> Element<'a, Message> { - let light = Color { - r: 0.85, - g: 0.87, - b: 0.93, - a: 1.0, - }; let mut options = vec!["WCS".to_string()]; options.extend(names); let selected = if current.is_empty() { @@ -181,21 +161,20 @@ pub(super) fn viewcube_ucs_picker<'a>(current: String, names: Vec) -> El // Fixed width so the caller can centre it under the cube centre with a // simple half-width offset (content-sized width would drift off-centre). .width(iced::Length::Fixed(UCS_PICKER_W)) - .style(move |_: &Theme, _| iced::widget::pick_list::Style { - background: Background::Color(Color { - r: 0.16, - g: 0.17, - b: 0.20, - a: 0.92, - }), + .style(move |theme: &Theme, _| { + let palette = theme.extended_palette(); + iced::widget::pick_list::Style { + background: Background::Color(palette.background.weak.color), border: Border { radius: 3.0.into(), + color: palette.background.neutral.color, + width: 1.0, ..Default::default() }, - text_color: light, - placeholder_color: light, - handle_color: light, + text_color: palette.background.base.text, + placeholder_color: palette.background.base.text.scale_alpha(0.68), + handle_color: palette.background.base.text, + } }) .into() } - diff --git a/src/discussions.rs b/src/discussions.rs new file mode 100644 index 000000000..43b733122 --- /dev/null +++ b/src/discussions.rs @@ -0,0 +1,240 @@ +//! GitHub Discussions shown on the Start page. +//! +//! Native builds read GitHub's public Atom feed and the public Discussions +//! page, which exposes the repository's pinned-discussion section without an +//! access token. The last successful result is cached for offline launches. +//! Web builds read `discussions.json`, generated by the Pages workflow through +//! GitHub's authenticated GraphQL API. + +pub const DISCUSSIONS_URL: &str = + "https://github.com/HakanSeven12/OpenCADStudio/discussions"; + +#[cfg(not(target_arch = "wasm32"))] +const FEED_URL: &str = + "https://github.com/HakanSeven12/OpenCADStudio/discussions.atom"; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct DiscussionEntry { + pub number: u64, + pub title: String, + pub url: String, + pub author: String, + pub updated_at: String, + #[serde(default)] + pub pinned: bool, +} + +fn sort_entries(entries: &mut [DiscussionEntry]) { + entries.sort_by(|a, b| { + b.pinned + .cmp(&a.pinned) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + .then_with(|| b.number.cmp(&a.number)) + }); +} + +fn parse_json(body: &str) -> Result, String> { + let mut entries: Vec = + serde_json::from_str(body).map_err(|e| e.to_string())?; + entries.retain(|entry| { + entry.number > 0 + && !entry.title.trim().is_empty() + && entry.url.starts_with("https://github.com/") + }); + sort_entries(&mut entries); + Ok(entries) +} + +#[cfg(not(target_arch = "wasm32"))] +fn cache_path() -> Option { + crate::config::config_dir().map(|dir| dir.join("discussions.json")) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn load_cached() -> Vec { + cache_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|body| parse_json(&body).ok()) + .or_else(|| parse_json(include_str!("../web/discussions.json")).ok()) + .unwrap_or_default() +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_discussions() -> Result, String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .into(); + let feed = get_text(&agent, FEED_URL)?; + let page = get_text(&agent, DISCUSSIONS_URL)?; + let pinned = pinned_numbers(&page); + let mut entries = parse_atom(&feed); + if entries.is_empty() { + return Err("GitHub Discussions feed returned no entries".into()); + } + for entry in &mut entries { + entry.pinned = pinned.contains(&entry.number); + } + sort_entries(&mut entries); + + if let Some(path) = cache_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(body) = serde_json::to_string(&entries) { + let _ = std::fs::write(path, body); + } + } + Ok(entries) +} + +#[cfg(not(target_arch = "wasm32"))] +fn get_text(agent: &ureq::Agent, url: &str) -> Result { + agent + .get(url) + .header( + "User-Agent", + concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")), + ) + .call() + .map_err(|e| e.to_string())? + .body_mut() + .with_config() + .limit(4 * 1024 * 1024) + .read_to_string() + .map_err(|e| e.to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_atom(feed: &str) -> Vec { + feed.split("") + .skip(1) + .filter_map(|tail| { + let entry = tail.split_once("")?.0; + let url_start = entry.find( + "href=\"https://github.com/HakanSeven12/OpenCADStudio/discussions/", + )? + "href=\"".len(); + let url_tail = &entry[url_start..]; + let url = url_tail.split_once('"')?.0.to_string(); + let number = url.rsplit('/').next()?.parse().ok()?; + let title = xml_text(entry, "title")?; + let author_block = entry.split_once("")?.1.split_once("")?.0; + let author = xml_text(author_block, "name").unwrap_or_default(); + let updated_at = xml_text(entry, "updated").unwrap_or_default(); + Some(DiscussionEntry { + number, + title, + url, + author, + updated_at, + pinned: false, + }) + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn xml_text(block: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let value = block.split_once(&open)?.1.split_once(&close)?.0; + let compact = value.split_whitespace().collect::>().join(" "); + if compact.is_empty() { + None + } else { + Some( + compact + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"), + ) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn pinned_numbers(page: &str) -> std::collections::HashSet { + let mut numbers = std::collections::HashSet::new(); + let Some(after_heading) = page.split_once("id=\"pinned-discussions\"").map(|(_, rest)| rest) + else { + return numbers; + }; + let section = after_heading + .split_once("") + .map(|(section, _)| section) + .unwrap_or(after_heading); + let marker = "/HakanSeven12/OpenCADStudio/discussions/"; + let mut rest = section; + while let Some(pos) = rest.find(marker) { + let tail = &rest[pos + marker.len()..]; + let digits = tail + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if let Ok(number) = digits.parse() { + numbers.insert(number); + } + rest = tail; + } + numbers +} + +#[cfg(target_arch = "wasm32")] +pub async fn fetch_discussions_web() -> Result, String> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window().ok_or("no window")?; + let response = JsFuture::from(window.fetch_with_str("discussions.json")) + .await + .map_err(|_| "fetch failed")?; + let response: web_sys::Response = + response.dyn_into().map_err(|_| "not a Response")?; + if !response.ok() { + return Err(format!("HTTP {}", response.status())); + } + let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?) + .await + .map_err(|_| "body read failed")?; + let body = text.as_string().ok_or("body is not a string")?; + parse_json(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn pinned_discussions_are_sorted_first() { + let feed = r#" + + + New & recent + 2026-07-28T10:00:00+00:00 + alice + + + + Pinned + 2026-07-20T10:00:00+00:00 + bob + + "#; + let page = r#" +

Pinned Discussions

+ + "#; + let pinned = pinned_numbers(page); + let mut entries = parse_atom(feed); + for entry in &mut entries { + entry.pinned = pinned.contains(&entry.number); + } + sort_entries(&mut entries); + + assert_eq!(entries[0].number, 1); + assert_eq!(entries[1].title, "New & recent"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 64369e432..ea1711c07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod entities; pub mod io; pub mod modules; pub mod patreon; +pub mod discussions; pub mod videos; pub mod plugin; pub mod perf; diff --git a/src/main.rs b/src/main.rs index 783e5109b..c92a56c8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod entities; mod io; mod modules; mod patreon; +mod discussions; mod videos; mod plugin; mod perf; diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index 2105ea23f..74f3d0b83 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -576,6 +576,7 @@ impl ViewCubeText { vp_w: u32, vp_h: u32, cube_px: u32, + text_color: [f32; 4], ) { let (vw, vh) = (vp_w as f32, vp_h as f32); let cube_half = cube_px as f32 * VIEWCUBE_SCALE; @@ -637,7 +638,12 @@ impl ViewCubeText { continue; } let alpha = ((dot - 0.12) / 0.88).clamp(0.0, 1.0); - let color = [1.0, 1.0, 1.0, alpha]; + let color = [ + text_color[0], + text_color[1], + text_color[2], + text_color[3] * alpha, + ]; let u = Vec3::from(FACE_U[fi]); let v = Vec3::from(FACE_V[fi]); let center = face_n; // unit normal = face surface centre (distance E) @@ -714,7 +720,12 @@ impl ViewCubeText { } else { 0.5 }; - let color = [1.0, 1.0, 1.0, alpha]; + let color = [ + text_color[0], + text_color[1], + text_color[2], + text_color[3] * alpha, + ]; let (u0, v0, u1, v1) = glyph_uv(gi, self.atlas_w, self.atlas_h); let corner = |lx: f32, ly: f32| center + Vec3::X * lx + Vec3::Y * ly; let tl = project_world(corner(-CARD_GW * 0.5, CARD_GH * 0.5)); @@ -1232,6 +1243,7 @@ impl ViewCubePipeline { vp_w: u32, vp_h: u32, hover: Option, + text_color: [f32; 4], ) { queue.write_buffer( &self.uniform_buffer, @@ -1245,7 +1257,15 @@ impl ViewCubePipeline { )), ); self.text - .update(queue, cam_rotation, compass_rotation, vp_w, vp_h, self.cube_px); + .update( + queue, + cam_rotation, + compass_rotation, + vp_w, + vp_h, + self.cube_px, + text_color, + ); } pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index f1266acae..af93772bf 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -152,6 +152,8 @@ pub struct Primitive { pub(in crate::scene) viewports: Vec, /// Background color used to clear each viewport's MSAA buffer. pub(in crate::scene) bg_color: [f32; 4], + /// Active Iced theme text colour for GPU-rendered ViewCube labels. + pub(in crate::scene) viewcube_text_color: [f32; 4], /// One input-to-render sample, carried only when PERF tracing is enabled. pub(in crate::scene) nav_perf: Option, } @@ -327,6 +329,7 @@ impl shader::Primitive for Primitive { (vp.screen_rect.width * bounds.width) as u32, (vp.screen_rect.height * bounds.height) as u32, vp.hover_region, + self.viewcube_text_color, ); } continue; @@ -874,6 +877,7 @@ impl shader::Primitive for Primitive { (vp.screen_rect.width * bounds.width) as u32, (vp.screen_rect.height * bounds.height) as u32, vp.hover_region, + self.viewcube_text_color, ); } } @@ -1689,6 +1693,7 @@ impl Scene { model_render_mode: acadrust::entities::ViewportRenderMode, _hover_region: Option, show_viewcube: bool, + viewcube_text_color: [f32; 4], ) -> Primitive { let nav_build_started = iced::time::Instant::now(); let perf_nav = self.take_nav_perf(); @@ -1720,6 +1725,7 @@ impl Scene { Primitive { viewports, bg_color, + viewcube_text_color, nav_perf: perf_nav, } } @@ -1736,6 +1742,7 @@ impl Scene { tile_idx: usize, model_render_mode: acadrust::entities::ViewportRenderMode, show_viewcube: bool, + viewcube_text_color: [f32; 4], ) -> Primitive { let hover_region = self.viewcube_hover.get(); let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); @@ -1745,6 +1752,7 @@ impl Scene { return Primitive { viewports: vec![], bg_color, + viewcube_text_color, nav_perf: None, }; }; @@ -1792,6 +1800,7 @@ impl Scene { Primitive { viewports, bg_color, + viewcube_text_color, nav_perf: perf_nav, } } diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index a3cf1a044..07d87b868 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -23,6 +23,9 @@ pub struct ViewportPane<'a> { /// per `pane_grid` pane, filling its own bounds). `None` β†’ the unified /// full-canvas path (paper layout, or the whole-canvas Model fallback). pub pane: Option, + /// Active Iced theme text colour, forwarded to the GPU-rendered ViewCube + /// face and compass labels. + pub viewcube_text_color: [f32; 4], } impl<'a> ViewportPane<'a> { @@ -30,12 +33,14 @@ impl<'a> ViewportPane<'a> { scene: &'a Scene, show_viewcube: bool, render_mode: acadrust::entities::ViewportRenderMode, + viewcube_text_color: [f32; 4], ) -> Self { Self { scene, show_viewcube, render_mode, pane: None, + viewcube_text_color, } } @@ -46,12 +51,14 @@ impl<'a> ViewportPane<'a> { show_viewcube: bool, render_mode: acadrust::entities::ViewportRenderMode, tile_idx: usize, + viewcube_text_color: [f32; 4], ) -> Self { Self { scene, show_viewcube, render_mode, pane: Some(tile_idx), + viewcube_text_color, } } } @@ -74,12 +81,14 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> idx, self.render_mode, self.show_viewcube, + self.viewcube_text_color, ), None => self.scene.build_viewports( bounds, self.render_mode, state.hover_region, self.show_viewcube, + self.viewcube_text_color, ), } } diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 08edb5c60..bb2af84be 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -20,25 +20,6 @@ pub struct ColorExtras { pub by_block: bool, } -const PICKER_BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; - /// Encode a colour as the ACI integer string the style editors store /// (ByBlock=0, ByLayer=256, indexed 1-255; RGB has no ACI slot β†’ ByLayer). pub fn color_to_aci_string(c: AcadColor) -> String { @@ -85,15 +66,10 @@ pub fn color_display_name(c: AcadColor) -> String { /// A small colour square. fn swatch<'a>(bg: Color) -> Element<'a, Message> { container(text("").width(13).height(13)) - .style(move |_: &Theme| container::Style { + .style(move |theme: &Theme| container::Style { background: Some(Background::Color(bg)), border: Border { - color: Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5, - }, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 2.0.into(), }, @@ -126,8 +102,8 @@ pub fn color_selector<'a>( let head = button( row![ swatch(cur_bg), - text(cur_name).size(11).color(TEXT), - crate::ui::icons::arrow_toggle(open, 9.0, TEXT), + text(cur_name).size(11), + crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) .align_y(iced::Center), @@ -141,14 +117,17 @@ pub fn color_selector<'a>( } let popup = container(color_list(extras, on_select, on_more)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PICKER_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, ..Default::default() + } }) .padding(5) .width(220); @@ -161,6 +140,21 @@ pub fn color_selector<'a>( }) } +fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let hovered = matches!(status, button::Status::Hovered); + let text_color = if hovered { + palette.background.strong.text + } else { + palette.background.base.text + }; + button::Style { + background: hovered.then_some(Background::Color(palette.background.strong.color)), + text_color, + ..Default::default() + } +} + /// The colour list shown inside a picker popup: named ACI colours (with /// swatches) plus a "More…" entry that opens the full palette window. Shared by /// `color_selector` and the ribbon's colour overlay. @@ -172,21 +166,12 @@ pub fn color_list<'a>( let named_row = |color: AcadColor| -> Element<'a, Message> { let (bg, name) = acad_color_display(color); button( - row![swatch(bg), text(name).size(11).color(TEXT)] + row![swatch(bg), text(name).size(11)] .spacing(5) .align_y(iced::Center), ) .on_press(on_select(color)) - .style(|_: &Theme, status| button::Style { - background: matches!(status, button::Status::Hovered) - .then_some(Background::Color(Color { - r: 0.25, - g: 0.25, - b: 0.30, - a: 1.0, - })), - ..Default::default() - }) + .style(list_row_style) .padding([2, 4]) .width(Length::Fill) .into() @@ -203,18 +188,9 @@ pub fn color_list<'a>( list = list.push(named_row(AcadColor::Index(i))); } list = list.push( - button(text("More…").size(11).color(TEXT)) + button(text("More…").size(11)) .on_press(on_more) - .style(|_: &Theme, status| button::Style { - background: matches!(status, button::Status::Hovered) - .then_some(Background::Color(Color { - r: 0.25, - g: 0.25, - b: 0.30, - a: 1.0, - })), - ..Default::default() - }) + .style(list_row_style) .padding([2, 4]) .width(Length::Fill), ); @@ -227,7 +203,7 @@ pub fn color_grid_window(on_pick: impl Fn(AcadColor) -> Message) -> Element<'sta let chip = |color: AcadColor, label: &'static str| -> Element<'static, Message> { let (bg, _) = acad_color_display(color); button( - row![swatch(bg), text(label).size(11).color(TEXT)] + row![swatch(bg), text(label).size(11)] .spacing(5) .align_y(iced::Center), ) @@ -250,18 +226,13 @@ pub fn color_grid_window(on_pick: impl Fn(AcadColor) -> Message) -> Element<'sta r = r.push( button(text("").width(18).height(18)) .on_press(on_pick(AcadColor::Index(ci))) - .style(move |_: &Theme, status| button::Style { + .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(bg)), border: Border { color: if matches!(status, button::Status::Hovered) { - Color::WHITE + theme.extended_palette().primary.base.color } else { - Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.4, - } + theme.extended_palette().background.neutral.color }, width: if matches!(status, button::Status::Hovered) { 1.5 @@ -270,6 +241,7 @@ pub fn color_grid_window(on_pick: impl Fn(AcadColor) -> Message) -> Element<'sta }, radius: 1.0.into(), }, + text_color: theme.extended_palette().background.base.text, ..Default::default() }) .padding(0), @@ -281,14 +253,16 @@ pub fn color_grid_window(on_pick: impl Fn(AcadColor) -> Message) -> Element<'sta container( column![ - text("Select Color").size(13).color(TEXT), + text("Select Color").size(13), row![chip(AcadColor::ByLayer, "ByLayer"), chip(AcadColor::ByBlock, "ByBlock")].spacing(6), scrollable(grid).height(Length::Fill), ] .spacing(8), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PICKER_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .padding(10) diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 24a0b05e7..085d33de6 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -370,11 +370,14 @@ impl CommandLine { let history_rows = visible[start..] .iter() .fold(column![].spacing(0), |col, entry| { - let color = match entry.kind { - EntryKind::Command => CMD_COLOR, - EntryKind::Output => OUT_COLOR, - EntryKind::Error => ERR_COLOR, - EntryKind::Info => INFO_COLOR, + let kind = entry.kind.clone(); + let entry_text = |value: String| { + text(value).size(11).style({ + let kind = kind.clone(); + move |theme: &Theme| iced::widget::text::Style { + color: Some(history_color(theme, &kind)), + } + }) }; // The current step's prompt is the single pinned line. When the // step offers options, render them as clickable buttons inline @@ -384,31 +387,28 @@ impl CommandLine { // listing is dropped here (the history log keeps the full text). if entry.pinned && !self.step_options.is_empty() { let shown = strip_option_listing(&entry.text); - let mut r = row![text(shown).size(11).color(color)] + let mut r = row![entry_text(shown)] .spacing(6) .align_y(iced::Center); for opt in &self.step_options { - let btn = button( - text(opt.label.to_uppercase()).size(11).color(CMD_COLOR), - ) + let btn = button(text(opt.label.to_uppercase()).size(11)) .on_press(Message::CommandOptionPick(opt.keyword.clone())) .padding([1, 6]) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered) { - Color { - r: 0.28, - g: 0.40, - b: 0.56, - a: 1.0, - } + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = if matches!( + status, + button::Status::Hovered | button::Status::Pressed + ) { + palette.primary.weak } else { - INPUT_ROW_BG + palette.background.weakest }; button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, @@ -419,46 +419,41 @@ impl CommandLine { } col.push(container(r).padding([1, 8])) } else { - col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8])) + col.push(container(entry_text(entry.text.clone())).padding([1, 8])) } }); - let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]); + let prompt = container( + text("Command:").size(11).style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().success.base.color), + }), + ) + .padding([5, 8]); // Literal-space toggle: while active, every line behaves as if it // started with `>` β€” Space stays in the line instead of submitting, so // arguments with spaces (text strings, paths, `UCS Z 90` as one line) // can be typed. Persists until toggled off; a hand-typed leading `>` // lights the button too (same mode, one line only). let literal_active = self.literal_spaces || self.input.starts_with('>'); - let literal_btn = button(text(">").size(11).color(if literal_active { - Color::WHITE - } else { - PROMPT_COLOR - })) + let literal_btn = button(text(">").size(11)) .on_press(Message::CommandLiteralToggle) .padding([2, 6]) - .style(move |_: &Theme, status| { - let bg = if literal_active { - Color { - r: 0.28, - g: 0.40, - b: 0.56, - a: 1.0, - } - } else if matches!(status, button::Status::Hovered) { - Color { - r: 0.22, - g: 0.30, - b: 0.42, - a: 1.0, - } + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = if literal_active { + palette.primary.weak + } else if matches!( + status, + button::Status::Hovered | button::Status::Pressed + ) { + palette.background.weak } else { - INPUT_ROW_BG + palette.background.weakest }; button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, @@ -467,19 +462,10 @@ impl CommandLine { }); let literal_tip = container( text("Literal spaces: Space stays in the line instead of running the command (same as typing a leading '>'). Stays on until toggled off.") - .size(11) - .color(Color::WHITE), + .size(11), ) .padding([3, 6]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); + .style(container::bordered_box); let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4); // While dynamic input is capturing keystrokes, the command-line // text field is left without an `on_input` handler so it can't @@ -492,33 +478,6 @@ impl CommandLine { .on_submit(Message::CommandSubmit); } let input = input - .style(|_: &Theme, _| text_input::Style { - background: Background::Color(INPUT_BG), - border: Border { - color: Color { - r: 0.40, - g: 0.60, - b: 0.90, - a: 1.0, - }, - width: 1.0, - radius: 2.0.into(), - }, - icon: Color::WHITE, - placeholder: Color { - r: 0.4, - g: 0.4, - b: 0.4, - a: 1.0, - }, - value: Color::WHITE, - selection: Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.5, - }, - }) .size(11) .padding([4, 6]); // Autocomplete suggestions panel, shown above the input row @@ -537,31 +496,25 @@ impl CommandLine { let mut col = column![].spacing(0).width(Length::Fill); for (idx, cmd) in matches.iter().enumerate() { let is_selected = cursor == idx; - let row = button(text(cmd.clone()).size(11).color(CMD_COLOR)) + let row = button(text(cmd.clone()).size(11)) .on_press(Message::CommandSuggestionPick(cmd.clone())) .width(Length::Fill) .padding([2, 8]) - .style(move |_: &Theme, status| { - let bg = if is_selected { - Color { - r: 0.28, - g: 0.40, - b: 0.56, - a: 1.0, - } - } else if matches!(status, button::Status::Hovered) { - Color { - r: 0.22, - g: 0.30, - b: 0.42, - a: 1.0, - } + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = if is_selected { + palette.primary.weak + } else if matches!( + status, + button::Status::Hovered | button::Status::Pressed + ) { + palette.background.weak } else { - PANEL_BG + palette.background.base }; button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border::default(), ..Default::default() } @@ -569,15 +522,7 @@ impl CommandLine { col = col.push(row); } container(col) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) + .style(container::bordered_box) .width(Length::Fill) .into() } @@ -590,22 +535,13 @@ impl CommandLine { // user can recover anything that has already faded off the // overlay. let dropdown_icon = if self.history_open { - crate::ui::icons::arrow_down(11.0, PROMPT_COLOR) + crate::ui::icons::themed_arrow_down(11.0) } else { - crate::ui::icons::arrow_right(11.0, PROMPT_COLOR) + crate::ui::icons::themed_arrow_right(11.0) }; let dropdown_btn = button(dropdown_icon) .on_press(Message::CommandHistoryToggle) - .style(|_: &Theme, _status| button::Style { - background: Some(Background::Color(INPUT_ROW_BG)), - text_color: Color::WHITE, - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(button::subtle) .padding([2, 6]); let input_row = row![prompt, literal_btn, input, dropdown_btn] .spacing(4) @@ -625,23 +561,21 @@ impl CommandLine { .size(11) .padding([2, 8]) .max_height(180.0) - .style(|_: &Theme, _status| text_editor::Style { - background: Background::Color(PANEL_BG), - border: Border::default(), - placeholder: OUT_COLOR, - value: CMD_COLOR, - selection: Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.5, - }, + .style(|theme: &Theme, _status| { + let palette = theme.extended_palette(); + text_editor::Style { + background: Background::Color(palette.background.base.color), + border: Border::default(), + placeholder: palette.background.base.text.scale_alpha(0.72), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } }); // Header strip: a Copy-all and a Clear button pinned above the log. let copy_btn = button( row![ - crate::ui::icons::tinted(crate::ui::icons::COPY, 11.0, PROMPT_COLOR), - text("Copy").size(11).color(CMD_COLOR), + crate::ui::icons::themed_success(crate::ui::icons::COPY, 11.0), + text("Copy").size(11), ] .spacing(4) .align_y(iced::Center), @@ -651,8 +585,8 @@ impl CommandLine { .padding([2, 6]); let clear_btn = button( row![ - crate::ui::icons::tinted(crate::ui::icons::TRASH, 11.0, ERR_COLOR), - text("Clear").size(11).color(CMD_COLOR), + crate::ui::icons::themed_warning(crate::ui::icons::TRASH, 11.0), + text("Clear").size(11), ] .spacing(4) .align_y(iced::Center), @@ -668,15 +602,7 @@ impl CommandLine { .width(Length::Fill) .padding([2, 6]); let panel = container(column![header, log]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) + .style(container::bordered_box) .width(Length::Fill) .padding([4, 0]); opaque(panel).into() @@ -688,35 +614,43 @@ impl CommandLine { autocomplete, dropdown, container(history_rows) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(HISTORY_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Length::Fill) .padding([2, 0]), container(input_row) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(INPUT_ROW_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weakest.color)), border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Length::Fill) // Match the drawing tab bar / status bar height, and vertically // centre the prompt/input/dropdown within it (issue #216). .center_y(Length::Fixed(30.0)), ]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } }) .width(Length::Fixed(720.0)) .into() @@ -780,22 +714,18 @@ pub fn ranked_matches( /// Flat button style for the history dropdown's Copy / Clear strip: a subtle /// filled pill that brightens on hover. -fn header_btn_style(_: &Theme, status: button::Status) -> button::Style { - let bg = if matches!(status, button::Status::Hovered) { - Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, - } +fn header_btn_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) { + palette.background.weak } else { - INPUT_ROW_BG + palette.background.weakest }; button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, @@ -803,66 +733,15 @@ fn header_btn_style(_: &Theme, status: button::Status) -> button::Style { } } -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const HISTORY_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const INPUT_ROW_BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -const INPUT_BG: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const BORDER_COLOR: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, -}; -const PROMPT_COLOR: Color = Color { - r: 0.55, - g: 0.78, - b: 0.55, - a: 1.0, -}; -const CMD_COLOR: Color = Color { - r: 0.80, - g: 0.80, - b: 0.80, - a: 1.0, -}; -const OUT_COLOR: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; -const ERR_COLOR: Color = Color { - r: 0.90, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const INFO_COLOR: Color = Color { - r: 0.50, - g: 0.70, - b: 0.90, - a: 1.0, -}; +fn history_color(theme: &Theme, kind: &EntryKind) -> Color { + let palette = theme.extended_palette(); + match kind { + EntryKind::Command => palette.background.base.text, + EntryKind::Output => palette.background.base.text.scale_alpha(0.72), + EntryKind::Error => palette.danger.base.color, + EntryKind::Info => palette.primary.base.color, + } +} #[cfg(test)] mod tests { diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 61c1b7ed0..50aa23db6 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -1,4 +1,4 @@ -//! Shared monochrome UI-chrome icons rendered from bundled SVGs. +//! Shared SVG rendering for monochrome UI chrome and multi-colour tool icons. //! //! Dropdown carets and the undo/redo controls used to be drawn as Unicode //! glyphs (`β–Ύ`, `β–²`, `β†Ά`, `β†·`). Those depend on the active text font carrying @@ -6,8 +6,18 @@ //! build bundles only Fira Sans, which lacks them, so they rendered as empty //! boxes. Drawing them from SVG instead makes the chrome font-independent. +use std::cell::RefCell; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer; +use iced::advanced::svg::{self as core_svg, Renderer as _}; +use iced::advanced::widget::{Tree, Widget}; use iced::widget::{container, svg, Space}; -use iced::{Color, Element, Length, Theme}; +use iced::{ + Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer, + Size, Theme, +}; +use rustc_hash::FxHashMap; const TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg"); const TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg"); @@ -65,7 +75,7 @@ pub const FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export pub const PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg"); pub const HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.svg"); pub const DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg"); -pub const TRI_LEFT_B: &[u8] = include_bytes!("../../assets/icons/ui/tri_left.svg"); +pub const DIRTY_DOT: &[u8] = include_bytes!("../../assets/icons/ui/dirty_dot.svg"); pub const ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg"); // ── Status-bar toggle icons (issue #216) ────────────────────────────────── @@ -82,40 +92,415 @@ pub const ST_FILTER: &[u8] = include_bytes!("../../assets/icons/status/filter.sv pub const ST_SELCYCLE: &[u8] = include_bytes!("../../assets/icons/status/selcycle.svg"); pub const ST_CLEANSCREEN: &[u8] = include_bytes!("../../assets/icons/status/cleanscreen.svg"); -/// Render one of the bundled SVGs tinted to `color` at a square `size`. -pub fn tinted<'a, M: 'a>(bytes: &'static [u8], size: f32, color: Color) -> Element<'a, M> { +// Tool SVGs share a small source palette. These colours are semantic rather +// than literal: cyan is the accent, pale grey is foreground, yellow is warning, +// and so on. `SemanticIcon` resolves those roles from Iced's active extended +// palette at draw time, retaining the artwork's multiple colours across themes. +const SEMANTIC_CACHE_LIMIT: usize = 2048; + +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +struct SemanticCacheKey { + address: usize, + length: usize, + palette: [[u8; 4]; 6], +} + +thread_local! { + static SEMANTIC_CACHE: RefCell> = + RefCell::new(FxHashMap::default()); +} + +#[derive(Clone, Copy)] +struct SemanticColors { + background: [u8; 7], + text: [u8; 7], + primary_weak: [u8; 7], + primary: [u8; 7], + primary_strong: [u8; 7], + secondary_weak: [u8; 7], + secondary: [u8; 7], + secondary_strong: [u8; 7], + success_weak: [u8; 7], + success: [u8; 7], + warning: [u8; 7], + warning_strong: [u8; 7], + danger_weak: [u8; 7], + danger: [u8; 7], +} + +impl SemanticColors { + fn from_theme(theme: &Theme) -> Self { + let palette = theme.extended_palette(); + Self { + background: color_hex(palette.background.strong.color), + text: color_hex(palette.background.base.text), + primary_weak: color_hex(palette.primary.weak.color), + primary: color_hex(palette.primary.base.color), + primary_strong: color_hex(palette.primary.strong.color), + secondary_weak: color_hex(palette.secondary.weak.color), + secondary: color_hex(palette.secondary.base.color), + secondary_strong: color_hex(palette.secondary.strong.color), + success_weak: color_hex(palette.success.weak.color), + success: color_hex(palette.success.base.color), + warning: color_hex(palette.warning.base.color), + warning_strong: color_hex(palette.warning.strong.color), + danger_weak: color_hex(palette.danger.weak.color), + danger: color_hex(palette.danger.base.color), + } + } +} + +struct SemanticIcon { + bytes: &'static [u8], + size: f32, + opacity: f32, +} + +impl Widget for SemanticIcon { + fn size(&self) -> Size { + Size::new(Length::Fixed(self.size), Length::Fixed(self.size)) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic( + limits, + Length::Fixed(self.size), + Length::Fixed(self.size), + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: iced::advanced::mouse::Cursor, + _viewport: &Rectangle, + ) { + let handle = semantic_handle(self.bytes, theme); + let measured = renderer.measure_svg(&handle); + if measured.width == 0 || measured.height == 0 { + return; + } + + let image_size = Size::new(measured.width as f32, measured.height as f32); + let bounds = layout.bounds(); + let fitted = ContentFit::Contain.fit(image_size, bounds.size()); + let position = Point::new( + bounds.center_x() - fitted.width / 2.0, + bounds.center_y() - fitted.height / 2.0, + ); + + renderer.draw_svg( + core_svg::Svg { + handle, + color: None, + rotation: Radians(0.0), + opacity: self.opacity, + }, + Rectangle::new(position, fitted), + bounds, + ); + } +} + +/// Render a multi-colour tool icon using semantic colours from the active theme. +pub fn semantic<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + Element::new(SemanticIcon { + bytes, + size, + opacity: 1.0, + }) +} + +/// Render a disabled multi-colour tool icon without flattening its colour roles. +pub fn semantic_disabled<'a, M: 'a>( + bytes: &'static [u8], + size: f32, +) -> Element<'a, M> { + Element::new(SemanticIcon { + bytes, + size, + opacity: 0.42, + }) +} + +fn semantic_handle(bytes: &'static [u8], theme: &Theme) -> svg::Handle { + let key = SemanticCacheKey { + address: bytes.as_ptr() as usize, + length: bytes.len(), + palette: palette_key(theme), + }; + + SEMANTIC_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if let Some(handle) = cache.get(&key) { + return handle.clone(); + } + + let handle = svg::Handle::from_memory(recolor_semantic_svg(bytes, theme)); + if cache.len() >= SEMANTIC_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, handle.clone()); + handle + }) +} + +fn palette_key(theme: &Theme) -> [[u8; 4]; 6] { + let palette = theme.palette(); + [ + palette.background.into_rgba8(), + palette.text.into_rgba8(), + palette.primary.into_rgba8(), + palette.success.into_rgba8(), + palette.warning.into_rgba8(), + palette.danger.into_rgba8(), + ] +} + +fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec { + let colors = SemanticColors::from_theme(theme); + let mut output = Vec::with_capacity(source.len()); + let mut index = 0; + + while index < source.len() { + if source[index] == b'#' { + let mut end = index + 1; + while end < source.len() && source[end].is_ascii_hexdigit() { + end += 1; + } + let digit_count = end - index - 1; + if matches!(digit_count, 3 | 4 | 6 | 8) { + if let Some(replacement) = + semantic_color(&source[index..end], &colors) + { + output.extend_from_slice(replacement); + index = end; + continue; + } + } + } else if starts_with_word_ignore_ascii_case(source, index, b"white") { + output.extend_from_slice(&colors.text); + index += 5; + continue; + } + + output.push(source[index]); + index += 1; + } + + output +} + +fn semantic_color<'a>( + token: &[u8], + colors: &'a SemanticColors, +) -> Option<&'a [u8; 7]> { + if is_one_of(token, &["#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"]) { + Some(&colors.text) + } else if is_one_of(token, &["#cccccc", "#bdbdbd", "#aaaaaa"]) { + Some(&colors.secondary_strong) + } else if is_one_of( + token, + &["#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a"], + ) { + Some(&colors.secondary) + } else if is_one_of( + token, + &[ + "#505050", "#555", "#606060", "#666", "#616161", "#546e7a", + "#455a64", "#37474f", + ], + ) { + Some(&colors.secondary_weak) + } else if is_one_of(token, &["#1a1a1a"]) { + Some(&colors.background) + } else if is_one_of(token, &["#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"]) { + Some(&colors.primary) + } else if is_one_of(token, &["#1565c0"]) { + Some(&colors.primary_strong) + } else if is_one_of(token, &["#0d47a1"]) { + Some(&colors.primary_weak) + } else if is_one_of(token, &["#4ccf6f"]) { + Some(&colors.success) + } else if is_one_of(token, &["#00695c", "#004d40"]) { + Some(&colors.success_weak) + } else if is_one_of(token, &["#f0c040", "#ffd740", "#fdd835"]) { + Some(&colors.warning) + } else if is_one_of(token, &["#f9a825"]) { + Some(&colors.warning_strong) + } else if is_one_of( + token, + &[ + "#e05050", "#ef5350", "#e06c6c", "#e53935", "#ff0000", "#e10000", + ], + ) { + Some(&colors.danger) + } else if is_one_of(token, &["#b71c1c"]) { + Some(&colors.danger_weak) + } else { + None + } +} + +fn is_one_of(token: &[u8], candidates: &[&str]) -> bool { + candidates + .iter() + .any(|candidate| token.eq_ignore_ascii_case(candidate.as_bytes())) +} + +fn starts_with_word_ignore_ascii_case( + source: &[u8], + index: usize, + word: &[u8], +) -> bool { + let Some(end) = index.checked_add(word.len()) else { + return false; + }; + if end > source.len() + || !source[index..end].eq_ignore_ascii_case(word) + || index > 0 && source[index - 1].is_ascii_alphabetic() + || end < source.len() && source[end].is_ascii_alphabetic() + { + return false; + } + true +} + +fn color_hex(color: Color) -> [u8; 7] { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let [red, green, blue, _] = color.into_rgba8(); + [ + b'#', + HEX[(red >> 4) as usize], + HEX[(red & 0x0f) as usize], + HEX[(green >> 4) as usize], + HEX[(green & 0x0f) as usize], + HEX[(blue >> 4) as usize], + HEX[(blue & 0x0f) as usize], + ] +} + +/// Render a chrome icon with the active Iced theme's normal text color. +pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { svg(svg::Handle::from_memory(bytes)) .width(size) .height(size) - .style(move |_: &Theme, _| svg::Style { color: Some(color) }) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().background.base.text), + }) .into() } -/// Backwards-compatible alias used by the caret/undo/redo helpers below. -fn icon<'a, M: 'a>(bytes: &'static [u8], size: f32, color: Color) -> Element<'a, M> { - tinted(bytes, size, color) +/// Render secondary chrome with the active Iced theme's text color. +pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }) + .into() } -/// A fixed-width (14 px) "current row" check column: a green-tintable check -/// when `active`, otherwise an empty spacer that preserves alignment. Used by -/// the many dropdown / popup list rows that mark the selected entry. -pub fn check_cell<'a, M: 'a>(active: bool, color: Color) -> Element<'a, M> { +/// Render disabled chrome with the active Iced theme's text color. +pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.42), + ), + }) + .into() +} + +/// Render an emphasized chrome icon with the active Iced theme's primary color. +pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().primary.base.color), + }) + .into() +} + +/// Render a positive-state chrome icon with the active Iced theme's success color. +pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().success.base.color), + }) + .into() +} + +/// Render a warning-state chrome icon from the active Iced theme. +pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().warning.base.color), + }) + .into() +} + +/// Render a destructive-state chrome icon from the active Iced theme. +pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().danger.base.color), + }) + .into() +} + +/// Render an icon with the foreground chosen for a danger-coloured surface. +pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(svg::Handle::from_memory(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.extended_palette().danger.base.text), + }) + .into() +} + +/// Fixed-width check column colored from the active Iced theme. +pub fn themed_check_cell<'a, M: 'a>(active: bool) -> Element<'a, M> { let inner: Element<'a, M> = if active { - tinted(CHECK, 11.0, color) + themed_primary(CHECK, 11.0) } else { Space::new().width(0).into() }; container(inner).width(Length::Fixed(14.0)).into() } -/// Render a bundled SVG at its native colours (no tint) at a square `size`. -pub fn raw<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) - .width(size) - .height(size) - .into() -} - /// SVG bytes for an OSNAP mode's marker symbol, for the snap menu. (#138) pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] { use crate::snap::SnapType as S; @@ -166,46 +551,102 @@ pub fn layer_lock(locked: bool) -> &'static [u8] { } } -/// Downward dropdown caret (replaces `β–Ύ`). -pub fn arrow_down<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(TRI_DOWN, size, color) +pub fn themed_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed(TRI_DOWN, size) } -/// Upward dropdown caret, shown when a dropdown is open (replaces `β–²`). -pub fn arrow_up<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(TRI_UP, size, color) +pub fn themed_arrow_up<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed(TRI_UP, size) } -/// Rightward caret for a collapsed item (replaces `β–Έ`). -pub fn arrow_right<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(TRI_RIGHT, size, color) +pub fn themed_arrow_right<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed(TRI_RIGHT, size) } -/// Leftward caret. -pub fn arrow_left<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(TRI_LEFT, size, color) +pub fn themed_arrow_left<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed(TRI_LEFT, size) } -/// House glyph β€” the ViewCube "home view" button. -pub fn home<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(HOME, size, color) +pub fn themed_primary_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed_primary(TRI_DOWN, size) +} + +pub fn themed_secondary_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed_secondary(TRI_DOWN, size) +} + +pub fn themed_disabled_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed_disabled(TRI_DOWN, size) +} + +pub fn themed_home<'a, M: 'a>(size: f32) -> Element<'a, M> { + themed(HOME, size) } /// Caret that flips up/down with `open`. -pub fn arrow_toggle<'a, M: 'a>(open: bool, size: f32, color: Color) -> Element<'a, M> { +pub fn themed_arrow_toggle<'a, M: 'a>(open: bool, size: f32) -> Element<'a, M> { if open { - arrow_up(size, color) + themed_arrow_up(size) } else { - arrow_down(size, color) + themed_arrow_down(size) } } -/// Undo curved arrow (replaces `β†Ά`). -pub fn undo<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(UNDO, size, color) +pub fn themed_undo<'a, M: 'a>(size: f32, enabled: bool) -> Element<'a, M> { + if enabled { + themed(UNDO, size) + } else { + themed_disabled(UNDO, size) + } } -/// Redo curved arrow (replaces `β†·`). -pub fn redo<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { - icon(REDO, size, color) +pub fn themed_redo<'a, M: 'a>(size: f32, enabled: bool) -> Element<'a, M> { + if enabled { + themed(REDO, size) + } else { + themed_disabled(REDO, size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_svg_uses_multiple_theme_roles() { + let source = br##" + + + + + + + "##; + let theme = Theme::Dark; + let colors = SemanticColors::from_theme(&theme); + let themed = recolor_semantic_svg(source, &theme); + + assert!(contains(&themed, &colors.text)); + assert!(contains(&themed, &colors.primary)); + assert!(contains(&themed, &colors.warning)); + assert!(contains(&themed, &colors.danger)); + assert!(contains(&themed, &colors.success)); + assert!(contains(&themed, b"#123456")); + } + + #[test] + fn semantic_svg_maps_named_white() { + let theme = Theme::Dark; + let colors = SemanticColors::from_theme(&theme); + let themed = + recolor_semantic_svg(br##""##, &theme); + + assert!(contains(&themed, &colors.text)); + } + + fn contains(source: &[u8], needle: &[u8]) -> bool { + source + .windows(needle.len()) + .any(|window| window == needle) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 38640b8d1..d6ce49f54 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,6 +2,17 @@ /// Change this to scale the ribbon, layer manager rows, and property panel rows uniformly. pub const ROW_H: f32 = 26.0; +/// Place `content` at fixed top-left coordinates inside a fill-sized layer. +/// Negative coordinates clamp to the layer edge. +pub fn pin_at<'a, Message: 'a>( + position: iced::Point, + content: impl Into>, +) -> iced::Element<'a, Message> { + iced::widget::pin(content) + .position(iced::Point::new(position.x.max(0.0), position.y.max(0.0))) + .into() +} + pub mod color_select; pub mod command_line; pub mod icons; diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 5b30ab83d..64a0a8877 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -7,34 +7,7 @@ use crate::app::Message; use iced::widget::{button, column, container, mouse_area, opaque, row, stack}; -use iced::{Background, Border, Color, Element, Length, Padding, Theme, Vector}; - -const PANEL: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -const BORDER_C: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -/// Title-bar background. -const TITLE_C: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -/// Drag-handle arrow β€” bright green so the move affordance stands out. -const GRIP_C: Color = Color { - r: 0.2, - g: 1.0, - b: 0.3, - a: 1.0, -}; +use iced::{Background, Border, Element, Length, Padding, Theme, Vector}; /// Stack `content` over `base` behind a dimmed backdrop, framed with a /// draggable title bar (the βœ• close button at its right end). The backdrop only @@ -52,15 +25,9 @@ pub fn modal<'a>( offset: Vector, resizable: bool, ) -> Element<'a, Message> { - let close = button(crate::ui::icons::tinted( + let close = button(crate::ui::icons::themed_danger( crate::ui::icons::CLOSE, 13.0, - Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 1.0, - }, )) .on_press(on_close) .padding([1, 7]) @@ -71,10 +38,12 @@ pub fn modal<'a>( // screen width β€” the dialog stays sized to its content. Pressing the grip // starts a drag (handled in `update`). let grip = mouse_area( - container(crate::ui::icons::tinted(crate::ui::icons::MOVE, 14.0, GRIP_C)) + container(crate::ui::icons::themed_primary(crate::ui::icons::MOVE, 14.0)) .padding([1, 7]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TITLE_C)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), border: Border { radius: 4.0.into(), ..Default::default() @@ -89,12 +58,7 @@ pub fn modal<'a>( // overlaid at the right edge. The bar takes an explicit `title_width` // (the caller's content width) instead of `Fill` β€” a Fill child inside // the Shrink frame would blow the dialog out to the full screen. - let title_text = iced::widget::text(title).size(15).color(Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, - }); + let title_text = iced::widget::text(title).size(15); let title_bar = stack![ container(title_text) .width(Length::Fixed(title_width)) @@ -108,10 +72,12 @@ pub fn modal<'a>( .align_y(iced::alignment::Vertical::Center), ]; - let panel_style = |_: &Theme| container::Style { - background: Some(Background::Color(PANEL)), + let panel_style = |theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), border: Border { - color: BORDER_C, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 6.0.into(), }, @@ -125,7 +91,7 @@ pub fn modal<'a>( // top-right above the content either way (`align_x(Right)`). let body = if resizable { let resize = mouse_area( - container(crate::ui::icons::tinted(crate::ui::icons::RESIZE, 15.0, GRIP_C)) + container(crate::ui::icons::themed_primary(crate::ui::icons::RESIZE, 15.0)) .padding([0, 2]), ) .on_press(Message::ModalResizeGrab) @@ -157,11 +123,15 @@ pub fn modal<'a>( .center_x(Length::Fill) .center_y(Length::Fill) .padding(pad) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - a: 0.55, - ..Color::BLACK - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .extended_palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), ..Default::default() }), ) @@ -177,24 +147,15 @@ pub fn modal<'a>( .into() } -fn close_style(_: &Theme, status: button::Status) -> button::Style { - let bg = match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.7, - g: 0.2, - b: 0.2, - a: 1.0, - }, - _ => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, - }, +fn close_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.danger.strong, + _ => palette.background.weakest, }; button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { radius: 4.0.into(), ..Default::default() diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index f333049e5..31e31b279 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -292,7 +292,7 @@ struct SelectionCanvas { hover_locked: bool, } -fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker) { +fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) { let sp = grip.pos; let h = crate::scene::pick::grip::GRIP_HALF_PX; let path = match grip.shape { @@ -332,30 +332,13 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker) { }; if grip.is_hot { - frame.fill( - &path, - Color { - r: 1.0, - g: 0.15, - b: 0.10, - a: 1.0, - }, - ); + frame.fill(&path, theme.extended_palette().danger.base.color); } else { - let color = Color { - r: 0.10, - g: 0.45, - b: 0.90, - a: 1.0, - }; + let palette = theme.extended_palette(); + let color = palette.primary.base.color; frame.fill( &path, - Color { - r: 0.10, - g: 0.10, - b: 0.20, - a: 0.7, - }, + palette.background.base.color.scale_alpha(0.7), ); frame.stroke( &path, @@ -442,7 +425,7 @@ impl canvas::Program for SelectionCanvas { &self, _state: &(), renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: iced::Rectangle, cursor: mouse::Cursor, ) -> Vec { @@ -452,18 +435,13 @@ impl canvas::Program for SelectionCanvas { // Filled bars in the pane_grid spacing gaps, so adjacent panes read as // distinct viewports. Drawn first so all other overlays sit on top. if !self.dividers.is_empty() { - const DIVIDER: Color = Color { - r: 0.46, - g: 0.52, - b: 0.62, - a: 1.0, - }; + let divider = theme.extended_palette().background.neutral.color; for d in &self.dividers { let bar = canvas::Path::rectangle( Point::new(d.x, d.y), iced::Size::new(d.width.max(1.0), d.height.max(1.0)), ); - frame.fill(&bar, DIVIDER); + frame.fill(&bar, divider); } } @@ -472,23 +450,13 @@ impl canvas::Program for SelectionCanvas { // under the cursor, and drag a translucent ghost card along the cursor // so the pane is visibly "moving". if let Some(src) = self.pane_move_rect { - let accent = Color { - r: 0.30, - g: 0.62, - b: 1.0, - a: 1.0, - }; + let accent = theme.extended_palette().primary.base.color; // Source pane: dimmed + dashed-feel outline (it has been lifted). let src_path = canvas::Path::rectangle(Point::new(src.x, src.y), iced::Size::new(src.width, src.height)); frame.fill( &src_path, - Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.28, - }, + theme.extended_palette().background.strong.color.scale_alpha(0.28), ); frame.stroke( &src_path, @@ -539,18 +507,20 @@ impl canvas::Program for SelectionCanvas { // Draw a selection marquee (green crossing / blue window) as a filled, // stroked rectangle between two canvas points. Shared by the live // box-selection and the preview-only window marquee (#291). - fn draw_marquee(frame: &mut canvas::Frame, a: Point, b: Point, crossing: bool) { - let (fill, stroke) = if crossing { - ( - Color { r: 0.20, g: 0.72, b: 0.44, a: 0.12 }, - Color { r: 0.20, g: 0.72, b: 0.44, a: 0.9 }, - ) + fn draw_marquee( + frame: &mut canvas::Frame, + a: Point, + b: Point, + crossing: bool, + theme: &Theme, + ) { + let base = if crossing { + theme.extended_palette().success.base.color } else { - ( - Color { r: 0.20, g: 0.44, b: 0.72, a: 0.12 }, - Color { r: 0.20, g: 0.44, b: 0.72, a: 0.9 }, - ) + theme.extended_palette().primary.base.color }; + let fill = base.scale_alpha(0.12); + let stroke = base.scale_alpha(0.9); let x0 = a.x.min(b.x); let y0 = a.y.min(b.y); let w = (a.x - b.x).abs(); @@ -568,45 +538,21 @@ impl canvas::Program for SelectionCanvas { } if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { - draw_marquee(&mut frame, a, b, self.selection.box_crossing); + draw_marquee(&mut frame, a, b, self.selection.box_crossing, theme); } // Preview marquee for point-picked windows (STRETCH) β€” same look, no pick. if let Some((a, b, crossing)) = self.selection.preview_box { - draw_marquee(&mut frame, a, b, crossing); + draw_marquee(&mut frame, a, b, crossing, theme); } if self.selection.poly_active && self.selection.poly_points.len() > 1 { - let (fill, stroke) = if self.selection.poly_crossing { - ( - Color { - r: 0.20, - g: 0.72, - b: 0.44, - a: 0.12, - }, - Color { - r: 0.20, - g: 0.72, - b: 0.44, - a: 0.9, - }, - ) + let base = if self.selection.poly_crossing { + theme.extended_palette().success.base.color } else { - ( - Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.12, - }, - Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.9, - }, - ) + theme.extended_palette().primary.base.color }; + let fill = base.scale_alpha(0.12); + let stroke = base.scale_alpha(0.9); if let Some(cur) = self.selection.last_move_pos { let start = self.selection.poly_points[0]; let fill_path = canvas::Path::new(|p| { @@ -667,7 +613,7 @@ impl canvas::Program for SelectionCanvas { }; frame.with_clip(grip_clip, |frame| { for grip in &self.grips { - draw_grip_marker(frame, grip); + draw_grip_marker(frame, grip, theme); } }); } @@ -962,12 +908,12 @@ impl canvas::Program for SelectionCanvas { // PAN mode replaces the crosshair with a hand cursor. if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed { if let Some(cp) = self.selection.last_move_pos { - let color = Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 0.90, - }; + let color = theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.90); let stroke = canvas::Stroke { width: 1.0, style: canvas::Style::Solid(color), @@ -1010,8 +956,9 @@ impl canvas::Program for SelectionCanvas { // the hovered object sits on a locked layer (issue: locked // objects are visible + snappable but not selectable/editable). if self.hover_locked { - let amber = Color { r: 0.96, g: 0.76, b: 0.26, a: 0.98 }; - let dark = Color { r: 0.12, g: 0.10, b: 0.04, a: 1.0 }; + let warning = theme.extended_palette().warning.base; + let amber = warning.color.scale_alpha(0.98); + let dark = warning.text; let bx = cp.x + sq + 7.0; let by = cp.y - sq - 13.0; // Lock body (filled). @@ -1058,12 +1005,7 @@ impl canvas::Program for SelectionCanvas { } // ── Object Snap Tracking ───────────────────────────────────────────── - let track_color = Color { - r: 0.15, - g: 0.85, - b: 0.95, - a: 0.7, - }; + let track_color = theme.extended_palette().primary.base.color.scale_alpha(0.7); // The alignment line the cursor is currently locked to β€” drawn at its // real angle from the acquired point through the lock and a little // beyond, dashed so it reads as a construction guide. This covers the @@ -2142,10 +2084,12 @@ struct DynInputCanvas { } impl DynInputCanvas { - fn dotted() -> canvas::Stroke<'static> { + fn dotted(theme: &Theme) -> canvas::Stroke<'static> { canvas::Stroke { width: 1.0, - style: canvas::Style::Solid(Color { r: 0.55, g: 0.55, b: 0.58, a: 0.9 }), + style: canvas::Style::Solid( + theme.extended_palette().background.neutral.color.scale_alpha(0.9) + ), line_dash: canvas::LineDash { segments: &[2.0, 3.0], offset: 0 }, ..Default::default() } @@ -2160,13 +2104,19 @@ impl DynInputCanvas { } /// Draw a value box centred at `center`, clamped inside `bounds`. - fn draw_box(frame: &mut canvas::Frame, b: &DynBox, center: Point, bounds: iced::Rectangle) { + fn draw_box( + frame: &mut canvas::Frame, + b: &DynBox, + center: Point, + bounds: iced::Rectangle, + theme: &Theme, + ) { let content = Self::box_content(b); let w = (content.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let x = (center.x - w * 0.5).clamp(0.0, (bounds.width - w).max(0.0)); let y = (center.y - DYN_BOX_H * 0.5).clamp(0.0, (bounds.height - DYN_BOX_H).max(0.0)); let rect = canvas::Path::rectangle(Point { x, y }, Size { width: w, height: DYN_BOX_H }); - let (fill, border) = Self::box_colors(b); + let (fill, border, text) = Self::box_colors(b, theme); frame.fill(&rect, fill); frame.stroke( &rect, @@ -2177,7 +2127,7 @@ impl DynInputCanvas { frame.fill_text(canvas::Text { content, position: Point { x: x + DYN_PAD, y: y + DYN_PAD }, - color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, + color: text, size: iced::Pixels(DYN_FONT), // Force Advanced shaping: the default `Auto` uses Basic shaping for // ASCII-only strings, which the web (wgpu/webgl) backend fails to @@ -2188,43 +2138,48 @@ impl DynInputCanvas { }); } - fn box_colors(b: &DynBox) -> (Color, Color) { + fn box_colors(b: &DynBox, theme: &Theme) -> (Color, Color, Color) { + let palette = theme.extended_palette(); if b.active { ( - Color { r: 0.12, g: 0.18, b: 0.30, a: 0.95 }, - Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }, + palette.primary.weak.color, + palette.primary.base.color, + palette.primary.weak.text, ) } else if b.locked { ( - Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, - Color { r: 0.95, g: 0.75, b: 0.30, a: 0.9 }, + palette.warning.weak.color, + palette.warning.base.color, + palette.warning.weak.text, ) } else { ( - Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, - Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 }, + palette.background.weak.color, + palette.background.neutral.color, + palette.background.weak.text, ) } } /// Prompt pill at `pos`. - fn draw_prompt(&self, frame: &mut canvas::Frame, pos: Point) { + fn draw_prompt(&self, frame: &mut canvas::Frame, pos: Point, theme: &Theme) { if self.prompt.is_empty() { return; } + let palette = theme.extended_palette(); let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let rect = canvas::Path::rectangle(pos, Size { width: pw, height: DYN_BOX_H }); - frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 }); + frame.fill(&rect, palette.background.strong.color); frame.stroke( &rect, canvas::Stroke::default() - .with_color(Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 }) + .with_color(palette.primary.base.color.scale_alpha(0.9)) .with_width(1.0), ); frame.fill_text(canvas::Text { content: self.prompt.clone(), position: Point { x: pos.x + DYN_PAD, y: pos.y + DYN_PAD }, - color: Color { r: 0.70, g: 0.85, b: 0.70, a: 1.0 }, + color: palette.background.strong.text, size: iced::Pixels(DYN_FONT), shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() @@ -2233,7 +2188,13 @@ impl DynInputCanvas { /// Guided layout: draw the guide geometry anchored at `base`, then place /// each box according to its role. - fn draw_guided(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle, base: Point) { + fn draw_guided( + &self, + frame: &mut canvas::Frame, + bounds: iced::Rectangle, + base: Point, + theme: &Theme, + ) { let cursor_raw = self.cursor_screen; let (vx, vy) = (cursor_raw.x - base.x, cursor_raw.y - base.y); let raw_len = (vx * vx + vy * vy).sqrt().max(1.0); @@ -2308,7 +2269,7 @@ impl DynInputCanvas { y: base.y + a_ref.sin() * len, }); }); - frame.stroke(&href, Self::dotted()); + frame.stroke(&href, Self::dotted(theme)); let arc = canvas::Path::new(|p| { let steps = 48; for k in 0..=steps { @@ -2324,14 +2285,14 @@ impl DynInputCanvas { } } }); - frame.stroke(&arc, Self::dotted()); + frame.stroke(&arc, Self::dotted(theme)); } DynGuide::Radius => { let line = canvas::Path::new(|p| { p.move_to(base); p.line_to(cursor); }); - frame.stroke(&line, Self::dotted()); + frame.stroke(&line, Self::dotted(theme)); } DynGuide::Perp => { if let Some((end, _, _)) = perp_info { @@ -2340,7 +2301,7 @@ impl DynInputCanvas { p.move_to(base); p.line_to(end); }); - frame.stroke(&line, Self::dotted()); + frame.stroke(&line, Self::dotted(theme)); } } DynGuide::PerpDim => { @@ -2351,14 +2312,14 @@ impl DynInputCanvas { p.move_to(ob); p.line_to(oe); }); - frame.stroke(&dim, Self::dotted()); + frame.stroke(&dim, Self::dotted(theme)); let ext = canvas::Path::new(|p| { p.move_to(base); p.line_to(ob); p.move_to(end); p.line_to(oe); }); - frame.stroke(&ext, Self::dotted()); + frame.stroke(&ext, Self::dotted(theme)); } } DynGuide::AxisDelta | DynGuide::RectSides => { @@ -2368,7 +2329,7 @@ impl DynInputCanvas { p.line_to(corner); p.line_to(cursor); }); - frame.stroke(&legs, Self::dotted()); + frame.stroke(&legs, Self::dotted(theme)); if self.guide == DynGuide::RectSides { // Close the rectangle so both side pairs read as a box. let rest = canvas::Path::new(|p| { @@ -2376,7 +2337,7 @@ impl DynInputCanvas { p.line_to(Point { x: base.x, y: cursor.y }); p.line_to(cursor); }); - frame.stroke(&rest, Self::dotted()); + frame.stroke(&rest, Self::dotted(theme)); } } DynGuide::None => {} @@ -2423,12 +2384,12 @@ impl DynInputCanvas { y: base.y + dy * len * 0.5 + ny * 16.0, }, }; - Self::draw_box(frame, b, center, bounds); + Self::draw_box(frame, b, center, bounds, theme); } } /// Fallback row layout near the cursor (no anchor / `None` guide). - fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle) { + fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle, theme: &Theme) { let texts: Vec = self .boxes .iter() @@ -2466,7 +2427,7 @@ impl DynInputCanvas { py = (by - pad - DYN_BOX_H).max(0.0); } if has_prompt { - self.draw_prompt(frame, Point { x: bx, y: py }); + self.draw_prompt(frame, Point { x: bx, y: py }, theme); } let mut x = bx; @@ -2474,7 +2435,7 @@ impl DynInputCanvas { let w = widths[i]; let rect = canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: DYN_BOX_H }); - let (fill, border) = Self::box_colors(b); + let (fill, border, text) = Self::box_colors(b, theme); frame.fill(&rect, fill); frame.stroke( &rect, @@ -2485,7 +2446,7 @@ impl DynInputCanvas { frame.fill_text(canvas::Text { content: texts[i].clone(), position: Point { x: x + DYN_PAD, y: by + DYN_PAD }, - color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, + color: text, size: iced::Pixels(DYN_FONT), shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() @@ -2511,7 +2472,7 @@ impl canvas::Program for DynInputCanvas { &self, _state: &(), renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> Vec { @@ -2529,15 +2490,15 @@ impl canvas::Program for DynInputCanvas { if py + DYN_BOX_H > bounds.height { py = (self.cursor_screen.y - DYN_BOX_H - 4.0).max(0.0); } - self.draw_prompt(&mut frame, Point { x: px, y: py }); + self.draw_prompt(&mut frame, Point { x: px, y: py }, theme); } return vec![frame.into_geometry()]; } // Guided layouts need the anchor; without it fall back to a cursor row. match (self.guide, self.base_screen) { - (DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds), - (_, Some(base)) => self.draw_guided(&mut frame, bounds, base), + (DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds, theme), + (_, Some(base)) => self.draw_guided(&mut frame, bounds, base, theme), } vec![frame.into_geometry()] } diff --git a/src/ui/popup/cycle_popup.rs b/src/ui/popup/cycle_popup.rs index 2f0f0f273..979a913c1 100644 --- a/src/ui/popup/cycle_popup.rs +++ b/src/ui/popup/cycle_popup.rs @@ -2,8 +2,8 @@ //! two or more overlapping objects. Each row names a candidate; clicking it //! adds that object to the current selection. Clicking outside dismisses it. -use iced::widget::{button, column, container, mouse_area, opaque, row, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Length, Theme}; +use iced::widget::{button, column, container, mouse_area, opaque, text}; +use iced::{Element, Fill, Length}; use crate::app::Message; @@ -19,41 +19,19 @@ pub fn cycle_popup_overlay( .collect(); let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(container::bordered_box) .width(Length::Fixed(150.0)); - let positioned = column![ - Space::new().height(Length::Fixed(anchor.y.max(0.0))), - row![ - Space::new().width(Length::Fixed(anchor.x.max(0.0))), - opaque(panel), - ], - ] - .width(Fill) - .height(Fill); + let positioned = crate::ui::pin_at(anchor, opaque(panel)); mouse_area(positioned).on_press(Message::CycleCancel).into() } fn item_row(handle: acadrust::Handle, label: String) -> Element<'static, Message> { - let content = text(label).size(11).color(LABEL).align_y(iced::Center); + let content = text(label).size(11).align_y(iced::Center); let btn = button(content) .on_press(Message::CycleSelect(handle)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]); // Highlight the underlying object while the cursor is over this row. @@ -62,30 +40,3 @@ fn item_row(handle: acadrust::Handle, label: String) -> Element<'static, Message .on_exit(Message::CycleHoverExit(handle)) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.45, - b: 0.62, - a: 1.0, -}; -const LABEL: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; diff --git a/src/ui/popup/isolate_popup.rs b/src/ui/popup/isolate_popup.rs index a112dad9b..de2200bfe 100644 --- a/src/ui/popup/isolate_popup.rs +++ b/src/ui/popup/isolate_popup.rs @@ -1,75 +1,51 @@ -//! Isolate action menu β€” Isolate / Hide / End Isolation. Opened from the -//! ISO status pill, rendered as a floating overlay above the status bar -//! (same pattern as the scale picker). The same actions are also offered -//! in the viewport right-click menu. +//! Isolate / Hide / End Isolation status menu. -use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, row, text}; +use iced::{Element, Fill}; use crate::app::Message; +use crate::ui::statusbar::status_menu::Entry; -/// Full-screen overlay: transparent click-catcher + action list pinned -/// bottom-right, above the status bar. -/// /// - `has_selection`: enables Isolate / Hide (they act on the selection). /// - `isolation_active`: enables End Isolation (something is hidden). -pub fn isolate_popup_overlay( +pub fn menu_entries( has_selection: bool, isolation_active: bool, - pill: Option, - win: (f32, f32), -) -> Element<'static, Message> { - let rows = column![ - action_row( +) -> Vec> { + vec![ + action_entry( "Isolate Objects", has_selection, Message::Command("ISOLATEOBJECTS".to_string()), ), - action_row( + action_entry( "Hide Objects", has_selection, Message::Command("HIDEOBJECTS".to_string()), ), - action_row( + action_entry( "End Isolation", isolation_active, Message::Command("UNISOLATEOBJECTS".to_string()), ), - ]; + ] +} - let panel = container(rows) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(160.0)); - - let positioned = super::position_statusbar_popup(panel.into(), pill, win, 160.0, true); - - mouse_area(positioned) - .on_press(Message::CloseIsolatePopup) - .into() +fn action_entry(label: &'static str, enabled: bool, msg: Message) -> Entry<'static> { + let row = action_row(label, enabled, msg); + if enabled { + Entry::close(row) + } else { + Entry::stay(row) + } } fn action_row(label: &'static str, enabled: bool, msg: Message) -> Element<'static, Message> { - let lbl = text(label) - .size(11) - .color(if enabled { LABEL_ON } else { LABEL_OFF }); + let lbl = text(label).size(11); let content = row![lbl].align_y(iced::Center); let mut btn = button(content) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (enabled, status) { - (true, button::Status::Hovered) => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 12]); if enabled { @@ -77,36 +53,3 @@ fn action_row(label: &'static str, enabled: bool, msg: Message) -> Element<'stat } btn.into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.5, - g: 0.5, - b: 0.5, - a: 1.0, -}; diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index 8d7c7ea91..e5ff95d10 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -5,76 +5,3 @@ pub mod scale_popup; pub mod selection_filter_popup; pub mod snap_popup; pub mod units_popup; - -use iced::widget::container; -use iced::{Element, Fill, Padding, Rectangle}; - -use crate::app::Message; - -/// Decide a status-bar popup's horizontal anchor and paddings from its pill's -/// screen bounds. Returns `(align_right, horizontal_pad, bottom_pad)`. -/// -/// The popup opens just above the pill. Horizontally it prefers `prefer_right` -/// (right edge aligned to the pill's right, growing left) but flips to the other -/// side when that direction would cross the window edge. -pub fn popup_anchor( - pill: Option, - win: (f32, f32), - popup_w: f32, - prefer_right: bool, -) -> (bool, f32, f32) { - let Some(b) = pill else { - // No recorded position yet β€” fall back to the bottom-right corner. - return (prefer_right, 4.0, 27.0); - }; - let bottom = (win.1 - b.y).max(0.0); - let right_pad = (win.0 - (b.x + b.width)).max(4.0); - let left_pad = b.x.max(4.0); - - if prefer_right { - // Right-aligned popup grows left; flip left-aligned if it would pass the - // left window edge. - if (b.x + b.width) - popup_w < 2.0 { - (false, left_pad, bottom) - } else { - (true, right_pad, bottom) - } - } else { - // Left-aligned popup grows right; flip right-aligned if it would pass the - // right window edge. - if b.x + popup_w > win.0 - 2.0 { - (true, right_pad, bottom) - } else { - (false, left_pad, bottom) - } - } -} - -/// Wrap a status-bar popup `panel` in a full-window container positioned just -/// above its pill, flipping horizontally to stay on screen. -pub fn position_statusbar_popup<'a>( - panel: Element<'a, Message>, - pill: Option, - win: (f32, f32), - popup_w: f32, - prefer_right: bool, -) -> Element<'a, Message> { - let (align_right, h_pad, bottom) = popup_anchor(pill, win, popup_w, prefer_right); - let pad = Padding { - top: 0.0, - bottom, - left: if align_right { 0.0 } else { h_pad }, - right: if align_right { h_pad } else { 0.0 }, - }; - let c = container(panel) - .align_bottom(Fill) - .width(Fill) - .height(Fill) - .padding(pad); - let c = if align_right { - c.align_right(Fill) - } else { - c.align_left(Fill) - }; - c.into() -} diff --git a/src/ui/popup/polar_popup.rs b/src/ui/popup/polar_popup.rs index 7ec7d1939..4a1fa26d5 100644 --- a/src/ui/popup/polar_popup.rs +++ b/src/ui/popup/polar_popup.rs @@ -1,12 +1,10 @@ -//! Polar-tracking angle picker β€” sets the polar increment used by POLAR -//! tracking. Rendered as a floating overlay above the status bar, same pattern -//! as the units / scale pickers. Offers the common AutoCAD-style increments -//! plus a free-text field for any custom angle (#264). +//! Polar-tracking angle status menu. -use iced::widget::{button, column, container, mouse_area, row, text, text_input}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, container, row, text, text_input}; +use iced::{Element, Fill, Length}; use crate::app::Message; +use crate::ui::statusbar::status_menu::Entry; /// Angle increments offered in the picker, in degrees. Matches the common /// drafting set and adds the fine 1Β° step requested in #264. @@ -21,20 +19,15 @@ pub fn angle_label(deg: f32) -> String { } } -/// Full-screen overlay: transparent click-catcher + angle list pinned -/// bottom-right, above the status bar. `custom` is the live text of the -/// free-entry field. -pub fn polar_popup_overlay<'a>( +pub fn menu_entries<'a>( current: f32, custom: &'a str, - pill: Option, - win: (f32, f32), -) -> Element<'a, Message> { - let mut rows: Vec> = PRESETS +) -> Vec> { + let mut entries: Vec> = PRESETS .iter() .map(|°| { let active = (current - deg).abs() < 1e-3; - angle_row(deg, active) + Entry::close(angle_row(deg, active)) }) .collect(); @@ -48,91 +41,27 @@ pub fn polar_popup_overlay<'a>( let custom_row = container( row![ custom_field, - text("Β°").size(11).color(LABEL_OFF), + text("Β°").size(11), ] .spacing(4) .align_y(iced::Center), ) .padding([5, 10]); - rows.push(custom_row.into()); - - let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(120.0)); - - let positioned = super::position_statusbar_popup(panel.into(), pill, win, 120.0, true); - - mouse_area(positioned) - .on_press(Message::ClosePolarPopup) - .into() + entries.push(Entry::stay(custom_row)); + entries } fn angle_row<'a>(deg: f32, active: bool) -> Element<'a, Message> { - let check = crate::ui::icons::check_cell(active, CHECK_COLOR); + let check = crate::ui::icons::themed_check_cell(active); - let lbl = text(angle_label(deg)) - .size(11) - .color(if active { LABEL_ON } else { LABEL_OFF }); + let lbl = text(angle_label(deg)).size(11); let content = row![check, lbl].spacing(6).align_y(iced::Center); button(content) .on_press(Message::SetPolarAngle(deg)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; diff --git a/src/ui/popup/scale_popup.rs b/src/ui/popup/scale_popup.rs index b0c6aeb41..33375ae4d 100644 --- a/src/ui/popup/scale_popup.rs +++ b/src/ui/popup/scale_popup.rs @@ -1,29 +1,24 @@ -//! Scale picker dropdown β€” annotation scale (model space) or viewport scale (paper space). -//! -//! Rendered as a floating overlay above the status bar, same pattern as snap_popup. +//! Annotation / viewport scale status menu. -use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, row, text}; +use iced::{Element, Fill}; use crate::app::Message; +use crate::ui::statusbar::status_menu::Entry; -/// Full-screen overlay: transparent click-catcher + scale list panel pinned bottom-right. -/// /// - `is_model`: true = model space (dispatches SetAnnotationScale), false = paper space (SetViewportScale). /// - `current_anno_scale`: current annotation_scale from Scene (used to highlight active row in model space). /// - `viewport_scale`: current effective vp scale, view_height-first (used to highlight in paper space). /// - `file_scales`: scale list read from the drawing (`ACAD_SCALELIST`). Only /// scales actually stored in the file are shown; the picker never injects /// scales of its own. -pub fn scale_popup_overlay( +pub fn menu_entries( is_model: bool, current_anno_scale: f32, viewport_scale: Option, file_scales: Vec<(String, f32, f64)>, - pill: Option, - win: (f32, f32), -) -> Element<'static, Message> { - let mut rows: Vec> = file_scales +) -> Vec> { + let mut entries: Vec> = file_scales .into_iter() .map(|(label, anno_scale, vp_scale)| { let active = if is_model { @@ -38,108 +33,36 @@ pub fn scale_popup_overlay( } else { Message::SetViewportScale(vp_scale) }; - scale_row(label, active, msg) + Entry::close(scale_row(label, active, msg)) }) .collect(); - // Model space gets a "Manage scales..." row that opens the scale manager. if is_model { - rows.push(manage_row()); + entries.push(Entry::close(manage_row())); } - - let width = if is_model { 150.0 } else { 120.0 }; - let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(width)); - - let positioned = super::position_statusbar_popup(panel.into(), pill, win, width, true); - - mouse_area(positioned) - .on_press(Message::CloseScalePopup) - .into() + entries } fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Message> { - let check = crate::ui::icons::check_cell(active, CHECK_COLOR); + let check = crate::ui::icons::themed_check_cell(active); - let lbl = text(label) - .size(11) - .color(if active { LABEL_ON } else { LABEL_OFF }); + let lbl = text(label).size(11); let content = row![check, lbl].spacing(6).align_y(iced::Center); button(content) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]) .into() } fn manage_row() -> Element<'static, Message> { - button(text("Manage...").size(11).color(CHECK_COLOR)) + button(text("Manage...").size(11)) .on_press(Message::ScaleManagerOpen) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::primary) .width(Fill) .padding([5, 10]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; diff --git a/src/ui/popup/selection_filter_popup.rs b/src/ui/popup/selection_filter_popup.rs index b86b616c5..085e66776 100644 --- a/src/ui/popup/selection_filter_popup.rs +++ b/src/ui/popup/selection_filter_popup.rs @@ -1,25 +1,21 @@ -//! Selection-filter type picker β€” choose which entity types are selectable. +//! Selection-filter status menu β€” choose which entity types are selectable. //! A checked row means that type can be picked; unchecking it excludes the //! type from interactive selection. Opened from the FILTER status pill. use rustc_hash::FxHashSet as HashSet; -use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, container, row, text}; +use iced::{Background, Element, Fill, Theme}; use crate::app::Message; +use crate::ui::statusbar::status_menu::Entry; -/// Full-screen overlay: transparent click-catcher + type list pinned -/// bottom-right, above the status bar. -/// /// - `types`: entity-type names present in the current layout. /// - `excluded`: types currently filtered out (unchecked). -pub fn selection_filter_popup_overlay( +pub fn menu_entries( types: Vec, excluded: &HashSet, - pill: Option, - win: (f32, f32), -) -> Element<'static, Message> { +) -> Vec> { // "Select All / Clear All" header, mirroring the OSNAP popup: Select All // clears every exclusion, Clear All excludes every present type. let has_types = !types.is_empty(); @@ -41,144 +37,68 @@ pub fn selection_filter_popup_overlay( .padding([4u16, 8]); let divider = container(iced::widget::Space::new().height(1)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(DIVIDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color, + )), ..Default::default() }) .width(Fill) .padding([0, 4]); - let rows: Vec> = if types.is_empty() { - vec![empty_row()] + let rows: Vec> = if types.is_empty() { + vec![Entry::stay(empty_row())] } else { types .into_iter() .map(|name| { let included = !excluded.contains(&name); - type_row(name, included) + Entry::stay(type_row(name, included)) }) .collect() }; - let panel = container(column![header, divider, column(rows)]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(180.0)); - - let positioned = super::position_statusbar_popup(panel.into(), pill, win, 180.0, true); - - mouse_area(positioned) - .on_press(Message::CloseSelectionFilterPopup) - .into() + let mut entries = vec![Entry::stay(header), Entry::stay(divider)]; + entries.extend(rows); + entries } fn type_row(name: String, included: bool) -> Element<'static, Message> { - let check = crate::ui::icons::check_cell(included, CHECK_COLOR); + let check = crate::ui::icons::themed_check_cell(included); - let lbl = text(name.clone()) - .size(11) - .color(if included { LABEL_ON } else { LABEL_OFF }); + let lbl = text(name.clone()).size(11); let content = row![check, lbl].spacing(6).align_y(iced::Center); button(content) .on_press(Message::ToggleSelectionFilterType(name)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]) .into() } fn empty_row() -> Element<'static, Message> { - container(text("No objects").size(11).color(LABEL_OFF)) + container( + text("No objects").size(11).style(|theme: &Theme| text::Style { + color: Some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.42), + ), + }), + ) .padding([4, 10]) .into() } fn header_btn(label: &str, msg: Message, enabled: bool) -> Element<'_, Message> { - let color = if enabled { - Color { r: 0.70, g: 0.70, b: 0.70, a: 1.0 } - } else { - Color { r: 0.38, g: 0.38, b: 0.38, a: 1.0 } - }; - let b = button(text(label).size(10).color(color)); + let b = button(text(label).size(10)); let b = if enabled { b.on_press(msg) } else { b }; - b.style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => BTN_BG, - })), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) + b.style(button::secondary) .padding([3, 8]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const DIVIDER: Color = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, -}; -const BTN_BG: Color = Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.6, - g: 0.6, - b: 0.6, - a: 1.0, -}; diff --git a/src/ui/popup/snap_popup.rs b/src/ui/popup/snap_popup.rs index 37d7ff3b6..6259a9f4c 100644 --- a/src/ui/popup/snap_popup.rs +++ b/src/ui/popup/snap_popup.rs @@ -1,27 +1,16 @@ -//! OpenCADStudio-style OSNAP dropdown popup panel. -//! -//! Rendered as a floating overlay above the status bar. The popup is only -//! inserted into the view stack when `snap_popup_open` is true. +//! OpenCADStudio-style OSNAP status menu. -use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, container, row, text}; +use iced::{Background, Element, Fill, Length, Theme}; use crate::app::Message; use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES}; +use crate::ui::statusbar::status_menu::Entry; -/// Returns a full-screen overlay element: -/// - a transparent click-catcher that closes the popup on outside click -/// - the popup panel pinned to the bottom-right (above the status bar) -pub fn snap_popup_overlay<'a>( - snapper: &'a Snapper, - pill: Option, - win: (f32, f32), -) -> Element<'a, Message> { - // ── Panel content ───────────────────────────────────────────────────── +pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec> { let all_on = snapper.all_on(); let none_on = snapper.none_on(); - // "Select All / Clear All" header row let header = row![ header_btn("Select All", Message::SnapSelectAll, !all_on), header_btn("Clear All", Message::SnapClearAll, !none_on), @@ -31,61 +20,41 @@ pub fn snap_popup_overlay<'a>( // Divider let divider = container(iced::widget::Space::new().height(1)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(DIVIDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color, + )), ..Default::default() }) .width(Fill) .padding([0, 4]); - // Snap mode rows - let mut rows: Vec> = Vec::new(); + let mut entries = vec![Entry::stay(header), Entry::stay(divider)]; for &(snap_type, _glyph, label) in ALL_SNAP_MODES { - rows.push(snap_row(snap_type, label, snapper.is_on(snap_type))); + entries.push(Entry::stay(snap_row( + snap_type, + label, + snapper.is_on(snap_type), + ))); } - - let panel_content = column![header, divider, column(rows)]; - - const PANEL_W: f32 = 210.0; - let panel = container(panel_content) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(PANEL_W)); - - // Anchored just above its pill, flipping horizontally to stay on screen. - let positioned = super::position_statusbar_popup(panel.into(), pill, win, PANEL_W, true); - - // ── Click-catcher: closes popup on any outside click ────────────────── - mouse_area(positioned) - .on_press(Message::CloseSnapPopup) - .into() + entries } // ── Individual snap row ─────────────────────────────────────────────────── fn snap_row<'a>(snap_type: SnapType, label: &'a str, active: bool) -> Element<'a, Message> { - let checkmark = crate::ui::icons::check_cell(active, CHECK_COLOR); + let checkmark = crate::ui::icons::themed_check_cell(active); // SVG marker (not a Unicode glyph) so the symbols render on the web build, // whose bundled font lacks them and showed tofu boxes. (#138) - let icon_el = container(crate::ui::icons::tinted::( + let icon_el = container(crate::ui::icons::themed_success::( crate::ui::icons::osnap(snap_type), 13.0, - ICON_COLOR, )) .width(Length::Fixed(16.0)) .align_x(iced::Center); - let label_el = text(label) - .size(11) - .color(if active { LABEL_ON } else { LABEL_OFF }); + let label_el = text(label).size(11); let content = row![checkmark, icon_el, label_el] .spacing(4) @@ -93,105 +62,16 @@ fn snap_row<'a>(snap_type: SnapType, label: &'a str, active: bool) -> Element<'a button(content) .on_press(Message::ToggleSnap(snap_type)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([3, 8]) .into() } fn header_btn(label: &str, msg: Message, enabled: bool) -> Element<'_, Message> { - let b = button(text(label).size(10).color(if enabled { - Color { - r: 0.70, - g: 0.70, - b: 0.70, - a: 1.0, - } - } else { - Color { - r: 0.38, - g: 0.38, - b: 0.38, - a: 1.0, - } - })); + let b = button(text(label).size(10)); let b = if enabled { b.on_press(msg) } else { b }; - b.style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => BTN_BG, - })), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) + b.style(button::secondary) .padding([3, 8]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.16, - g: 0.16, - b: 0.16, - a: 0.98, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const DIVIDER: Color = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, -}; -const BTN_BG: Color = Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.20, - g: 0.75, - b: 0.35, - a: 1.0, -}; // green βœ“ -const ICON_COLOR: Color = Color { - r: 0.25, - g: 0.75, - b: 0.45, - a: 1.0, -}; // green icon -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.52, - g: 0.52, - b: 0.52, - a: 1.0, -}; - diff --git a/src/ui/popup/units_popup.rs b/src/ui/popup/units_popup.rs index 17421569b..d8301824c 100644 --- a/src/ui/popup/units_popup.rs +++ b/src/ui/popup/units_popup.rs @@ -1,10 +1,10 @@ -//! Drawing-units picker β€” sets the INSUNITS header value. Rendered as a -//! floating overlay above the status bar, same pattern as the scale picker. +//! Drawing-units status menu. -use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, row, text}; +use iced::{Element, Fill}; use crate::app::Message; +use crate::ui::statusbar::status_menu::Entry; /// Units offered in the picker: (INSUNITS code, menu label). const UNITS: &[(i16, &str)] = &[ @@ -35,95 +35,30 @@ pub fn unit_short(code: i16) -> &'static str { } } -/// Full-screen overlay: transparent click-catcher + units list pinned -/// bottom-right, above the status bar. -pub fn units_popup_overlay( - current: i16, - pill: Option, - win: (f32, f32), -) -> Element<'static, Message> { - let rows: Vec> = UNITS +pub fn menu_entries(current: i16) -> Vec> { + UNITS .iter() - .map(|&(code, label)| unit_row(label, code == current, Message::SetDrawingUnits(code))) - .collect(); - - let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() + .map(|&(code, label)| { + Entry::close(unit_row( + label, + code == current, + Message::SetDrawingUnits(code), + )) }) - .width(Length::Fixed(140.0)); - - let positioned = super::position_statusbar_popup(panel.into(), pill, win, 140.0, true); - - mouse_area(positioned) - .on_press(Message::CloseUnitsPopup) - .into() + .collect() } fn unit_row(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> { - let check = crate::ui::icons::check_cell(active, CHECK_COLOR); + let check = crate::ui::icons::themed_check_cell(active); - let lbl = text(label) - .size(11) - .color(if active { LABEL_ON } else { LABEL_OFF }); + let lbl = text(label).size(11); let content = row![check, lbl].spacing(6).align_y(iced::Center); button(content) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index d5d49303f..dadfdfdaa 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -208,9 +208,11 @@ impl PropertiesPanel { pub fn view(&self) -> Element<'_, Message> { // ── Header ────────────────────────────────────────────────────────── - let header = container(text("Properties").size(12).color(Color::WHITE)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(HEADER_BG)), + let header = container(text("Properties").size(12)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color, + )), ..Default::default() }) .width(Length::Fill) @@ -218,7 +220,10 @@ impl PropertiesPanel { // ── Title bar (entity type / "No Selection") ───────────────────── let title_content: Element<'_, Message> = if self.selection_groups.is_empty() { - text(crate::ui::text_util::elide(&self.title, 34)).size(FONT_SZ).color(SECTION_LABEL).into() + text(crate::ui::text_util::elide(&self.title, 34)) + .size(FONT_SZ) + .style(muted_text_style) + .into() } else { combo_box( &self.selection_group_combo, @@ -235,14 +240,17 @@ impl PropertiesPanel { }; let title_bar = container(title_content) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(SECTION_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weakest.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .width(Length::Fill) .padding([4, 10]); @@ -252,7 +260,7 @@ impl PropertiesPanel { container( text("Select an object to view properties") .size(10) - .color(HINT_COLOR), + .style(hint_text_style), ) .padding([10, 10]) .into() @@ -265,14 +273,17 @@ impl PropertiesPanel { }; container(column![header, title_bar, content]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .width(250) .height(Length::Fill) @@ -286,15 +297,22 @@ impl PropertiesPanel { if self.sections.is_empty() { return None; } - let title = container(text(crate::ui::text_util::elide(&self.title, 34)).size(FONT_SZ).color(SECTION_LABEL)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(SECTION_BG)), + let title = container( + text(crate::ui::text_util::elide(&self.title, 34)) + .size(FONT_SZ) + .style(muted_text_style), + ) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weakest.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .width(Length::Fill) .padding([4, 10]); @@ -306,14 +324,17 @@ impl PropertiesPanel { Some( container(col) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }) .width(230) .into(), @@ -324,15 +345,18 @@ impl PropertiesPanel { fn render_section<'a>(&'a self, section: &'a PropSection) -> Element<'a, Message> { // Section header - let hdr = container(text(§ion.title).size(10).color(Color::WHITE)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(SECTION_HDR_BG)), + let hdr = container(text(§ion.title).size(10)) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .width(Length::Fill) .padding([3, 8]); @@ -508,31 +532,25 @@ impl PropertiesPanel { fn render_color_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> { let color_btn = button( row![ - container(text("?").size(10).color(VALUE_COLOR)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, - })), + container(text("?").size(10)) + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.strong.color)), border: Border { - color: Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5 - }, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: Some(palette.background.strong.text), ..Default::default() + } }) .width(SWATCH_SZ) .height(SWATCH_SZ) .align_x(iced::Center) .align_y(iced::Center), - text(VARIES_LABEL).size(FONT_SZ).color(VALUE_COLOR), + text(VARIES_LABEL).size(FONT_SZ), ] .spacing(4) .align_y(iced::Center), @@ -724,7 +742,7 @@ impl PropertiesPanel { .on_input(move |v| Message::PropGeomInput { field, value: v }) .on_submit(Message::PropGeomCommit(field)) .size(FONT_SZ) - .style(|_: &Theme, status| text_input::Style { + .style(|theme: &Theme, status| text_input::Style { // The wrapping container draws the border; keep the input flat // so field + caret read as one control. border: Border { @@ -732,33 +750,32 @@ impl PropertiesPanel { width: 0.0, radius: 0.0.into(), }, - ..text_input_style(&Theme::Dark, status) + ..text_input_style(theme, status) }) .padding([3, 6]) .width(Length::Fill); let caret = button( - container(crate::ui::icons::arrow_toggle( - self.edit_choice_open, - FONT_SZ, - VALUE_COLOR, - )) + container(if self.edit_choice_open { + crate::ui::icons::themed_arrow_up(FONT_SZ) + } else { + crate::ui::icons::themed_arrow_down(FONT_SZ) + }) .height(Length::Fill) .align_y(iced::Center), ) .on_press(Message::PropEditChoiceToggle) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - _ => VALUE_BG, - })), - text_color: VALUE_COLOR, + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border::default(), ..Default::default() + } }) .padding(Padding { top: 0.0, @@ -768,14 +785,17 @@ impl PropertiesPanel { }) .height(Length::Fixed(ROW_H - 6.0)); let head = container(row![input, caret].align_y(iced::Center)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(VALUE_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, ..Default::default() + } }) .width(Length::Fill); @@ -794,39 +814,15 @@ impl PropertiesPanel { } let value = opt.clone(); list = list.push( - button(text(opt.as_str()).size(FONT_SZ).color(VALUE_COLOR)) + button(text(opt.as_str()).size(FONT_SZ)) .on_press(Message::PropGeomChoiceChanged { field, value }) - .style(|_: &Theme, status| button::Style { - background: matches!(status, button::Status::Hovered).then_some( - Background::Color(Color { - r: 0.25, - g: 0.45, - b: 0.70, - a: 1.0, - }), - ), - text_color: VALUE_COLOR, - ..Default::default() - }) + .style(button::subtle) .padding([2, 6]) .width(Length::Fill), ); } let popup = container(scrollable(list).height(Length::Shrink)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, - })), - border: Border { - color: BORDER, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) + .style(container::bordered_box) .padding(2) .width(200) .max_height(220.0); @@ -940,13 +936,13 @@ pub fn color_picker_dropdown<'a>( r.push( button(text("").width(18).height(18)) .on_press(msg) - .style(move |_: &Theme, status| button::Style { + .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(bg)), border: Border { color: if matches!(status, button::Status::Hovered) { - Color::WHITE + theme.extended_palette().primary.base.color } else { - Color::BLACK + theme.extended_palette().background.neutral.color }, width: if matches!(status, button::Status::Hovered) { 1.5 @@ -955,6 +951,7 @@ pub fn color_picker_dropdown<'a>( }, radius: 2.0.into(), }, + text_color: theme.extended_palette().background.base.text, ..Default::default() }) .padding(0), @@ -965,20 +962,20 @@ pub fn color_picker_dropdown<'a>( // "More Colors…" toggle button let more_btn = button( row![ - crate::ui::icons::arrow_toggle(palette_open, 9.0, HINT_COLOR), + if palette_open { + crate::ui::icons::themed_arrow_up(9.0) + } else { + crate::ui::icons::themed_arrow_down(9.0) + }, text(if palette_open { "Less" } else { "More Colors…" }) .size(10) - .color(HINT_COLOR), + .style(hint_text_style), ] .spacing(4) .align_y(iced::Center), ) .on_press(palette_toggle_msg) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(PICKER_BG)), - text_color: HINT_COLOR, - ..Default::default() - }) + .style(button::subtle) .padding([2, 6]) .width(Length::Fill); @@ -989,14 +986,17 @@ pub fn color_picker_dropdown<'a>( }; let mut col = column![container(inner) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PICKER_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into() }, ..Default::default() + } }) .padding([6, 8]) .width(Length::Fill)] @@ -1019,18 +1019,13 @@ pub fn color_picker_dropdown<'a>( r = r.push( button(text("").width(12).height(12)) .on_press(msg) - .style(move |_: &Theme, status| button::Style { + .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(bg)), border: Border { color: if matches!(status, button::Status::Hovered) { - Color::WHITE + theme.extended_palette().primary.base.color } else { - Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.4, - } + theme.extended_palette().background.neutral.color }, width: if matches!(status, button::Status::Hovered) { 1.5 @@ -1039,6 +1034,7 @@ pub fn color_picker_dropdown<'a>( }, radius: 1.0.into(), }, + text_color: theme.extended_palette().background.base.text, ..Default::default() }) .padding(0), @@ -1049,14 +1045,17 @@ pub fn color_picker_dropdown<'a>( } col = col.push( container(scrollable(rows).height(160)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PICKER_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .padding([4, 6]) .width(Length::Fill), @@ -1071,22 +1070,23 @@ pub fn color_picker_dropdown<'a>( /// A boolean toggle button row (for "Invisible" etc.). fn render_stepper_row<'a>(label: &'a str, display: &'a str) -> Element<'a, Message> { let arrow = |glyph: &'static str, delta: i8| { - button(text(glyph).size(FONT_SZ).color(VALUE_COLOR)) + button(text(glyph).size(FONT_SZ)) .on_press(Message::PropVertexStep(delta)) .padding([0, 6]) - .style(|_: &Theme, status| { - let bg = match status { - button::Status::Hovered | button::Status::Pressed => HOVER_BG, - _ => VALUE_BG, + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, }; button::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(pair.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, - text_color: VALUE_COLOR, + text_color: pair.text, ..Default::default() } }) @@ -1095,7 +1095,6 @@ fn render_stepper_row<'a>(label: &'a str, display: &'a str) -> Element<'a, Messa arrow("β—€", -1), text(display) .size(FONT_SZ) - .color(VALUE_COLOR) .width(Length::Fill) .align_x(iced::Center), arrow("β–Ά", 1), @@ -1111,22 +1110,29 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem button( text(btn_label) .size(FONT_SZ) - .color(if value { WARN_COLOR } else { VALUE_COLOR }), + .style(move |theme: &Theme| iced::widget::text::Style { + color: value.then_some(theme.extended_palette().warning.base.color), + }), ) .on_press(Message::PropBoolToggle(field)) - .style(move |_: &Theme, status| { - let bg = match status { - button::Status::Hovered | button::Status::Pressed => HOVER_BG, - _ => VALUE_BG, + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, }; button::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(pair.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, - text_color: if value { WARN_COLOR } else { VALUE_COLOR }, + text_color: if value { + palette.warning.base.color + } else { + pair.text + }, ..Default::default() } }) @@ -1211,10 +1217,14 @@ fn render_group_row( let label_btn = button( container( row![ - crate::ui::icons::arrow_toggle(expanded, FONT_SZ, LABEL_COLOR), + if expanded { + crate::ui::icons::themed_arrow_down(FONT_SZ) + } else { + crate::ui::icons::themed_arrow_right(FONT_SZ) + }, text(crate::ui::text_util::elide(base, 16)) .size(FONT_SZ) - .color(LABEL_COLOR), + .style(muted_text_style), ] .spacing(4) .align_y(iced::Center), @@ -1223,20 +1233,7 @@ fn render_group_row( .align_y(iced::Center), ) .on_press(Message::PropGroupToggle(key)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, - }, - _ => LABEL_BG, - })), - text_color: LABEL_COLOR, - border: Border::default(), - ..Default::default() - }) + .style(button::subtle) .padding(Padding { top: 0.0, bottom: 0.0, @@ -1246,8 +1243,10 @@ fn render_group_row( .width(Length::Fill) .height(Length::Fixed(ROW_H)); let label_col = container(label_btn) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LABEL_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), ..Default::default() }) .width(Length::FillPortion(5)) @@ -1262,8 +1261,10 @@ fn render_group_row( .padding([3, 6]) .width(Length::Fill); let value_col = container(value_field) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(VALUE_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Length::FillPortion(6)) @@ -1278,9 +1279,9 @@ fn render_group_row( container(row![label_col, value_col]) .height(Length::Fixed(ROW_H)) - .style(|_: &Theme| container::Style { + .style(|theme: &Theme| container::Style { border: Border { - color: BORDER, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -1305,9 +1306,15 @@ fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { - let label_col = container(text(crate::ui::text_util::elide(label, 18)).size(FONT_SZ).color(LABEL_COLOR)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LABEL_BG)), + let label_col = container( + text(crate::ui::text_util::elide(label, 18)) + .size(FONT_SZ) + .style(muted_text_style), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), ..Default::default() }) .width(Length::FillPortion(5)) @@ -1320,8 +1327,10 @@ fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element< right: 6.0, }); let value_col = container(widget) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(VALUE_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Length::FillPortion(6)) @@ -1335,9 +1344,9 @@ fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element< }); container(row![label_col, value_col]) .height(Length::Fixed(ROW_H)) - .style(|_: &Theme| container::Style { + .style(|theme: &Theme| container::Style { border: Border { - color: BORDER, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -1348,18 +1357,9 @@ fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element< /// A plain text button used inside the color picker for ByLayer / ByBlock. fn picker_text_btn(label: &str, msg: Message) -> Element<'_, Message> { - button(text(label).size(FONT_SZ).color(VALUE_COLOR)) + button(text(label).size(FONT_SZ)) .on_press(msg) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(LABEL_BG)), - border: Border { - color: BORDER, - width: 1.0, - radius: 2.0.into(), - }, - text_color: VALUE_COLOR, - ..Default::default() - }) + .style(button::secondary) .padding([2, 8]) .into() } @@ -1419,49 +1419,41 @@ fn aci_label(idx: u8) -> &'static str { // ── Widget style helpers ────────────────────────────────────────────────── -fn combo_btn_style(_theme: &Theme, status: button::Status) -> button::Style { - let bg = match status { - button::Status::Hovered | button::Status::Pressed => HOVER_BG, - _ => VALUE_BG, +fn combo_btn_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, }; button::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(pair.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, - text_color: VALUE_COLOR, + text_color: pair.text, ..Default::default() } } -fn text_input_style(_theme: &Theme, status: text_input::Status) -> text_input::Style { +fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); let border_color = match status { - text_input::Status::Focused { .. } => Color { - r: 0.3, - g: 0.6, - b: 1.0, - a: 1.0, - }, - _ => BORDER, + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, }; text_input::Style { - background: Background::Color(VALUE_BG), + background: Background::Color(palette.background.base.color), border: Border { color: border_color, width: 1.0, radius: 2.0.into(), }, icon: Color::TRANSPARENT, - placeholder: HINT_COLOR, - value: VALUE_COLOR, - selection: Color { - r: 0.2, - g: 0.4, - b: 0.8, - a: 0.5, - }, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), } } @@ -1472,109 +1464,30 @@ fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::S /// Style for a read-only-but-selectable value field: flat (no input box or /// focus highlight, so it reads as plain text, unlike the bordered editable /// fields) yet with a visible selection colour so Ctrl+C copy is discoverable. -fn ro_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { +fn ro_input_style(theme: &Theme, _status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); text_input::Style { - background: Background::Color(VALUE_BG), + background: Background::Color(palette.background.base.color), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, icon: Color::TRANSPARENT, - placeholder: HINT_COLOR, - value: VALUE_COLOR, - selection: Color { - r: 0.2, - g: 0.4, - b: 0.8, - a: 0.5, - }, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), } } -// ── Colour constants ────────────────────────────────────────────────────── +fn muted_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.72)), + } +} -const PANEL_BG: Color = Color { - r: 0.19, - g: 0.19, - b: 0.19, - a: 1.0, -}; -const HEADER_BG: Color = Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, -}; -const SECTION_BG: Color = Color { - r: 0.21, - g: 0.21, - b: 0.21, - a: 1.0, -}; -const SECTION_HDR_BG: Color = Color { - r: 0.26, - g: 0.26, - b: 0.28, - a: 1.0, -}; -const LABEL_BG: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const VALUE_BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -const HOVER_BG: Color = Color { - r: 0.25, - g: 0.25, - b: 0.28, - a: 1.0, -}; -const PICKER_BG: Color = Color { - r: 0.16, - g: 0.16, - b: 0.18, - a: 1.0, -}; -const LABEL_COLOR: Color = Color { - r: 0.70, - g: 0.70, - b: 0.70, - a: 1.0, -}; -const VALUE_COLOR: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, -}; -const HINT_COLOR: Color = Color { - r: 0.45, - g: 0.45, - b: 0.50, - a: 1.0, -}; -const SECTION_LABEL: Color = Color { - r: 0.75, - g: 0.75, - b: 0.75, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const WARN_COLOR: Color = Color { - r: 1.00, - g: 0.60, - b: 0.10, - a: 1.0, -}; +fn hint_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.48)), + } +} diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs index 6f753c342..c7c193e24 100644 --- a/src/ui/ribbon/collapse.rs +++ b/src/ui/ribbon/collapse.rs @@ -15,7 +15,7 @@ use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, Widget}; use iced::advanced::{mouse, overlay, renderer, Clipboard, Renderer as _, Shell}; use iced::{ - Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, + Background, Border, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector, }; @@ -113,8 +113,6 @@ pub struct CollapsePanels<'a> { open: Option, /// Fallback row height, used only when there are no panels to measure. row_h: f32, - /// Colour of the 1px divider drawn between panels. - divider: Color, /// Chosen degradation level per panel; set during layout. levels: RefCell>, /// If set, the measured row height is written here each layout (read when @@ -129,13 +127,12 @@ pub struct CollapsePanels<'a> { } impl<'a> CollapsePanels<'a> { - pub fn new(panels: Vec>, open: Option, row_h: f32, divider: Color) -> Self { + pub fn new(panels: Vec>, open: Option, row_h: f32) -> Self { let n = panels.len(); Self { panels, open, row_h, - divider, levels: RefCell::new(vec![FULL; n]), height_out: None, tight_out: None, @@ -480,7 +477,7 @@ impl<'a> Widget for CollapsePanels<'a> { shadow: Shadow::default(), snap: true, }, - Background::Color(self.divider), + Background::Color(theme.extended_palette().background.neutral.color), ); } } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 22a44c3eb..85190521e 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use rustc_hash::FxHashMap as HashMap; use acadrust::types::{Color as AcadColor, LineWeight}; -use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, text}; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text}; use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; @@ -419,58 +419,41 @@ impl Ribbon { let is_active = i == self.active; let is_contextual = module.id() == "layout"; - let accent = if is_contextual { - ACCENT_GOLD - } else { - ACCENT_BLUE - }; - let text_inactive = if is_contextual { - Color { - r: 0.90, - g: 0.72, - b: 0.30, - a: 1.0, - } - } else { - Color { - r: 0.75, - g: 0.75, - b: 0.75, - a: 1.0, - } - }; - let hover_bg = if is_contextual { - Color { - r: 0.28, - g: 0.24, - b: 0.12, - a: 1.0, - } - } else { - Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, - } - }; let btn = container( button(text(module.title()).size(12)) .on_press(Message::RibbonSelectTab(i)) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => RIBBON_BG, - (false, button::Status::Hovered) => hover_bg, - _ => Color::TRANSPARENT, - })), - text_color: if is_active { - Color::WHITE + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let accent = if is_contextual { + palette.warning.base } else { - text_inactive + palette.primary.base + }; + let pair = match (is_active, status) { + (true, _) => palette.background.weakest, + (false, button::Status::Hovered) => { + if is_contextual { + palette.warning.weak + } else { + palette.background.weak + } + } + _ => palette.background.base, + }; + button::Style { + background: (is_active + || matches!(status, button::Status::Hovered)) + .then_some(Background::Color(pair.color)), + text_color: if is_active { + pair.text + } else if is_contextual { + accent.color + } else { + palette.background.base.text.scale_alpha(0.72) }, border: Border { color: if is_active { - accent + accent.color } else { Color::TRANSPARENT }, @@ -479,13 +462,18 @@ impl Ribbon { }, shadow: iced::Shadow::default(), snap: false, + } }) .padding([5, 14]), ) - .style(move |_: &Theme| container::Style { + .style(move |theme: &Theme| container::Style { border: Border { color: if is_active { - accent + if is_contextual { + theme.extended_palette().warning.base.color + } else { + theme.extended_palette().primary.base.color + } } else { Color::TRANSPARENT }, @@ -514,9 +502,11 @@ impl Ribbon { // persisted (see `Ribbon::set_collapse_mode`). It hides itself once the // tool row is tight, giving the cramped tab row its space back. let dd_open = self.open_dropdown.as_deref() == Some(COLLAPSE_MODE_ID); - let mode_btn = button(crate::ui::icons::arrow_down(10.0, ARROW_COLOR)) + let mode_btn = button(crate::ui::icons::themed_arrow_down(10.0)) .on_press(Message::ToggleRibbonDropdown(COLLAPSE_MODE_ID.to_string())) - .style(move |_: &Theme, status| top_hist_btn_style(true, dd_open, status)) + .style(move |theme: &Theme, status| { + top_hist_btn_style(theme, true, dd_open, status) + }) .height(24) .padding([2, 8]); let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn); @@ -534,8 +524,10 @@ impl Ribbon { } let tab_bar = container(tab_row) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TOPBAR_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .padding(Padding { @@ -653,10 +645,12 @@ impl Ribbon { self.active_lineweight, &style_ctx, )) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(RIBBON_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), border: Border { - color: BORDER_DARK, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -666,7 +660,7 @@ impl Ribbon { } }) .collect(); - CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H, BORDER_DARK) + CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) .report_height(self.tool_bar_h.clone()) .report_tight(self.collapse_tight.clone()) .mode(self.collapse_mode) @@ -676,10 +670,12 @@ impl Ribbon { }; let tool_bar = container(tool_area) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(RIBBON_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), border: Border { - color: BORDER_DARK, + color: theme.extended_palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -712,19 +708,13 @@ impl Ribbon { .enumerate() .map(|(idx, label)| { let step = idx + 1; - button(text(label.clone()).size(11).color(LABEL_ON)) + button(text(label.clone()).size(11)) .on_press(if is_undo { Message::UndoMany(step) } else { Message::RedoMany(step) }) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([5, 10]) .into() @@ -732,15 +722,7 @@ impl Ribbon { .collect(); let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(170.0)); let (align_right, h_pad, top) = self.dd_anchor(open_id, 170.0, win.0); @@ -755,27 +737,16 @@ impl Ribbon { let rows: Vec> = CollapseMode::ALL .iter() .map(|&m| { - let mark: Element = if m == current { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }; button( row![ - container(mark).width(Length::Fixed(16.0)), - text(m.label()).size(11).color(LABEL_ON), + crate::ui::icons::themed_check_cell(m == current), + text(m.label()).size(11), ] .spacing(4) .align_y(iced::Center), ) .on_press(Message::SetRibbonCollapseMode(m)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([5, 10]) .into() @@ -783,15 +754,7 @@ impl Ribbon { .collect(); let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(W)); let (align_right, h_pad, top) = self.dd_anchor(open_id, W, win.0); @@ -852,28 +815,25 @@ impl Ribbon { .iter() .map(|(cmd, label, item_icon)| { let is_current = *cmd == last_cmd; - let checkmark: Element<'_, Message> = container(if is_current { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }) - .width(Length::Fixed(14.0)) - .into(); - let icon_el: Element = match *item_icon { - IconKind::Glyph(s) => text(s) - .size(13) - .color(ICON_COLOR) + let checkmark: Element<'_, Message> = + crate::ui::icons::themed_check_cell(is_current); + let icon_el: Element = + container(make_icon(*item_icon, 20.0)) .width(Length::Fixed(20.0)) - .into(), - IconKind::Svg(bytes) => { - let handle = svg::Handle::from_memory(bytes); - svg(handle).width(20).height(20).into() - } - }; + .into(); let label_el = text(*label) .size(11) - .color(if is_current { LABEL_ON } else { LABEL_OFF }); + .style(move |theme: &Theme| iced::widget::text::Style { + color: (!is_current).then_some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }); button( row![checkmark, icon_el, label_el] @@ -884,13 +844,7 @@ impl Ribbon { dropdown_id: dd_id, cmd: *cmd, }) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([4, 10]) .into() @@ -898,15 +852,7 @@ impl Ribbon { .collect(); let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(190.0)); let (align_right, h_pad, top) = self.dd_anchor(open_id, 190.0, win.0); @@ -923,15 +869,9 @@ impl Ribbon { // on it flips that state instead of bubbling up to the row's // make-active handler (#133). let icon_btn = |bytes: &'static [u8], msg: Message| -> Element<'_, Message> { - button(crate::ui::icons::raw(bytes, 14.0)) + button(crate::ui::icons::semantic(bytes, 14.0)) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .padding([2, 4]) .into() }; @@ -952,10 +892,10 @@ impl Ribbon { let name = info.name.clone(); let swatch = container(text("")) - .style(move |_: &Theme| container::Style { + .style(move |theme: &Theme| container::Style { background: Some(Background::Color(lc)), border: Border { - color: SWATCH_BORDER, + color: theme.extended_palette().background.strong.color, width: 1.0, radius: 1.0.into(), }, @@ -976,29 +916,27 @@ impl Ribbon { crate::ui::icons::layer_lock(ll), Message::LayerToggleLock(index), ); - let checkmark: Element<'_, Message> = container(if is_active { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }) - .width(Length::Fixed(14.0)) - .into(); + let checkmark: Element<'_, Message> = + crate::ui::icons::themed_check_cell(is_active); let label = text(&info.name) .size(11) - .color(if is_active { LABEL_ON } else { LABEL_OFF }); + .style(move |theme: &Theme| iced::widget::text::Style { + color: (!is_active).then_some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }); // The swatch + label area selects the layer as active; the // icon buttons above handle their own toggles. let select = button(row![swatch, label].spacing(5).align_y(iced::Center)) .on_press(Message::RibbonLayerChanged(name)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([4, 4]); @@ -1021,34 +959,7 @@ impl Ribbon { let search = iced::widget::text_input("Search layers…", &self.layer_filter) .on_input(Message::RibbonLayerFilterChanged) .size(11) - .padding([4, 6]) - .style(|_: &Theme, _| iced::widget::text_input::Style { - background: Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, - }), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 2.0.into(), - }, - icon: Color::WHITE, - placeholder: Color { - r: 0.45, - g: 0.45, - b: 0.45, - a: 1.0, - }, - value: Color::WHITE, - selection: Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.5, - }, - }); + .padding([4, 6]); let panel = container( column![ container(search).padding([4, 4]), @@ -1056,15 +967,7 @@ impl Ribbon { ] .spacing(2), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(220.0)); let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 220.0, win.0); @@ -1113,35 +1016,29 @@ impl Ribbon { }; let active = ctx.active_for(style_key).to_string(); - let row_style = |_: &Theme, status: button::Status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }; - let mut rows: Vec> = ctx .names_for(style_key) .iter() .map(|name| { let is_sel = name.as_str() == active.as_str(); let n = name.clone(); - let checkmark: Element = container(if is_sel { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }) - .width(Length::Fixed(14.0)) - .into(); + let checkmark: Element = + crate::ui::icons::themed_check_cell(is_sel); button( row![ checkmark, - text(name.clone()).size(11).color(if is_sel { - LABEL_ON - } else { - LABEL_OFF - }), + text(name.clone()) + .size(11) + .style(move |theme: &Theme| iced::widget::text::Style { + color: (!is_sel).then_some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }), ] .spacing(4) .align_y(iced::Center), @@ -1150,7 +1047,7 @@ impl Ribbon { key: style_key, name: n, }) - .style(row_style) + .style(popup_row_style) .width(Fill) .padding([4, 10]) .into() @@ -1159,9 +1056,9 @@ impl Ribbon { if let Some(mgr) = manager_cmd { rows.push( - button(text("Manage…").size(11).color(LABEL_ON)) + button(text("Manage…").size(11)) .on_press(Message::Command(mgr.to_string())) - .style(row_style) + .style(popup_row_style) .width(Fill) .padding([4, 10]) .into(), @@ -1169,15 +1066,7 @@ impl Ribbon { } let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(LARGE_W * 2.3)); let (align_right, h_pad, top) = self.dd_anchor(open_id, LARGE_W * 2.3, win.0); @@ -1200,15 +1089,7 @@ impl Ribbon { ); let panel = container(picker) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(200.0)); let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0); @@ -1243,23 +1124,22 @@ impl Ribbon { .into_iter() .map(|lt| { let is_cur = lt.name == *active_lt; - let check: Element<'_, Message> = container(if is_cur { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }) - .width(Length::Fixed(14.0)) - .into(); + let check: Element<'_, Message> = + crate::ui::icons::themed_check_cell(is_cur); let name_col = text(lt.name.clone()) .size(11) - .color(if is_cur { LABEL_ON } else { LABEL_OFF }) + .style(move |theme: &Theme| iced::widget::text::Style { + color: (!is_cur).then_some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }) .width(Length::Fixed(90.0)); - let art_col = text(lt.art.clone()).size(9).color(Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, - }); + let art_col = text(lt.art.clone()).size(9).style(muted_text_style); let name = lt.name.clone(); button( row![check, name_col, art_col] @@ -1267,13 +1147,7 @@ impl Ribbon { .align_y(iced::Center), ) .on_press(Message::RibbonLinetypeChanged(name)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([4, 6]) .into() @@ -1281,15 +1155,7 @@ impl Ribbon { .collect(); let list = container(scrollable(column(rows)).height(Length::Fixed(200.0))) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(220.0)); let (align_right, h_pad, top) = self.dd_anchor(PROP_LINETYPE_ID, 220.0, win.0); @@ -1308,31 +1174,29 @@ impl Ribbon { .map(|item| { let is_cur = item.0 == active_lw; let label = item.to_string(); - let check: Element<'_, Message> = container(if is_cur { - crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) - } else { - iced::widget::Space::new().width(0).into() - }) - .width(Length::Fixed(14.0)) - .into(); + let check: Element<'_, Message> = + crate::ui::icons::themed_check_cell(is_cur); button( row![ check, text(label) .size(11) - .color(if is_cur { LABEL_ON } else { LABEL_OFF }) + .style(move |theme: &Theme| iced::widget::text::Style { + color: (!is_cur).then_some( + theme + .extended_palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }) ] .spacing(5) .align_y(iced::Center), ) .on_press(Message::RibbonLineweightChanged(item.0)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(popup_row_style) .width(Fill) .padding([4, 8]) .into() @@ -1350,15 +1214,7 @@ impl Ribbon { win: (f32, f32), ) -> Option> { let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + .style(popup_panel_style) .width(Length::Fixed(width)); let (align_right, h_pad, top) = self.dd_anchor(dd_id, width, win.0); @@ -1439,7 +1295,7 @@ fn render_group<'a>( column![ tools_el, - container(text(group.title).size(9).color(GROUP_LABEL)).padding([1, 4]), + container(text(group.title).size(9).style(muted_text_style)).padding([1, 4]), ] .align_x(iced::Center) .spacing(0) @@ -1526,8 +1382,8 @@ fn collapse_button<'a>( column![ icon, row![ - text(title.to_string()).size(9).color(GROUP_LABEL), - crate::ui::icons::arrow_down(8.0, GROUP_LABEL), + text(title.to_string()).size(9).style(muted_text_style), + crate::ui::icons::themed_secondary_arrow_down(8.0), ] .spacing(3) .align_y(iced::Center), @@ -1536,22 +1392,7 @@ fn collapse_button<'a>( .spacing(2), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - ..Default::default() - }) + .style(button::subtle) .padding([3, 5]) .into(); } @@ -1594,29 +1435,14 @@ fn collapse_button<'a>( let opener = button( row![ - text(title.to_string()).size(9).color(GROUP_LABEL), - crate::ui::icons::arrow_down(8.0, GROUP_LABEL), + text(title.to_string()).size(9).style(muted_text_style), + crate::ui::icons::themed_secondary_arrow_down(8.0), ] .spacing(3) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - ..Default::default() - }) + .style(button::subtle) .padding([1, 4]); // The large face fills a fixed slot so a collapsed panel is shorter than a full diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index f555e6795..d6b3b0dd7 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -8,7 +8,7 @@ use acadrust::types::{Color as AcadColor, LineWeight}; // Ribbon tooltips anchor to the right of their button so the cursor β€” which // rests on the button itself β€” never covers the tip text. (#143) use iced::widget::tooltip::Position as TipPos; -use iced::widget::{button, column, container, row, svg, text, tooltip}; +use iced::widget::{button, column, container, row, text, tooltip}; use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; @@ -77,175 +77,6 @@ pub(super) const PROP_COLOR_ID: &str = "PROP_COLOR"; pub(super) const PROP_LINETYPE_ID: &str = "PROP_LINETYPE"; pub(super) const PROP_LW_ID: &str = "PROP_LW"; -// ── Colours ──────────────────────────────────────────────────────────────── - -/// Light chrome grey for the quick-access file-command icons on the top strip. -pub(super) const QA_ICON_COLOR: Color = Color { - r: 0.82, - g: 0.83, - b: 0.85, - a: 1.0, -}; -pub(super) const TOPBAR_BG: Color = Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, -}; -pub(super) const RIBBON_BG: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -pub(super) const BORDER_DARK: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -pub(super) const ACCENT_BLUE: Color = Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, -}; -pub(super) const ACCENT_GOLD: Color = Color { - r: 0.90, - g: 0.65, - b: 0.10, - a: 1.0, -}; -pub(super) const LABEL_COLOR: Color = Color { - r: 0.82, - g: 0.82, - b: 0.82, - a: 1.0, -}; -pub(super) const GROUP_LABEL: Color = Color { - r: 0.50, - g: 0.50, - b: 0.50, - a: 1.0, -}; -pub(super) const TOOL_HOVER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -pub(super) const TOOL_ACTIVE: Color = Color { - r: 0.18, - g: 0.42, - b: 0.70, - a: 1.0, -}; -pub(super) const ARROW_COLOR: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; -pub(super) const PANEL_BG: Color = Color { - r: 0.16, - g: 0.16, - b: 0.16, - a: 0.98, -}; -pub(super) const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -pub(super) const ROW_HOVER: Color = Color { - r: 0.24, - g: 0.24, - b: 0.24, - a: 1.0, -}; -pub(super) const CHECK_COLOR: Color = Color { - r: 0.20, - g: 0.75, - b: 0.35, - a: 1.0, -}; -pub(super) const ICON_COLOR: Color = Color { - r: 0.25, - g: 0.75, - b: 0.45, - a: 1.0, -}; -pub(super) const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -pub(super) const LABEL_OFF: Color = Color { - r: 0.72, - g: 0.72, - b: 0.72, - a: 1.0, -}; - -// ── Combo / dropdown colors ─────────────────────────────────────────────── - -pub(super) const COMBO_BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -pub(super) const COMBO_HOVER_BG: Color = Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, -}; -pub(super) const COMBO_OPEN_BG: Color = Color { - r: 0.14, - g: 0.14, - b: 0.14, - a: 1.0, -}; -pub(super) const COMBO_BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -pub(super) const COMBO_ACTIVE_BORDER: Color = Color { - r: 0.45, - g: 0.65, - b: 0.90, - a: 1.0, -}; -pub(super) const COMBO_ARROW: Color = Color { - r: 0.70, - g: 0.70, - b: 0.70, - a: 1.0, -}; -pub(super) const SWATCH_BORDER: Color = Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5, -}; -pub(super) const TIP_BG: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 0.97, -}; -pub(super) const HIST_INACTIVE_BG: Color = Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, -}; - // ── Style context (passed from Ribbon to render_large) ──────────────────── pub(super) struct StyleContext { @@ -294,11 +125,8 @@ pub(super) fn flush_small_col<'a>( pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> { match icon { - IconKind::Glyph(s) => text(s).size(size * 0.7).color(Color::WHITE).into(), - IconKind::Svg(bytes) => { - let handle = svg::Handle::from_memory(bytes); - svg(handle).width(size).height(size).into() - } + IconKind::Glyph(s) => text(s).size(size * 0.7).into(), + IconKind::Svg(bytes) => icons::semantic(bytes, size), } } @@ -309,22 +137,19 @@ pub(super) fn start_dimmed(state: &ToggleState, event: &ModuleEvent) -> bool { && !matches!(event, ModuleEvent::Command(c) if crate::app::commands::start_allowed(c)) } -/// Label / glyph color for a possibly-dimmed tool. -pub(super) const DIM_TOOL: Color = Color { - r: 0.42, - g: 0.42, - b: 0.45, - a: 1.0, -}; - -/// `make_icon`, greyed out when `dim` (SVGs render monochrome via tint). +/// `make_icon`, faded when `dim` without flattening multi-colour SVGs. pub(super) fn make_icon_dim(icon: IconKind, size: f32, dim: bool) -> Element<'static, Message> { if !dim { return make_icon(icon, size); } match icon { - IconKind::Glyph(s) => text(s).size(size * 0.7).color(DIM_TOOL).into(), - IconKind::Svg(bytes) => icons::tinted(bytes, size, DIM_TOOL), + IconKind::Glyph(s) => text(s) + .size(size * 0.7) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.42)), + }) + .into(), + IconKind::Svg(bytes) => icons::semantic_disabled(bytes, size), } } @@ -349,15 +174,24 @@ pub(super) fn is_active_tool( // ── Button style ─────────────────────────────────────────────────────────── -pub(super) fn tool_btn_style(is_active: bool, status: button::Status) -> button::Style { +pub(super) fn tool_btn_style( + theme: &Theme, + is_active: bool, + status: button::Status, +) -> button::Style { + let palette = theme.extended_palette(); + let pair = match (is_active, status) { + (true, _) => palette.primary.weak, + (_, button::Status::Hovered) => palette.background.weak, + (_, button::Status::Pressed) => palette.primary.weak, + _ => palette.background.base, + }; button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => TOOL_ACTIVE, - (_, button::Status::Hovered) => TOOL_HOVER, - (_, button::Status::Pressed) => TOOL_ACTIVE, - _ => Color::TRANSPARENT, - })), - text_color: Color::WHITE, + background: is_active + .then_some(Background::Color(pair.color)) + .or_else(|| matches!(status, button::Status::Hovered | button::Status::Pressed) + .then_some(Background::Color(pair.color))), + text_color: pair.text, border: Border { radius: 3.0.into(), color: Color::TRANSPARENT, @@ -368,21 +202,93 @@ pub(super) fn tool_btn_style(is_active: bool, status: button::Status) -> button: } } -// ── Tooltip helpers ──────────────────────────────────────────────────────── - -pub(super) fn make_tip(tip: String) -> Element<'static, Message> { - text(tip).size(11).color(Color::WHITE).into() +pub(super) fn combo_btn_style( + theme: &Theme, + is_open: bool, + status: button::Status, + radius: f32, +) -> button::Style { + let palette = theme.extended_palette(); + let pair = if is_open { + palette.primary.weak + } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { + palette.background.weak + } else { + palette.background.weakest + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + radius: radius.into(), + width: 1.0, + color: if is_open { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + }, + ..Default::default() + } } -pub(super) fn tip_style(_theme: &Theme) -> container::Style { +pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) { + palette.background.weak + } else { + palette.background.base + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + ..Default::default() + } +} + +pub(super) fn popup_panel_style(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); container::Style { - background: Some(Background::Color(TIP_BG)), + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: COMBO_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, - text_color: Some(Color::WHITE), + ..Default::default() + } +} + +pub(super) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.72)), + } +} + +pub(super) fn tool_label_style(theme: &Theme, dim: bool) -> iced::widget::text::Style { + iced::widget::text::Style { + color: dim.then_some( + theme.extended_palette().background.base.text.scale_alpha(0.42), + ), + } +} + +// ── Tooltip helpers ──────────────────────────────────────────────────────── + +pub(super) fn make_tip(tip: String) -> Element<'static, Message> { + text(tip).size(11).into() +} + +pub(super) fn tip_style(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.strong.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + text_color: Some(palette.background.strong.text), ..Default::default() } } @@ -408,7 +314,7 @@ pub(super) fn render_small<'a>( let tip_text = format!("{}\nCommand: {}", t.label, t.id); let btn = button(make_icon_dim(t.icon, SMALL_ICON, dim)) .on_press(Message::RibbonToolClick { tool_id, event }) - .style(move |_: &Theme, status| tool_btn_style(active, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) .width(Length::Fixed(SMALL_W)) .height(ROW_H) .padding([4, 4]); @@ -474,31 +380,22 @@ pub(super) fn render_small<'a>( tool_id: last.to_string(), event: ModuleEvent::Command(last.to_string()), }) - .style(move |_: &Theme, status| tool_btn_style(active, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) .width(Length::Fixed(SMALL_W)) .height(ROW_H) .padding([4, 4]); let arr_tip = format!("{} options", cur_label); let arr_btn = button( - container(icons::arrow_down(8.0, ARROW_COLOR)) + container(icons::themed_arrow_down(8.0)) .width(Fill) .height(Fill) .align_x(iced::Center) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(id.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => TOOL_HOVER, - _ if dd_open => TOOL_ACTIVE, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - ..Default::default() + .style(move |theme: &Theme, status| { + tool_btn_style(theme, dd_open, status) }) .width(Length::Fixed(ARROW_W)) .height(ROW_H) @@ -569,7 +466,9 @@ pub(super) fn render_large_dropdown<'a>( let top_btn = button( column![ make_icon_dim(cur_icon, LARGE_ICON, dim), - text(label.to_string()).size(10).color(if dim { DIM_TOOL } else { LABEL_COLOR }), + text(label.to_string()) + .size(10) + .style(move |theme: &Theme| tool_label_style(theme, dim)), ] .align_x(iced::Center) .spacing(3), @@ -578,7 +477,7 @@ pub(super) fn render_large_dropdown<'a>( tool_id: last.to_string(), event: ModuleEvent::Command(last.to_string()), }) - .style(move |_: &Theme, status| tool_btn_style(active, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) .width(Length::Fixed(LARGE_W)) .height(Fill) .padding(Padding { @@ -589,24 +488,15 @@ pub(super) fn render_large_dropdown<'a>( }); let arr_btn = button( - container(icons::arrow_down(9.0, ARROW_COLOR)) + container(icons::themed_arrow_down(9.0)) .width(Fill) .height(Fill) .align_x(iced::Center) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(id.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => TOOL_HOVER, - _ if dd_open => TOOL_ACTIVE, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() + .style(move |theme: &Theme, status| { + tool_btn_style(theme, dd_open, status) }) .width(Length::Fixed(LARGE_W)) .height(LARGE_ARR) @@ -659,13 +549,15 @@ pub(super) fn render_large<'a>( let btn = button( column![ make_icon_dim(t.icon, LARGE_ICON, dim), - text(t.label).size(10).color(if dim { DIM_TOOL } else { LABEL_COLOR }), + text(t.label) + .size(10) + .style(move |theme: &Theme| tool_label_style(theme, dim)), ] .align_x(iced::Center) .spacing(3), ) .on_press(Message::RibbonToolClick { tool_id, event }) - .style(move |_: &Theme, status| tool_btn_style(active, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) .width(Length::Fixed(LARGE_W)) .height(Fill) .padding(Padding { @@ -733,14 +625,14 @@ pub(super) fn render_large<'a>( let ll = info.map(|l| l.locked).unwrap_or(false); let is_open = open_dd.as_deref() == Some(LAYER_COMBO_ID); - let vis_icon = icons::raw(icons::layer_visible(lv), 14.0); - let freeze_icon = icons::raw(icons::layer_freeze(lf), 14.0); - let lock_icon = icons::raw(icons::layer_lock(ll), 14.0); + let vis_icon = icons::semantic(icons::layer_visible(lv), 14.0); + let freeze_icon = icons::semantic(icons::layer_freeze(lf), 14.0); + let lock_icon = icons::semantic(icons::layer_lock(ll), 14.0); let swatch = container(text("")) - .style(move |_: &Theme| container::Style { + .style(move |theme: &Theme| container::Style { background: Some(Background::Color(lc)), border: Border { - color: SWATCH_BORDER, + color: theme.extended_palette().background.strong.color, width: 1.0, radius: 1.0.into(), }, @@ -758,27 +650,17 @@ pub(super) fn render_large<'a>( freeze_icon, lock_icon, swatch, - container(text(active_layer).size(11).color(Color::WHITE)) + container(text(active_layer).size(11)) .width(name_w) .clip(true), - icons::arrow_down(9.0, COMBO_ARROW), + icons::themed_arrow_down(9.0), ] .spacing(4) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(LAYER_COMBO_ID.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_open, status) { - (true, _) => COMBO_OPEN_BG, - (_, button::Status::Hovered) => COMBO_HOVER_BG, - _ => COMBO_BG, - })), - border: Border { - radius: 3.0.into(), - width: 1.0, - color: COMBO_BORDER, - }, - ..Default::default() + .style(move |theme: &Theme, status| { + combo_btn_style(theme, is_open, status, 3.0) }) .padding([3, 8]) .width(Fill); @@ -793,20 +675,16 @@ pub(super) fn render_large<'a>( let event = t.event.clone(); let icon_el: Element = if dim { make_icon_dim(t.icon, 16.0, true) - } else { match t.icon { - IconKind::Glyph(g) => text(g).size(13).color(Color::WHITE).into(), - IconKind::Svg(bytes) => { - iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) - .width(16) - .height(16) - .into() - } - } }; + } else { + make_icon(t.icon, 16.0) + }; let msg = module_event_to_message(event); tooltip( button(icon_el) .on_press(msg) - .style(move |_: &Theme, status| tool_btn_style(is_active, status)) + .style(move |theme: &Theme, status| { + tool_btn_style(theme, is_active, status) + }) .padding([2, 5]), make_tip(tip.to_string()), TipPos::Right, @@ -864,7 +742,9 @@ pub(super) fn render_large<'a>( let mp_btn = button( column![ make_icon_dim(match_prop.icon, LARGE_ICON, mp_dim), - text(match_prop.label).size(10).color(if mp_dim { DIM_TOOL } else { LABEL_COLOR }), + text(match_prop.label) + .size(10) + .style(move |theme: &Theme| tool_label_style(theme, mp_dim)), ] .align_x(iced::Center) .spacing(3), @@ -873,7 +753,7 @@ pub(super) fn render_large<'a>( tool_id: mp_id, event: mp_event, }) - .style(move |_: &Theme, status| tool_btn_style(mp_active, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, mp_active, status)) .width(Length::Fixed(LARGE_W)) .height(Fill) .padding(Padding { @@ -895,10 +775,10 @@ pub(super) fn render_large<'a>( let is_open = open_dd.as_deref() == Some(dd_id); let swatch_el: Element<'a, Message> = if let Some(c) = swatch { container(text("")) - .style(move |_: &Theme| container::Style { + .style(move |theme: &Theme| container::Style { background: Some(Background::Color(c)), border: Border { - color: SWATCH_BORDER, + color: theme.extended_palette().background.strong.color, width: 1.0, radius: 1.0.into(), }, @@ -913,40 +793,21 @@ pub(super) fn render_large<'a>( button( row![ swatch_el, - container(text(label).size(10).color(Color::WHITE)) + container(text(label).size(10)) .width(Fill) .clip(true), - icons::arrow_toggle( - is_open, - 8.0, - Color { - r: 0.6, - g: 0.6, - b: 0.6, - a: 1.0, - }, - ), + if is_open { + icons::themed_arrow_up(8.0) + } else { + icons::themed_arrow_down(8.0) + }, ] .spacing(4) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(dd_id.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_open, status) { - (true, _) => COMBO_OPEN_BG, - (_, button::Status::Hovered) => COMBO_HOVER_BG, - _ => COMBO_BG, - })), - border: Border { - radius: 2.0.into(), - width: 1.0, - color: if is_open { - COMBO_ACTIVE_BORDER - } else { - COMBO_BORDER - }, - }, - ..Default::default() + .style(move |theme: &Theme, status| { + combo_btn_style(theme, is_open, status, 2.0) }) .padding([3, 8]) .width(Length::Fixed(PROP_W)) @@ -999,31 +860,21 @@ pub(super) fn render_large<'a>( // ── combo button ── let combo_btn = button( row![ - container(text(active.clone()).size(11).color(Color::WHITE)) + container(text(active.clone()).size(11)) .width(Fill) .clip(true), - icons::arrow_toggle(is_open, 9.0, COMBO_ARROW), + if is_open { + icons::themed_arrow_up(9.0) + } else { + icons::themed_arrow_down(9.0) + }, ] .spacing(4) .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(combo_id.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_open, status) { - (true, _) => COMBO_OPEN_BG, - (_, button::Status::Hovered) => COMBO_HOVER_BG, - _ => COMBO_BG, - })), - border: Border { - radius: 3.0.into(), - width: 1.0, - color: if is_open { - COMBO_ACTIVE_BORDER - } else { - COMBO_BORDER - }, - }, - ..Default::default() + .style(move |theme: &Theme, status| { + combo_btn_style(theme, is_open, status, 3.0) }) .padding([3, 8]) .width(Fill); @@ -1045,20 +896,16 @@ pub(super) fn render_large<'a>( let event = t.event.clone(); let icon_el: Element = if dim { make_icon_dim(t.icon, 16.0, true) - } else { match t.icon { - IconKind::Glyph(g) => text(g).size(13).color(Color::WHITE).into(), - IconKind::Svg(bytes) => { - iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) - .width(16) - .height(16) - .into() - } - } }; + } else { + make_icon(t.icon, 16.0) + }; let msg = module_event_to_message(event); tooltip( button(icon_el) .on_press(msg) - .style(move |_: &Theme, status| tool_btn_style(is_active, status)) + .style(move |theme: &Theme, status| { + tool_btn_style(theme, is_active, status) + }) .padding([2, 5]), make_tip(tip.to_string()), TipPos::Right, @@ -1128,7 +975,11 @@ pub(super) fn quick_access_btn<'a>( // they read on the dark top strip (raw black is invisible there). // On the Start tab, commands the start gate refuses render dimmed. let dim = is_start && !crate::app::commands::start_allowed(cmd); - let icon = icons::tinted(icon_bytes, 16.0, if dim { DIM_TOOL } else { QA_ICON_COLOR }); + let icon = if dim { + icons::themed_disabled(icon_bytes, 16.0) + } else { + icons::themed(icon_bytes, 16.0) + }; let btn = button( container(icon) .width(Fill) @@ -1137,22 +988,7 @@ pub(super) fn quick_access_btn<'a>( .align_y(iced::Center), ) .on_press(Message::Command(cmd.to_string())) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - ..Default::default() - }) + .style(button::subtle) .width(Length::Fixed(TOP_HIST_W)) .height(24) .padding([2, 0]); @@ -1171,13 +1007,12 @@ pub(super) fn render_history_control<'a>( ) -> Element<'a, Message> { let dd_open = open_dropdown.as_deref() == Some(dropdown_id); let active = count > 0; - let icon_color = if active { Color::WHITE } else { LABEL_OFF }; let main_btn = { let glyph = if dropdown_id == UNDO_HISTORY_ID { - icons::undo(15.0, icon_color) + icons::themed_undo(15.0, active) } else { - icons::redo(15.0, icon_color) + icons::themed_redo(15.0, active) }; let btn = button( container(glyph) @@ -1186,7 +1021,9 @@ pub(super) fn render_history_control<'a>( .align_x(iced::Center) .align_y(iced::Center), ) - .style(move |_: &Theme, status| top_hist_btn_style(active, dd_open, status)) + .style(move |theme: &Theme, status| { + top_hist_btn_style(theme, active, dd_open, status) + }) .width(Length::Fixed(TOP_HIST_W)) .height(24) .padding([2, 0]); @@ -1211,16 +1048,19 @@ pub(super) fn render_history_control<'a>( let arrow_btn = { let btn = button( - container(icons::arrow_down( - 8.0, - if active { ARROW_COLOR } else { LABEL_OFF }, - )) + container(if active { + icons::themed_arrow_down(8.0) + } else { + icons::themed_disabled_arrow_down(8.0) + }) .width(Fill) .height(Fill) .align_x(iced::Center) .align_y(iced::Center), ) - .style(move |_: &Theme, status| top_hist_btn_style(active, dd_open, status)) + .style(move |theme: &Theme, status| { + top_hist_btn_style(theme, active, dd_open, status) + }) .width(Length::Fixed(TOP_ARR_W)) .height(24) .padding(0); @@ -1243,19 +1083,26 @@ pub(super) fn render_history_control<'a>( } pub(super) fn top_hist_btn_style( + theme: &Theme, active: bool, open: bool, status: button::Status, ) -> button::Style { + let palette = theme.extended_palette(); + let pair = match (active, open, status) { + (false, _, _) => palette.background.weakest, + (_, true, _) => palette.primary.weak, + (_, _, button::Status::Hovered) => palette.background.weak, + (_, _, button::Status::Pressed) => palette.primary.weak, + _ => palette.background.base, + }; button::Style { - background: Some(Background::Color(match (active, open, status) { - (false, _, _) => HIST_INACTIVE_BG, - (_, true, _) => TOOL_ACTIVE, - (_, _, button::Status::Hovered) => TOOL_HOVER, - (_, _, button::Status::Pressed) => TOOL_ACTIVE, - _ => Color::TRANSPARENT, - })), - text_color: Color::WHITE, + background: (!active || open || matches!( + status, + button::Status::Hovered | button::Status::Pressed + )) + .then_some(Background::Color(pair.color)), + text_color: pair.text, border: Border { radius: 3.0.into(), color: Color::TRANSPARENT, diff --git a/src/ui/side_toolbar.rs b/src/ui/side_toolbar.rs index 9a3349c7f..9bad95bf6 100644 --- a/src/ui/side_toolbar.rs +++ b/src/ui/side_toolbar.rs @@ -7,15 +7,12 @@ //! module's tools can drive it. First used for paper-space viewport / plot //! actions; reusable for any future context action set. -use iced::widget::{button, column, container, svg, text, tooltip}; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::widget::{button, column, container, text, tooltip}; +use iced::{Background, Border, Element, Length, Theme}; use crate::app::Message; use crate::modules::{IconKind, ToolDef}; -const PANEL_BG: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 0.96 }; -const PANEL_BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 }; -const BTN_HOVER: Color = Color { r: 0.24, g: 0.24, b: 0.24, a: 1.0 }; const ICON_SIZE: f32 = 22.0; const BTN_SIZE: f32 = 38.0; /// Gap between the toolbar and the right edge of the canvas. @@ -23,25 +20,26 @@ const EDGE_MARGIN: f32 = 8.0; fn icon_el(icon: IconKind) -> Element<'static, Message> { match icon { - IconKind::Glyph(s) => text(s).size(ICON_SIZE * 0.85).color(Color::WHITE).into(), - IconKind::Svg(bytes) => svg(svg::Handle::from_memory(bytes)) - .width(Length::Fixed(ICON_SIZE)) - .height(Length::Fixed(ICON_SIZE)) - .into(), + IconKind::Glyph(s) => text(s).size(ICON_SIZE * 0.85).into(), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, ICON_SIZE), } } fn tip_panel(label: &'static str) -> Element<'static, Message> { - container(text(label).size(11).color(Color::WHITE)) + container(text(label).size(11)) .padding([2, 6]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.strong.color)), border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, + text_color: Some(palette.background.strong.text), ..Default::default() + } }) .into() } @@ -63,17 +61,22 @@ pub fn view(tools: &[ToolDef]) -> Option> { }) .width(Length::Fixed(BTN_SIZE)) .height(Length::Fixed(BTN_SIZE)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => BTN_HOVER, - _ => Color::TRANSPARENT, - })), + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + let hovered = matches!( + status, + button::Status::Hovered | button::Status::Pressed + ); + button::Style { + background: hovered + .then_some(Background::Color(palette.background.strong.color)), border: Border { radius: 3.0.into(), ..Default::default() }, - text_color: Color::WHITE, + text_color: palette.background.base.text, ..Default::default() + } }); // Label tooltip on the left so it never runs off the right edge. col = col.push( @@ -81,14 +84,17 @@ pub fn view(tools: &[ToolDef]) -> Option> { ); } - let panel = container(col).padding(4).style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + let panel = container(col).padding(4).style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: PANEL_BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 5.0.into(), }, ..Default::default() + } }); // Fill the canvas, pin the panel to the right edge and centre it diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index e87bcde46..6ab82a431 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -2,12 +2,14 @@ pub mod statusbar_config; pub mod statusbar_menu; +pub mod status_menu; use iced::widget::tooltip::Position as TipPos; use iced::widget::{ - button, container, mouse_area, row, text, text_input, tooltip, + button, column, container, mouse_area, row, text, text_input, tooltip, }; use iced::{Background, Border, Color, Element, Length, Theme}; +use iced_aw::ContextMenu; use std::sync::Arc; /// Scrollable id of the status-bar layout-tab strip (retained so the existing @@ -22,17 +24,19 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; -use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; +use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; +use crate::ui::wrap_bar::{WrapBar, WrapFlow}; -// PosReport ids for anchoring each status-bar popup directly to its pill. -pub const SB_OSNAP_ID: &str = "SB_OSNAP"; -pub const SB_SCALE_ID: &str = "SB_SCALE"; -pub const SB_UNITS_ID: &str = "SB_UNITS"; -pub const SB_ISOLATE_ID: &str = "SB_ISOLATE"; -pub const SB_FILTER_ID: &str = "SB_FILTER"; -pub const SB_MENU_ID: &str = "SB_MENU"; -pub const SB_LAYOUTLIST_ID: &str = "SB_LAYOUTLIST"; -pub const SB_POLAR_ID: &str = "SB_POLAR"; +pub struct StatusMenuData<'a> { + pub layout_names: Vec, + pub polar_custom_input: &'a str, + pub scale_is_model: bool, + pub scale_list: Vec<(String, f32, f64)>, + pub has_selection: bool, + pub selection_types: Vec, + pub selection_filter: &'a rustc_hash::FxHashSet, + pub tooltip_hidden: bool, +} #[derive(Clone, Default)] pub struct StatusBar { @@ -50,12 +54,9 @@ impl StatusBar { pub fn view<'a>( &'a self, snapper: &'a Snapper, - popup_open: bool, ortho_mode: bool, polar_mode: bool, polar_increment_deg: f32, - // True while the polar-angle picker popup is open. - polar_popup_open: bool, dyn_input: bool, otrack: bool, layouts: Vec, @@ -75,8 +76,6 @@ impl StatusBar { show_layout_tabs: bool, // Current annotation scale for model space (1.0 = 1:1, 50.0 = 1:50, etc.). annotation_scale: f32, - // True when the scale picker popup is open. - scale_popup_open: bool, // True when the scale pill is interactive (always model space; paper space only when a viewport is active/selected). scale_pill_enabled: bool, // LWDISPLAY header flag β€” controls lineweight visibility in the viewport. @@ -94,8 +93,6 @@ impl StatusBar { clean_screen: bool, // Drawing units (INSUNITS) for the units pill. insertion_units: i16, - // True while the drawing-units picker is open. - units_popup_open: bool, // True when objects are hidden by Isolate / Hide. isolation_active: bool, // Whether entity transparency is shown (Transparency pill state). @@ -108,25 +105,28 @@ impl StatusBar { selection_cycling: bool, // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, + menu_data: StatusMenuData<'a>, ) -> Element<'a, Message> { + let StatusMenuData { + layout_names, + polar_custom_input, + scale_is_model, + scale_list, + has_selection, + selection_types, + selection_filter, + tooltip_hidden, + } = menu_data; + // Leftmost hamburger: opens a dropdown listing Model + every layout, so // a layout can be picked directly even when the tab strip is scrolled. - let menu_button = button(crate::ui::icons::tinted( - crate::ui::icons::MENU, - 16.0, - if is_start { DISABLED_COLOR } else { ICON_COLOR }, - )) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => PILL_BG, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - }) + let menu_icon = if is_start { + crate::ui::icons::themed_disabled(crate::ui::icons::MENU, 16.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0) + }; + let menu_button = button(menu_icon) + .style(button::subtle) .padding([4, 8]); let menu_btn = if is_start { tip( @@ -134,18 +134,21 @@ impl StatusBar { "Open or create a drawing to manage layouts.", ) } else { - menu_button.on_press(Message::ToggleLayoutList).into() + status_menu::menu_bar( + menu_tip( + menu_button + .on_press(Message::StatusMenuTooltipHidden(true)) + .into(), + "Model and layout list", + tooltip_hidden, + ), + statusbar_menu::layout_entries(&layout_names, ¤t_layout), + 200.0, + ) }; - let add_button = button(text("+").size(12).color(if is_start { - DISABLED_COLOR - } else { - ICON_COLOR - })) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - ..Default::default() - }) + let add_button = button(text("+").size(12)) + .style(button::subtle) .padding([4, 8]); let add_btn = if is_start { tip( @@ -173,9 +176,19 @@ impl StatusBar { format_scale(viewport_scale) }; let scale_element: Element<'_, Message> = if scale_pill_enabled { - tip( - popup_pill(&scale_label, scale_popup_open, Message::ToggleScalePopup), - "Annotation / Viewport Scale\nClick to change", + status_menu::menu_bar( + menu_tip( + popup_pill(&scale_label), + "Annotation / Viewport Scale\nClick to change", + tooltip_hidden, + ), + crate::ui::popup::scale_popup::menu_entries( + scale_is_model, + annotation_scale, + viewport_scale, + scale_list, + ), + if scale_is_model { 150.0 } else { 120.0 }, ) } else { status_pill(scale_label).into() @@ -189,7 +202,7 @@ impl StatusBar { let coords_label = format_coords(cursor_world, last_point, coords_mode, picking); pills.push( tip( - popup_pill(&coords_label, false, Message::CycleCoordsMode), + action_pill(&coords_label, Message::CycleCoordsMode), "Cursor coordinates ($COORDS)\nClick to cycle: static / live / polar", ) .into(), @@ -215,9 +228,14 @@ impl StatusBar { } if vis(StatusPill::Polar) { pills.push( - PosReport::new( - SB_POLAR_ID, - polar_pill(polar_mode, polar_increment_deg, polar_popup_open), + polar_pill( + polar_mode, + polar_increment_deg, + tooltip_hidden, + crate::ui::popup::polar_popup::menu_entries( + polar_increment_deg, + polar_custom_input, + ), ) .into(), ); @@ -242,9 +260,11 @@ impl StatusBar { } if vis(StatusPill::Osnap) { pills.push( - PosReport::new( - SB_OSNAP_ID, - osnap_btn(osnap_active, snapper.snap_enabled, popup_open), + osnap_btn( + osnap_active, + snapper.snap_enabled, + tooltip_hidden, + crate::ui::popup::snap_popup::menu_entries(snapper), ) .into(), ); @@ -259,20 +279,18 @@ impl StatusBar { ); } if vis(StatusPill::Scale) { - pills.push(PosReport::new(SB_SCALE_ID, scale_element).into()); + pills.push(scale_element); } if vis(StatusPill::Units) { pills.push( - PosReport::new( - SB_UNITS_ID, - tip( - popup_pill( - crate::ui::popup::units_popup::unit_short(insertion_units), - units_popup_open, - Message::ToggleUnitsPopup, - ), + status_menu::menu_bar( + menu_tip( + popup_pill(crate::ui::popup::units_popup::unit_short(insertion_units)), "Drawing Units (INSUNITS)\nClick to change", + tooltip_hidden, ), + crate::ui::popup::units_popup::menu_entries(insertion_units), + 140.0, ) .into(), ); @@ -292,12 +310,21 @@ impl StatusBar { } if vis(StatusPill::Isolate) { pills.push( - PosReport::new( - SB_ISOLATE_ID, - tip( - toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), + status_menu::menu_bar( + menu_tip( + toggle_pill( + crate::ui::icons::ST_ISOLATE, + isolation_active, + Message::StatusMenuTooltipHidden(true), + ), "Isolate Objects\nClick for Isolate / Hide / End", + tooltip_hidden, ), + crate::ui::popup::isolate_popup::menu_entries( + has_selection, + isolation_active, + ), + 160.0, ) .into(), ); @@ -313,16 +340,21 @@ impl StatusBar { } if vis(StatusPill::SelFilter) { pills.push( - PosReport::new( - SB_FILTER_ID, - tip( + status_menu::menu_bar( + menu_tip( toggle_pill( crate::ui::icons::ST_FILTER, selection_filter_active, - Message::ToggleSelectionFilterPopup, + Message::StatusMenuTooltipHidden(true), ), "Selection Filtering\nLimit which object types can be picked", + tooltip_hidden, ), + crate::ui::popup::selection_filter_popup::menu_entries( + selection_types, + selection_filter, + ), + 180.0, ) .into(), ); @@ -356,12 +388,14 @@ impl StatusBar { } // Customization handle: opens the pill show/hide menu. pills.push( - PosReport::new( - SB_MENU_ID, - tip( + status_menu::menu_bar( + menu_tip( customize_btn(), "Customization\nShow or hide status-bar items", + tooltip_hidden, ), + statusbar_menu::customization_entries(config), + 200.0, ) .into(), ); @@ -375,7 +409,7 @@ impl StatusBar { // use the remaining space on the final tab row when they fit; otherwise // WrapBar adds another right-aligned row. let mut left: Vec> = Vec::new(); - left.push(PosReport::new(SB_LAYOUTLIST_ID, menu_btn).into()); + left.push(menu_btn); if show_layout_tabs { let reorderable_layouts: Arc<[String]> = reorderable_layouts.into(); for name in layouts { @@ -403,14 +437,17 @@ impl StatusBar { .justify_end(true); container(wrap) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BAR_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .width(Length::Fill) // One row matches the drawing (document) tab bar height so the three @@ -451,19 +488,9 @@ fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, pick // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { - button(crate::ui::icons::tinted(crate::ui::icons::MENU, 16.0, ICON_COLOR)) - .on_press(Message::ToggleStatusBarMenu) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => PILL_BG, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - }) + button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)) + .on_press(Message::StatusMenuTooltipHidden(true)) + .style(button::subtle) .padding([4, 8]) .into() } @@ -471,7 +498,25 @@ fn customize_btn() -> Element<'static, Message> { // ── Tooltip helper ──────────────────────────────────────────────────────── fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> { - tip_node(content, text(label).size(11).color(Color::WHITE).into()) + tip_node(content, text(label).size(11).into()) +} + +/// A menu root shows its tooltip until clicked. Moving from the root into the +/// opened menu resets suppression for the next hover without covering the menu. +fn menu_tip<'a>( + content: Element<'a, Message>, + label: &'static str, + hidden: bool, +) -> Element<'a, Message> { + let content = if hidden { + content + } else { + tip(content, label) + }; + + mouse_area(content) + .on_exit(Message::StatusMenuTooltipHidden(false)) + .into() } /// Like [`tip`] but the tooltip body is any element β€” used to embed an SVG @@ -481,26 +526,7 @@ fn tip_node<'a>(content: Element<'a, Message>, body: Element<'a, Message>) -> El tooltip( content, container(body) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 0.97, - })), - border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - width: 1.0, - radius: 3.0.into(), - }, - text_color: Some(Color::WHITE), - ..Default::default() - }) + .style(container::bordered_box) .padding([4, 8]), TipPos::Top, ) @@ -513,24 +539,26 @@ fn tip_node<'a>(content: Element<'a, Message>, body: Element<'a, Message>) -> El /// text labels were too small to read). The name lives in the tooltip each /// call site already wraps it with. fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'static, Message> { - let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT }; - button(crate::ui::icons::tinted(icon, 17.0, color)) + let icon = if active { + crate::ui::icons::themed_primary(icon, 17.0) + } else { + crate::ui::icons::themed_secondary(icon, 17.0) + }; + button(icon) .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (active, status) { - (true, button::Status::Hovered) => SNAP_ON_HOVER, - (true, _) => SNAP_ON_BG, - (false, button::Status::Hovered) => SNAP_OFF_HOVER, - (false, _) => SNAP_OFF_BG, - })), - border: Border { - color: if active { SNAP_BORDER_ON } else { BORDER_COLOR }, - width: 1.0, - radius: 2.0.into(), - }, - text_color: color, - shadow: iced::Shadow::default(), - snap: false, + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if active { + let palette = theme.extended_palette(); + style.background = Some(Background::Color(match status { + button::Status::Hovered => palette.primary.base.color, + _ => palette.primary.weak.color, + })); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style }) .padding([4, 7]) .into() @@ -543,49 +571,33 @@ fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'stat // and a dropdown caret, so both halves read as a single integrated control and // the border / background / caret / sizing live in exactly ONE place. -/// Pill text/icon tint for the lit vs. dim state β€” shared so `main` (built by -/// the caller) and the caret always match. -fn pill_text_color(active: bool) -> Color { - if active { - OSNAP_ON_TEXT - } else { - OSNAP_OFF_TEXT - } -} - -/// Wrap a caller-built, already-click-wired `main` element together with a -/// dropdown caret that emits `caret_msg`. `active` lights the pill; `open` -/// highlights the border while its popup is showing. -fn split_pill( +/// Wrap a caller-built, already-click-wired `main` element together with its +/// menu-bearing dropdown caret. +fn split_pill<'a>( main: Element<'static, Message>, - caret_msg: Message, + caret: Element<'a, Message>, active: bool, - open: bool, -) -> Element<'static, Message> { - let color = pill_text_color(active); - let bg = if active { SNAP_ON_BG } else { SNAP_OFF_BG }; - let border_color = if open { - ACCENT - } else if active { - SNAP_BORDER_ON - } else { - BORDER_COLOR - }; - - let caret = mouse_area( - container(crate::ui::icons::arrow_down(9.0, color)).padding([0, 1]), - ) - .on_press(caret_msg); - +) -> Element<'a, Message> { container(row![main, caret].spacing(3).align_y(iced::Center)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(if active { + palette.primary.weak.color + } else { + palette.background.weakest.color + })), border: Border { - color: border_color, + color: if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }, width: 1.0, radius: 2.0.into(), }, ..Default::default() + } }) .padding([4, 6]) .into() @@ -596,12 +608,16 @@ fn split_pill( // Main: left-click toggles polar on/off; right-click cycles the increment. // Caret: opens the angle picker. -fn polar_pill(active: bool, increment_deg: f32, open: bool) -> Element<'static, Message> { +fn polar_pill<'a>( + active: bool, + increment_deg: f32, + tooltip_hidden: bool, + entries: Vec>, +) -> Element<'a, Message> { let angle = crate::ui::popup::polar_popup::angle_label(increment_deg); let tooltip_text = format!( "Polar Tracking ({angle})\nF10 β€” left-click on/off\nRight-click cycles Β· β–Ύ picks angle", ); - let color = pill_text_color(active); // Right-click quick-cycles through the same increments the picker lists. const CYCLE: &[f32] = &[90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0]; @@ -610,66 +626,129 @@ fn polar_pill(active: bool, increment_deg: f32, open: bool) -> Element<'static, None => 45.0, }; + let polar_icon = if active { + crate::ui::icons::themed_primary(crate::ui::icons::ST_POLAR, 17.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::ST_POLAR, 17.0) + }; let main = mouse_area( row![ - crate::ui::icons::tinted(crate::ui::icons::ST_POLAR, 17.0, color), - text(angle).size(11).color(color), + polar_icon, + text(angle).size(11).style(move |theme: &Theme| { + let palette = theme.extended_palette(); + text::Style { + color: Some(if active { + palette.primary.base.color + } else { + palette.background.base.text.scale_alpha(0.72) + }), + } + }), ] .spacing(2) .align_y(iced::Center), ) .on_press(Message::TogglePolar) .on_right_press(Message::SetPolarAngle(next_angle)); - - tooltip( - split_pill(main.into(), Message::TogglePolarPopup, active, open), - container(text(tooltip_text).size(11).color(Color::WHITE)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 0.95, - })), - border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) + let main = tooltip( + main, + container(text(tooltip_text).size(11)) + .style(container::bordered_box) .padding([4, 8]), TipPos::Top, - ) - .into() + ); + + let caret = status_menu::menu_bar( + menu_tip( + mouse_area( + container(if active { + crate::ui::icons::themed_primary_arrow_down(9.0) + } else { + crate::ui::icons::themed_secondary_arrow_down(9.0) + }) + .padding([4, 7]), + ) + .on_press(Message::StatusMenuTooltipHidden(true)) + .into(), + "Polar angle\nClick to choose", + tooltip_hidden, + ), + entries, + 120.0, + ); + + split_pill(main.into(), caret, active) } // ── OSNAP pill ───────────────────────────────────────────────────────────── // // Main: toggles the global snap on/off. Caret: opens the snap-type dropdown. -fn osnap_btn(active: bool, snap_enabled: bool, open: bool) -> Element<'static, Message> { +fn osnap_btn<'a>( + active: bool, + snap_enabled: bool, + tooltip_hidden: bool, + entries: Vec>, +) -> Element<'a, Message> { let on = active || snap_enabled; - let main = mouse_area(crate::ui::icons::tinted( - crate::ui::icons::ST_OSNAP, - 17.0, - pill_text_color(on), - )) - .on_press(Message::ToggleSnapEnabled); + let snap_icon = if on { + crate::ui::icons::themed_primary(crate::ui::icons::ST_OSNAP, 17.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::ST_OSNAP, 17.0) + }; + let main = tip( + mouse_area(snap_icon) + .on_press(Message::ToggleSnapEnabled) + .into(), + "Object Snap: toggle on/off\nF3", + ); - tip( - split_pill(main.into(), Message::ToggleSnapPopup, on, open), - "Object Snap: toggle on/off Β· β–Ύ opens the snap list\nF3", - ) + let caret = status_menu::menu_bar( + menu_tip( + mouse_area( + container(if on { + crate::ui::icons::themed_primary_arrow_down(9.0) + } else { + crate::ui::icons::themed_secondary_arrow_down(9.0) + }) + .padding([4, 7]), + ) + .on_press(Message::StatusMenuTooltipHidden(true)) + .into(), + "Object Snap list\nClick to choose snap types", + tooltip_hidden, + ), + entries, + 210.0, + ); + + split_pill(main, caret, on) } // ── Helpers ─────────────────────────────────────────────────────────────── +fn layout_tab_context_menu(name: String) -> Element<'static, Message> { + let item = |label: &'static str, msg: Message| { + button(text(label).size(12)) + .on_press(msg) + .style(button::subtle) + .padding([4, 12]) + .width(Length::Fill) + }; + + container( + column![ + item("Rename", Message::LayoutRenameStart(name.clone())), + item("Delete", Message::LayoutDelete(name)), + ] + .spacing(0) + .width(160), + ) + .style(container::bordered_box) + .padding([4, 0]) + .into() +} + /// A layout tab button. /// /// When `rename_edit` is `Some(value)` the tab shows an inline text input @@ -682,46 +761,34 @@ fn space_tab<'a>( enabled: bool, reorderable_layouts: Arc<[String]>, ) -> Element<'a, Message> { - let bg = move |is_active: bool, hovered: bool| { - if is_active { - TAB_ACTIVE - } else if hovered { - TAB_HOVER + let tab_style = move |theme: &Theme| { + let palette = theme.extended_palette(); + let text_color = if !enabled { + palette.background.base.text.scale_alpha(0.42) + } else if is_active { + palette.primary.weak.text } else { - Color::TRANSPARENT - } - }; - - let border = Border { - color: if is_active { - ACCENT - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 2.0.into(), - }; - - let text_color = if !enabled { - DISABLED_COLOR - } else if is_active { - Color::WHITE - } else { - Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, + palette.background.base.text.scale_alpha(0.72) + }; + container::Style { + background: is_active.then_some(Background::Color(palette.primary.weak.color)), + border: Border { + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 2.0.into(), + }, + text_color: Some(text_color), + ..Default::default() } }; if !enabled { - let display = container(text(label.clone()).size(12).color(text_color)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg(is_active, false))), - border, - ..Default::default() - }) + let display = container(text(label.clone()).size(12)) + .style(tab_style) .padding([4, 10]); crate::ui::wrap_bar::PosReport::owned( format!("SB_LAYOUT_TAB:{label}"), @@ -738,49 +805,15 @@ fn space_tab<'a>( .on_input(Message::LayoutRenameEdit) .on_submit(Message::LayoutRenameCommit) .size(12) - .style(|_: &Theme, _| text_input::Style { - background: Background::Color(TAB_ACTIVE), - border: Border { - color: ACCENT, - width: 1.0, - radius: 2.0.into(), - }, - icon: Color::WHITE, - placeholder: Color { - r: 0.5, - g: 0.5, - b: 0.5, - a: 1.0, - }, - value: Color::WHITE, - selection: Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 0.4, - }, - }) .padding([3, 6]) .width(Length::Fixed(90.0)); - let cancel_btn = button(crate::ui::icons::tinted( + let cancel_btn = button(crate::ui::icons::themed_secondary( crate::ui::icons::CLOSE, 10.0, - Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, - }, )) .on_press(Message::LayoutRenameCancel) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - border: Border::default(), - shadow: iced::Shadow::default(), - snap: false, - ..Default::default() - }) + .style(button::subtle) .padding([4, 4]); row![input, cancel_btn] @@ -788,26 +821,17 @@ fn space_tab<'a>( .align_y(iced::Center) .into() } else { - // Normal clickable tab β€” left click switches, right click opens context menu. - let display = container(text(label.clone()).size(12).color(text_color)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg(is_active, false))), - border, - ..Default::default() - }) + // Normal clickable tab β€” left click switches. Paper-layout tabs are + // wrapped in `ContextMenu`, which owns right-click handling. + let display = container(text(label.clone()).size(12)) + .style(tab_style) .padding([4, 10]); let switch_msg = Message::LayoutSwitch(label.clone()); - let ctx_msg = Message::LayoutContextMenu(label.clone()); - - // Use mouse_area so we can capture right-click for the context menu. - // PosReport records the tab's screen bounds so the context menu can - // anchor next to the clicked tab instead of the screen's left edge - // (#428). - let tab = mouse_area(display) - .on_press(switch_msg) - .on_right_press(ctx_msg); - let tab: Element<'a, Message> = if reorderable_layouts.contains(&label) { + let has_context_menu = reorderable_layouts.contains(&label); + let report_key = format!("SB_LAYOUT_TAB:{label}"); + let tab = mouse_area(display).on_press(switch_msg); + let tab: Element<'a, Message> = if has_context_menu { crate::ui::wrap_bar::ReorderTab::layout( label.clone(), reorderable_layouts, @@ -818,8 +842,13 @@ fn space_tab<'a>( tab.into() }; + let tab = if has_context_menu { + ContextMenu::new(tab, move || layout_tab_context_menu(label.clone())).into() + } else { + tab + }; crate::ui::wrap_bar::PosReport::owned( - format!("SB_LAYOUT_TAB:{label}"), + report_key, tab, ) .into() @@ -845,34 +874,21 @@ fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Mes ("PAPER", false, Some(Message::MspaceCommand)) }; - let text_color = if active { - SNAP_BORDER_ON - } else { - OSNAP_OFF_TEXT - }; - let bg_normal = if active { SNAP_ON_BG } else { SNAP_OFF_BG }; - let bg_hover = if active { - SNAP_ON_HOVER - } else { - SNAP_OFF_HOVER - }; - let border_color = if active { SNAP_BORDER_ON } else { BORDER_COLOR }; - let clickable = on_press.is_some(); - let mut btn = button(text(label).size(12).color(text_color)) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered if clickable => bg_hover, - _ => bg_normal, - })), - border: Border { - color: border_color, - width: 1.0, - radius: 2.0.into(), - }, - text_color, - shadow: iced::Shadow::default(), - snap: false, + let mut btn = button(text(label).size(12)) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if active { + let palette = theme.extended_palette(); + style.background = Some(Background::Color(match status { + button::Status::Hovered if clickable => palette.primary.base.color, + _ => palette.primary.weak.color, + })); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style }) .padding([4, 7]); @@ -884,148 +900,24 @@ fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Mes } fn status_pill(label: impl Into) -> Element<'static, Message> { - container(text(label.into()).size(12).color(Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, - })) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PILL_BG)), - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) + container(text(label.into()).size(12)) + .style(container::bordered_box) .padding([4, 8]) .into() } -// ── Colours ─────────────────────────────────────────────────────────────── - -const BAR_BG: Color = Color { - r: 0.14, - g: 0.14, - b: 0.14, - a: 1.0, -}; -const TAB_ACTIVE: Color = Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, -}; -const TAB_HOVER: Color = Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, -}; -const PILL_BG: Color = Color { - r: 0.19, - g: 0.19, - b: 0.19, - a: 1.0, -}; -const BORDER_COLOR: Color = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, -}; -const ICON_COLOR: Color = Color { - r: 0.70, - g: 0.70, - b: 0.70, - a: 1.0, -}; -const DISABLED_COLOR: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, -}; - -const OSNAP_ON_TEXT: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const OSNAP_OFF_TEXT: Color = Color { - r: 0.42, - g: 0.42, - b: 0.42, - a: 1.0, -}; -const SNAP_ON_BG: Color = Color { - r: 0.10, - g: 0.20, - b: 0.32, - a: 1.0, -}; -const SNAP_ON_HOVER: Color = Color { - r: 0.14, - g: 0.27, - b: 0.42, - a: 1.0, -}; -const SNAP_BORDER_ON: Color = Color { - r: 0.20, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const SNAP_OFF_BG: Color = Color { - r: 0.17, - g: 0.17, - b: 0.17, - a: 1.0, -}; -const SNAP_OFF_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; - // ── Scale popup button ──────────────────────────────────────────────────── -/// A labelled status-bar pill that opens a picker popup on click (units, -/// scale, …). Lit + accent-bordered while its popup is `open`. Shared so the -/// popup-opening pills don't each re-declare the same button chrome. -fn popup_pill(label: &str, open: bool, msg: Message) -> Element<'static, Message> { +/// Shared visual root for labelled status-bar menus (units, scale, …). +fn popup_pill(label: &str) -> Element<'static, Message> { + action_pill(label, Message::StatusMenuTooltipHidden(true)) +} + +fn action_pill(label: &str, msg: Message) -> Element<'static, Message> { let label = label.to_string(); - button( - text(label) - .size(12) - .color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }), - ) + button(text(label).size(12)) .on_press(msg) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (open, status) { - (true, button::Status::Hovered) => SNAP_ON_HOVER, - (true, _) => SNAP_ON_BG, - (false, button::Status::Hovered) => SNAP_OFF_HOVER, - (false, _) => SNAP_OFF_BG, - })), - border: Border { - color: if open { SNAP_BORDER_ON } else { BORDER_COLOR }, - width: 1.0, - radius: 2.0.into(), - }, - text_color: if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }, - shadow: iced::Shadow::default(), - snap: false, - }) + .style(button::subtle) .padding([4, 7]) .into() } diff --git a/src/ui/statusbar/status_menu.rs b/src/ui/statusbar/status_menu.rs new file mode 100644 index 000000000..48053ba65 --- /dev/null +++ b/src/ui/statusbar/status_menu.rs @@ -0,0 +1,80 @@ +//! Shared `iced_aw::MenuBar` plumbing for status-bar menus. + +use iced::{Background, Border, Color, Element, Length, Shadow, Theme}; +use iced_aw::menu::{DrawPath, Item, Menu, MenuBar}; + +use crate::app::Message; + +/// One row in a status-bar menu. +pub struct Entry<'a> { + content: Element<'a, Message>, + close_on_click: bool, +} + +impl<'a> Entry<'a> { + /// Keep the menu open after this row is clicked. + pub fn stay(content: impl Into>) -> Self { + Self { + content: content.into(), + close_on_click: false, + } + } + + /// Close the menu after this row is clicked. + pub fn close(content: impl Into>) -> Self { + Self { + content: content.into(), + close_on_click: true, + } + } +} + +/// Attach a menu to `root`. Each status-bar menu uses one root so the existing +/// wrapping layout can still move pills independently between rows. +pub fn menu_bar<'a>( + root: impl Into>, + entries: Vec>, + width: f32, +) -> Element<'a, Message> { + let items = entries + .into_iter() + .map(|entry| Item::new(entry.content).close_on_click(entry.close_on_click)) + .collect(); + let menu = Menu::new(items) + .width(Length::Fixed(width)) + .padding(0) + .spacing(0) + .offset(1.0) + .close_on_background_click(true); + + MenuBar::new(vec![Item::with_menu(root, menu)]) + .safe_bounds_margin(0.0) + .close_on_background_click_global(true) + .draw_path(DrawPath::Backdrop) + .style(|theme: &Theme, _| { + let palette = theme.extended_palette(); + iced_aw::style::menu_bar::Style { + bar_background: Background::Color(Color::TRANSPARENT), + bar_border: Border::default(), + bar_shadow: Shadow::default(), + menu_background: Background::Color(palette.background.weakest.color), + menu_border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + menu_shadow: Shadow { + color: palette.background.strongest.color.scale_alpha(0.35), + offset: iced::Vector::new(0.0, -2.0), + blur_radius: 6.0, + }, + path: Background::Color(palette.primary.weak.color), + path_border: Border { + color: palette.primary.base.color, + width: 1.0, + radius: 2.0.into(), + }, + } + }) + .into() +} diff --git a/src/ui/statusbar/statusbar_menu.rs b/src/ui/statusbar/statusbar_menu.rs index d078bae0c..20777b084 100644 --- a/src/ui/statusbar/statusbar_menu.rs +++ b/src/ui/statusbar/statusbar_menu.rs @@ -1,105 +1,47 @@ -//! Status-bar customization menu β€” a checkmark list of every toggle pill, -//! opened from the bar's far-right handle. Rendered as a floating overlay -//! above the status bar, same pattern as the scale picker. +//! Status-bar customization and layout-list menu entries. -use iced::widget::{button, column, container, mouse_area, row, scrollable, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; +use iced::widget::{button, row, text}; +use iced::{Background, Element, Fill, Theme}; use crate::app::Message; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; +use crate::ui::statusbar::status_menu::Entry; -/// Full-screen overlay: transparent click-catcher + the menu panel pinned to -/// the bottom-right, just above the status bar. -pub fn statusbar_menu_overlay( - config: &StatusBarConfig, - pill: Option, - win: (f32, f32), -) -> Element<'static, Message> { - let rows: Vec> = StatusPill::ALL +pub fn customization_entries(config: &StatusBarConfig) -> Vec> { + StatusPill::ALL .iter() .map(|&pill| { - menu_row( + Entry::stay(menu_row( pill.label(), config.is_visible(pill), Message::ToggleStatusPill(pill), - ) + )) }) - .collect(); - - let panel = container(column(rows)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(200.0)); - - let positioned = - crate::ui::popup::position_statusbar_popup(panel.into(), pill, win, 200.0, true); - - mouse_area(positioned) - .on_press(Message::CloseStatusBarMenu) - .into() + .collect() } -/// Dropdown listing Model + every paper layout, opened from the leftmost -/// hamburger. Pinned bottom-left just above the status bar; a click selects a -/// layout (and closes), an outside click just closes. -pub fn layout_list_overlay<'a>( +pub fn layout_entries<'a>( layouts: &[String], current: &str, - pill: Option, - win: (f32, f32), -) -> Element<'a, Message> { - let rows: Vec> = layouts +) -> Vec> { + layouts .iter() - .map(|name| layout_row(name.clone(), name == current)) - .collect(); - - let panel = container(scrollable(column(rows))) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }) - .width(Length::Fixed(200.0)) - .max_height(360.0); - - // Hamburger is on the left β†’ prefer left-aligned (grows right). - let positioned = - crate::ui::popup::position_statusbar_popup(panel.into(), pill, win, 200.0, false); - - mouse_area(positioned) - .on_press(Message::CloseLayoutList) - .into() + .map(|name| Entry::close(layout_row(name.clone(), name == current))) + .collect() } fn layout_row<'a>(name: String, is_current: bool) -> Element<'a, Message> { - let lbl = text(name.clone()) - .size(11) - .color(if is_current { LABEL_ON } else { LABEL_OFF }); + let lbl = text(name.clone()).size(11); button(row![lbl].align_y(iced::Center)) .on_press(Message::LayoutSwitch(name)) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match (is_current, status) { - (_, button::Status::Hovered) => ROW_HOVER, - (true, _) => Color { - r: 0.18, - g: 0.26, - b: 0.36, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - ..Default::default() + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if is_current && status == button::Status::Active { + let palette = theme.extended_palette(); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + } + style }) .width(Fill) .padding([4, 12]) @@ -107,63 +49,16 @@ fn layout_row<'a>(name: String, is_current: bool) -> Element<'a, Message> { } fn menu_row(label: &'static str, checked: bool, msg: Message) -> Element<'static, Message> { - let check = crate::ui::icons::check_cell(checked, CHECK_COLOR); + let check = crate::ui::icons::themed_check_cell(checked); - let lbl = text(label) - .size(11) - .color(if checked { LABEL_ON } else { LABEL_OFF }); + let lbl = text(label).size(11); let content = row![check, lbl].spacing(6).align_y(iced::Center); button(content) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(button::subtle) .width(Fill) .padding([4, 10]) .into() } - -// ── Colours ─────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL_BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, -}; -const ROW_HOVER: Color = Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, -}; -const CHECK_COLOR: Color = Color { - r: 0.35, - g: 0.75, - b: 1.00, - a: 1.0, -}; -const LABEL_ON: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; -const LABEL_OFF: Color = Color { - r: 0.65, - g: 0.65, - b: 0.65, - a: 1.0, -}; diff --git a/src/ui/style/anno_object_scale.rs b/src/ui/style/anno_object_scale.rs index 693919cc0..477e86fb5 100644 --- a/src/ui/style/anno_object_scale.rs +++ b/src/ui/style/anno_object_scale.rs @@ -7,16 +7,9 @@ //! the style / scale managers' frame so it looks consistent. use crate::app::Message; -use crate::ui::style::style_manager::{hdivider, tb_button, BG, BORDER, DIM, LIST, TB, TEXT}; +use crate::ui::style::style_manager::{hdivider, muted_text_style, tb_button}; use iced::widget::{column, container, mouse_area, row, scrollable, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const MEMBER_CHECK: Color = Color { - r: 0.30, - g: 0.82, - b: 0.36, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; /// `scales` is `(name, "paper:drawing" ratio, is_member)`. Every label is cloned /// into the widget tree, so the returned element borrows nothing from the args. @@ -26,17 +19,17 @@ pub fn view_window( ) -> Element<'static, Message> { let toolbar = container( row![ - text(format!("Object: {object_label}")) - .size(11) - .color(TEXT), + text(format!("Object: {object_label}")).size(11), Space::new().width(Fill), tb_button("Close", Message::CloseModal, true), ] .spacing(4) .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -45,21 +38,17 @@ pub fn view_window( let rows: Vec> = scales .iter() .map(|(name, ratio, member)| { - let check = crate::ui::icons::check_cell(*member, MEMBER_CHECK); + let check = crate::ui::icons::themed_check_cell(*member); let label = row![ check, - text(name.clone()).size(11).color(TEXT).width(Fill), - text(ratio.clone()).size(10).color(DIM), + text(name.clone()).size(11).width(Fill), + text(ratio.clone()).size(10).style(muted_text_style), ] .spacing(4) .align_y(iced::Center); let cell = container(label) .padding([4, 8]) - .width(Fill) - .style(move |_: &Theme| container::Style { - text_color: Some(TEXT), - ..Default::default() - }); + .width(Fill); mouse_area(cell) .on_press(Message::AnnoObjectScaleToggle(name.clone())) .into() @@ -67,14 +56,17 @@ pub fn view_window( .collect(); let list = container(scrollable(column(rows).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -84,7 +76,7 @@ pub fn view_window( column![ text("Click a scale to add or remove the object's representation for it.") .size(10) - .color(DIM), + .style(muted_text_style), list, ] .spacing(6) @@ -95,8 +87,10 @@ pub fn view_window( .padding(12); container(column![toolbar, hdivider(), body]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/style/dimstyle.rs b/src/ui/style/dimstyle.rs index 375474fdb..07c9ad11e 100644 --- a/src/ui/style/dimstyle.rs +++ b/src/ui/style/dimstyle.rs @@ -4,44 +4,7 @@ use crate::app::{ColorPickTarget, DsField, Message}; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, }; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const FIELD: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; /// All DimStyle field values needed by the view. pub struct DimStyleValues<'a> { @@ -131,44 +94,57 @@ pub struct DimStyleValues<'a> { } fn tab_btn_style(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (active, st) { - (true, _) => ACTIVE, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - _ => Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, - }, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (active, st) { + (true, _) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => { + palette.background.strong + } + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } } } -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(FIELD), + background: Background::Color(palette.background.base.color), border: Border { - color: BORDER, + color: border, width: 1.0, radius: 3.0.into(), }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), } } @@ -176,8 +152,10 @@ fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -221,7 +199,7 @@ pub fn view_window<'a>( ] .spacing(2); - let lbl = |s: &'static str| text(s).size(11).color(DIM).width(180); + let lbl = |s: &'static str| text(s).size(11).style(muted_style).width(180); let mk_field = |fld: DsField, val: &'a str| -> Element<'a, Message> { text_input("", val) @@ -338,7 +316,7 @@ pub fn view_window<'a>( let tab_content: Element<'_, Message> = match tab { 0 => column![ - text("Dimension Line").size(11).color(ACCENT), + text("Dimension Line").size(11).style(primary_style), row![ lbl("Extension (DIMDLE)"), mk_field(DsField::Dimdle, vals.dimdle) @@ -359,7 +337,7 @@ pub fn view_window<'a>( .align_y(iced::Center), chk("Suppress 1st line (DIMSD1)", vals.dimsd1, DsField::Dimsd1), chk("Suppress 2nd line (DIMSD2)", vals.dimsd2, DsField::Dimsd2), - text("Extension Line").size(11).color(ACCENT), + text("Extension Line").size(11).style(primary_style), row![ lbl("Extension (DIMEXE)"), mk_field(DsField::Dimexe, vals.dimexe) @@ -421,7 +399,7 @@ pub fn view_window<'a>( .spacing(7) .into(), 1 => column![ - text("Arrows").size(11).color(ACCENT), + text("Arrows").size(11).style(primary_style), hrow( "Arrowhead (DIMBLK)", vals.block_opts.clone(), @@ -485,7 +463,7 @@ pub fn view_window<'a>( .spacing(7) .into(), 2 => column![ - text("Text").size(11).color(ACCENT), + text("Text").size(11).style(primary_style), row![ lbl("Height (DIMTXT)"), mk_field(DsField::Dimtxt, vals.dimtxt) @@ -551,7 +529,7 @@ pub fn view_window<'a>( .spacing(7) .into(), 3 => column![ - text("Scale").size(11).color(ACCENT), + text("Scale").size(11).style(primary_style), chk("Annotative", vals.annotative, DsField::Annotative), row![ lbl("Overall scale (DIMSCALE)"), @@ -565,7 +543,7 @@ pub fn view_window<'a>( ] .spacing(8) .align_y(iced::Center), - text("Units").size(11).color(ACCENT), + text("Units").size(11).style(primary_style), enum_field("Format (DIMLUNIT)", DsField::Dimlunit, vals.dimlunit, OPT_LUNIT), row![ lbl("Decimals (DIMDEC)"), @@ -636,7 +614,7 @@ pub fn view_window<'a>( ("3", "Leading & trailing"), ], ), - text("Fit").size(11).color(ACCENT), + text("Fit").size(11).style(primary_style), enum_field( "Fit (DIMATFIT)", DsField::Dimatfit, @@ -680,7 +658,7 @@ pub fn view_window<'a>( .spacing(7) .into(), 5 => column![ - text("Alternate Units").size(11).color(ACCENT), + text("Alternate Units").size(11).style(primary_style), chk( "Enable alternate units (DIMALT)", vals.dimalt, @@ -728,7 +706,7 @@ pub fn view_window<'a>( .spacing(7) .into(), _ => column![ - text("Tolerances").size(11).color(ACCENT), + text("Tolerances").size(11).style(primary_style), chk("Generate tolerances (DIMTOL)", vals.dimtol, DsField::Dimtol), chk("Limits generation (DIMLIM)", vals.dimlim, DsField::Dimlim), row![ @@ -773,7 +751,7 @@ pub fn view_window<'a>( // content container instead of the panel padding. let right_panel = container( column![ - text(format!("Editing: {selected}")).size(11).color(DIM), + text(format!("Editing: {selected}")).size(11).style(muted_style), tabs, hdivider(), scrollable(container(tab_content).padding([12, 12]).width(Fill)) diff --git a/src/ui/style/mleaderstyle.rs b/src/ui/style/mleaderstyle.rs index e9e7474fa..22c908c31 100644 --- a/src/ui/style/mleaderstyle.rs +++ b/src/ui/style/mleaderstyle.rs @@ -4,63 +4,41 @@ use crate::app::Message; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, }; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (accent, st) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => { + palette.background.strong + } + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), } } @@ -106,7 +84,7 @@ pub struct MLeaderStyleView<'a> { } fn section<'a>(label: &'static str) -> Element<'a, Message> { - text(label).size(11).color(ACCENT).into() + text(label).size(11).style(primary_style).into() } fn num_row<'a>( @@ -116,7 +94,7 @@ fn num_row<'a>( field: &'static str, ) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(150), + text(label).size(11).style(muted_style).width(150), text_input(placeholder, value) .on_input(move |v| Message::MLeaderStyleEdit { field, value: v }) .size(11) @@ -150,7 +128,7 @@ fn color_row<'a>( Message::MLeaderColorMore(field), Message::OpenColorWindow(crate::app::ColorPickTarget::MLeader(field)), ); - row![text(label).size(11).color(DIM).width(150), selector] + row![text(label).size(11).style(muted_style).width(150), selector] .spacing(8) .align_y(iced::Center) .into() @@ -163,7 +141,7 @@ fn enum_row<'a>( field: &'static str, ) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(150), + text(label).size(11).style(muted_style).width(150), pick_list(options, Some(selected), move |value| { Message::MLeaderStyleSetEnum { field, value } }) @@ -178,7 +156,7 @@ fn enum_row<'a>( fn lineweight_row<'a>(selected: acadrust::types::LineWeight) -> Element<'a, Message> { let selected = crate::ui::properties::LwItem(selected); row![ - text("Line weight:").size(11).color(DIM).width(150), + text("Line weight:").size(11).style(muted_style).width(150), pick_list( crate::ui::properties::lw_options(), Some(selected), @@ -220,7 +198,7 @@ fn handle_row<'a>( field: &'static str, ) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(150), + text(label).size(11).style(muted_style).width(150), pick_list(options, Some(selected), move |value| { Message::MLeaderStyleSetHandle { field, value } }) @@ -247,7 +225,7 @@ pub fn view_window<'a>(v: MLeaderStyleView<'a>) -> Element<'a, Message> { scrollable( column![ row![ - text("Name:").size(11).color(DIM).width(150), + text("Name:").size(11).style(muted_style).width(150), text(s.name.clone()).size(11), ] .spacing(8), @@ -422,7 +400,7 @@ pub fn view_window<'a>(v: MLeaderStyleView<'a>) -> Element<'a, Message> { .height(Fill) .into() } else { - container(text("Select a style to view details.").size(11).color(DIM)) + container(text("Select a style to view details.").size(11).style(muted_style)) .padding([12, 12]) .into() }; diff --git a/src/ui/style/mlstyle.rs b/src/ui/style/mlstyle.rs index bfe5ddb17..9a1c22d34 100644 --- a/src/ui/style/mlstyle.rs +++ b/src/ui/style/mlstyle.rs @@ -2,14 +2,13 @@ use crate::app::Message; use iced::widget::{column, container, row, scrollable, text}; -use iced::{Color, Element, Fill}; +use iced::{Element, Fill, Theme}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} pub fn view_window<'a>( styles: Vec, @@ -22,7 +21,7 @@ pub fn view_window<'a>( // ── Right: Details panel ────────────────────────────────────────────── let info_row = |label: &'static str, val: String| -> Element<'_, Message> { row![ - text(label).size(11).color(DIM).width(120), + text(label).size(11).style(muted_style).width(120), text(val).size(11), ] .spacing(8) @@ -49,7 +48,7 @@ pub fn view_window<'a>( &e.linetype }; row![ - text(format!(" {idx}:")).size(10).color(DIM).width(24), + text(format!(" {idx}:")).size(10).style(muted_style).width(24), text(format!("{:+.3}", e.offset)).size(10).width(70), text(color_str).size(10).width(90), text(lt).size(10), @@ -65,7 +64,7 @@ pub fn view_window<'a>( info_row("Elements:", s.elements.len().to_string()), text(" Off Color Ltype") .size(10) - .color(DIM) + .style(muted_style) .into(), ]; col_items.extend(elem_rows); @@ -73,7 +72,7 @@ pub fn view_window<'a>( .height(Fill) .into() } else { - container(text("Select a style to view details.").size(11).color(DIM)) + container(text("Select a style to view details.").size(11).style(muted_style)) .padding([12, 12]) .into() }; diff --git a/src/ui/style/plotstyle.rs b/src/ui/style/plotstyle.rs index 7db908505..0a4ef8c70 100644 --- a/src/ui/style/plotstyle.rs +++ b/src/ui/style/plotstyle.rs @@ -2,108 +2,55 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const TB: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const FIELD: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; -const LIST: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (accent, st) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => { + palette.background.strong + } + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } } } -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(FIELD), + background: Background::Color(palette.background.base.color), border: Border { - color: BORDER, + color: border, width: 1.0, radius: 3.0.into(), }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } @@ -111,8 +58,10 @@ fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -122,8 +71,10 @@ fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -156,13 +107,15 @@ pub fn view_window<'a>( .style(btn_s(false)) .padding([4, 10]), Space::new().width(Fill), - text(table_name).size(10).color(DIM), + text(table_name).size(10).style(muted_style), ] .spacing(4) .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -199,19 +152,22 @@ pub fn view_window<'a>( }; button(text(label).size(10).font(iced::Font::MONOSPACE)) .on_press(Message::PlotStylePanelSelectAci(aci)) - .style(move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (is_sel, st) { - (true, _) => ACTIVE, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - text_color: TEXT, + .style(move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (is_sel, st) { + (true, _) => Some(palette.primary.strong), + (false, button::Status::Hovered | button::Status::Pressed) => { + Some(palette.background.strong) + } + _ => None, + }; + button::Style { + background: pair.map(|p| Background::Color(p.color)), + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text), ..Default::default() + } }) .padding([2, 8]) .width(Fill) @@ -221,16 +177,19 @@ pub fn view_window<'a>( let aci_list = container( column![ - text("ACI Color Index").size(10).color(DIM), + text("ACI Color Index").size(10).style(muted_style), container(scrollable(column(aci_items).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -269,12 +228,12 @@ pub fn view_window<'a>( .map(|e| format!("{}%", e.screening)) .unwrap_or_else(|| "β€”".into()); - let lbl = |s: &'static str| text(s).size(11).color(DIM); + let lbl = |s: &'static str| text(s).size(11).style(muted_style); let edit_panel = container( column![ row![ - text("ACI:").size(11).color(DIM).width(100), + text("ACI:").size(11).style(muted_style).width(100), text(format!("{selected_aci}")).size(11), ] .spacing(8) @@ -298,7 +257,7 @@ pub fn view_window<'a>( .size(11) .padding([4, 8]), Space::new().height(8), - text("Current values:").size(10).color(DIM), + text("Current values:").size(10).style(muted_style), text(format!(" Color: {cur_color}")).size(10), text(format!(" Lineweight: {cur_lw}")).size(10), text(format!(" Screening: {cur_scr}")).size(10), @@ -318,8 +277,10 @@ pub fn view_window<'a>( let body = row![aci_list, vsep(), edit_panel].height(Fill); container(column![toolbar, hdivider(), body].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/style/point_style.rs b/src/ui/style/point_style.rs index d2a5ee6ad..539b23176 100644 --- a/src/ui/style/point_style.rs +++ b/src/ui/style/point_style.rs @@ -7,15 +7,7 @@ use crate::app::Message; use iced::widget::{button, canvas, column, container, radio, row, text, text_input, Space}; -use iced::{mouse, Background, Border, Color, Element, Length, Point, Rectangle, Size, Theme}; - -const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; -const WHITE: Color = Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }; -const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; -const ACCENT: Color = Color { r: 0.30, g: 0.62, b: 0.95, a: 1.0 }; -const CELL: Color = Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 }; -const CELL_SEL: Color = Color { r: 0.30, g: 0.62, b: 0.95, a: 1.0 }; -const GLYPH: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 }; +use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Size, Theme}; const CELL_PX: f32 = 44.0; @@ -37,22 +29,23 @@ impl canvas::Program for GlyphCanvas { &self, _state: &(), renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); + let glyph = theme.extended_palette().background.base.text; let (cx, cy) = (bounds.width * 0.5, bounds.height * 0.5); let r = bounds.width.min(bounds.height) * 0.30; let stroke = canvas::Stroke { width: 1.4, - style: canvas::Style::Solid(GLYPH), + style: canvas::Style::Solid(glyph), ..Default::default() }; let line = |a: Point, b: Point| canvas::Path::line(a, b); match self.mode & 0x0F { - 0 => frame.fill(&canvas::Path::circle(Point::new(cx, cy), 2.4), GLYPH), + 0 => frame.fill(&canvas::Path::circle(Point::new(cx, cy), 2.4), glyph), 1 => {} 2 => { frame.stroke(&line(Point::new(cx - r, cy), Point::new(cx + r, cy)), stroke.clone()); @@ -89,39 +82,52 @@ fn cell<'a>(value: i16, selected: bool) -> Element<'a, Message> { button(glyph) .padding(0) .on_press(Message::PointStyleSetMode(value)) - .style(move |_: &Theme, status| { - let bg = if selected { - CELL_SEL + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = if selected { + palette.primary.strong } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } + palette.background.strong } else { - CELL + palette.background.weak }; button::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(pair.color)), border: Border { - color: Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, + text_color: pair.text, ..Default::default() } }) .into() } -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(Color { r: 0.1, g: 0.1, b: 0.1, a: 1.0 }), + background: Background::Color(palette.background.base.color), border: Border { - color: Color { r: 0.3, g: 0.3, b: 0.3, a: 1.0 }, + color: border, width: 1.0, radius: 4.0.into(), }, - icon: DIM, - placeholder: DIM, - value: WHITE, - selection: ACCENT, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } @@ -138,7 +144,7 @@ pub fn view_window<'a>(pdmode: i16, relative: bool, size_buf: &str) -> Element<' } let size_row = row![ - text("Point Size:").size(13).color(WHITE), + text("Point Size:").size(13), Space::new().width(10), text_input("0", size_buf) .on_input(Message::PointStyleSizeInput) @@ -147,7 +153,7 @@ pub fn view_window<'a>(pdmode: i16, relative: bool, size_buf: &str) -> Element<' .size(13) .width(110), Space::new().width(6), - text(if relative { "%" } else { "units" }).size(12).color(DIM), + text(if relative { "%" } else { "units" }).size(12).style(muted_style), ] .align_y(iced::Center); @@ -171,26 +177,14 @@ pub fn view_window<'a>(pdmode: i16, relative: bool, size_buf: &str) -> Element<' ] .spacing(6); - let ok = button(text("OK").size(13).color(WHITE)) + let ok = button(text("OK").size(13)) .padding([5, 22]) .on_press(Message::PointStyleOk) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.32, g: 0.55, b: 0.85, a: 1.0 } - } else { - ACCENT - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }); + .style(button::primary); container( column![ - text("Point Style").size(18).color(WHITE), + text("Point Style").size(18), Space::new().height(6), grid, Space::new().height(12), @@ -203,8 +197,10 @@ pub fn view_window<'a>(pdmode: i16, relative: bool, size_buf: &str) -> Element<' .spacing(4) .padding(20), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .into() diff --git a/src/ui/style/scale_manager.rs b/src/ui/style/scale_manager.rs index 0f51dfd88..fba9e3895 100644 --- a/src/ui/style/scale_manager.rs +++ b/src/ui/style/scale_manager.rs @@ -6,53 +6,36 @@ //! messages instead of the StyleKind machinery. use crate::app::Message; -use crate::ui::style::style_manager::{hdivider, tb_button, vsep, BG, BORDER, DIM, LIST, TB, TEXT}; +use crate::ui::style::style_manager::{ + hdivider, muted_text_style, tb_button, vsep, +}; use iced::widget::{ column, container, mouse_area, row, scrollable, text, text_input, Space, }; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Border, Element, Fill, Theme}; /// Inline-rename text-input id, so the rename-start handler can focus it. pub fn rename_input_id() -> iced::widget::Id { iced::widget::Id::new("scale-rename-input") } -const INPUT_BG: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const CURRENT_CHECK: Color = Color { - r: 0.30, - g: 0.82, - b: 0.36, - a: 1.0, -}; - -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(INPUT_BG), + background: Background::Color(palette.background.base.color), border: Border { - color: BORDER, + color: border, width: 1.0, radius: 4.0.into(), }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: Color { - r: 0.20, - g: 0.46, - b: 0.80, - a: 0.45, - }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), } } @@ -80,8 +63,10 @@ pub fn view_window<'a, 'b>( .spacing(4) .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -105,21 +90,24 @@ pub fn view_window<'a, 'b>( } let is_sel = name.as_str() == selected; let is_cur = name.eq_ignore_ascii_case(current); - let check = crate::ui::icons::check_cell(is_cur, CURRENT_CHECK); + let check = crate::ui::icons::themed_check_cell(is_cur); let label = row![ check, - text(name.clone()).size(11).color(TEXT).width(Fill), - text(ratio.clone()).size(10).color(DIM), + text(name.clone()).size(11).width(Fill), + text(ratio.clone()).size(10).style(muted_text_style), ] .spacing(4) .align_y(iced::Center); let cell = container(label) .padding([4, 8]) .width(Fill) - .style(move |_: &Theme| container::Style { - background: is_sel.then_some(Background::Color(ACTIVE)), - text_color: Some(TEXT), + .style(move |theme: &Theme| { + let pair = theme.extended_palette().primary.strong; + container::Style { + background: is_sel.then_some(Background::Color(pair.color)), + text_color: is_sel.then_some(pair.text), ..Default::default() + } }); mouse_area(cell) .on_press(Message::ScaleManagerSelect(name.clone())) @@ -130,16 +118,19 @@ pub fn view_window<'a, 'b>( let list_panel = container( column![ - text("Scales").size(10).color(DIM), + text("Scales").size(10).style(muted_text_style), container(scrollable(column(rows).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -161,7 +152,7 @@ pub fn view_window<'a, 'b>( let field = |label: &'static str, ph: &'static str, value: &str, on: fn(String) -> Message| { row![ - text(label).size(11).color(DIM).width(96), + text(label).size(11).style(muted_text_style).width(96), text_input(ph, value) .on_input(on) .style(field_style) @@ -175,13 +166,13 @@ pub fn view_window<'a, 'b>( let editor = container( column![ - text("Scale").size(10).color(DIM), + text("Scale").size(10).style(muted_text_style), field("Paper units", "1", paper_buf, Message::ScaleManagerPaperBuf), field("Drawing units", "50", drawing_buf, Message::ScaleManagerDrawingBuf), Space::new().height(6), text("Double-click a scale to rename it; edit its paper : drawing ratio here. New / Copy add a scale. Changes are kept only if you click Apply before closing.") .size(10) - .color(DIM), + .style(muted_text_style), ] .spacing(8), ) @@ -192,8 +183,10 @@ pub fn view_window<'a, 'b>( let body = row![list_panel, vsep(), editor].height(Fill); container(column![toolbar, hdivider(), body]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/style/style_list.rs b/src/ui/style/style_list.rs index 3e0ee8198..14ab3ae98 100644 --- a/src/ui/style/style_list.rs +++ b/src/ui/style/style_list.rs @@ -10,7 +10,7 @@ use crate::app::{Message, StyleKind}; use iced::widget::{container, mouse_area, row, text, text_input}; -use iced::{Background, Color, Element, Fill, Theme}; +use iced::{Background, Element, Fill, Theme}; /// Shared id for the inline rename field, so the rename-start handler can focus /// it the moment the row turns editable. @@ -18,25 +18,6 @@ pub fn rename_input_id() -> iced::widget::Id { iced::widget::Id::new("style-rename-input") } -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const CURRENT_CHECK: Color = Color { - r: 0.30, - g: 0.82, - b: 0.36, - a: 1.0, -}; - /// One row of the style list. Renders an editable `text_input` when `name` is /// the style being renamed (`rename_active`), otherwise a selectable row whose /// double click starts the rename. The current style gets a green βœ“. @@ -61,16 +42,19 @@ pub fn item<'a>( } else { // Fixed-width βœ“ column keeps every name left-aligned whether or not the // row is current. - let check = crate::ui::icons::check_cell(is_current, CURRENT_CHECK); - let label = row![check, text(name.to_string()).size(11).color(TEXT)] + let check = crate::ui::icons::themed_check_cell(is_current); + let label = row![check, text(name.to_string()).size(11)] .align_y(iced::Center); let cell = container(label) .padding([4, 8]) .width(Fill) - .style(move |_: &Theme| container::Style { - background: is_selected.then_some(Background::Color(ACTIVE)), - text_color: Some(TEXT), + .style(move |theme: &Theme| { + let pair = theme.extended_palette().primary.strong; + container::Style { + background: is_selected.then_some(Background::Color(pair.color)), + text_color: is_selected.then_some(pair.text), ..Default::default() + } }); mouse_area(cell) .on_press(on_select) diff --git a/src/ui/style/style_manager.rs b/src/ui/style/style_manager.rs index 8307028f3..499fe80d2 100644 --- a/src/ui/style/style_manager.rs +++ b/src/ui/style/style_manager.rs @@ -10,52 +10,7 @@ use crate::app::{Message, StyleKind}; use iced::widget::button::{Status, Style}; use iced::widget::{button, column, container, row, scrollable, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -// Shared palette β€” also reused by the scale manager so it matches the style -// managers exactly ([[feedback_shared_infra]]). -pub(crate) const TB: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -pub(crate) const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -pub(crate) const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -pub(crate) const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -pub(crate) const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -pub(crate) const LIST: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; /// Everything the shared frame needs. The per-manager `editor` element is the /// only bespoke part. @@ -103,8 +58,10 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> { .spacing(4) .align_y(iced::Center); let toolbar = container(bar) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -131,16 +88,19 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> { let list_panel = container( column![ - text("Styles").size(10).color(DIM), + text("Styles").size(10).style(muted_text_style), container(scrollable(column(rows).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -161,8 +121,10 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> { let body = row![list_panel, vsep(), s.editor].height(Fill); container(column![toolbar, hdivider(), body]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) @@ -182,35 +144,30 @@ pub(crate) fn tb_button<'a>(label: &'a str, msg: Message, accent: bool) -> Eleme } fn btn_s(accent: bool) -> impl Fn(&Theme, Status) -> Style { - move |_: &Theme, st| Style { - background: Some(Background::Color(match (accent, st) { - (true, Status::Hovered | Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, Status::Hovered | Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (accent, st) { + (true, Status::Hovered | Status::Pressed) => palette.primary.strong, + (false, Status::Hovered | Status::Pressed) => palette.background.strong, + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } + } +} + +pub(crate) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } @@ -218,8 +175,10 @@ pub(crate) fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -229,8 +188,10 @@ pub(crate) fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() diff --git a/src/ui/style/tablestyle.rs b/src/ui/style/tablestyle.rs index c5bbfc0bd..b67505244 100644 --- a/src/ui/style/tablestyle.rs +++ b/src/ui/style/tablestyle.rs @@ -4,63 +4,41 @@ use crate::app::Message; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column, }; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; +use iced::{Background, Border, Element, Fill, Theme}; fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (accent, st) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => { + palette.background.strong + } + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() + } + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), } } @@ -89,7 +67,7 @@ pub fn view_window<'a>( // ── Right: Details panel ────────────────────────────────────────────── let info_row = |label: &'static str, val: String| -> Element<'_, Message> { row![ - text(label).size(11).color(DIM).width(160), + text(label).size(11).style(muted_style).width(160), text(val).size(11), ] .spacing(8) @@ -108,7 +86,7 @@ pub fn view_window<'a>( field: &'static str| -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(150), + text(label).size(11).style(muted_style).width(150), text_input(placeholder, value) .on_input(move |v| Message::TableStyleCellEdit { row, @@ -142,14 +120,14 @@ pub fn view_window<'a>( Message::TableColorMore(row, field), Message::OpenColorWindow(crate::app::ColorPickTarget::Table(row, field)), ); - row![text(label).size(11).color(DIM).width(150), selector] + row![text(label).size(11).style(muted_style).width(150), selector] .spacing(8) .align_y(iced::Center) .into() }; let mut col = Column::new() .spacing(3) - .push(text(row_label).size(11).color(ACCENT)) + .push(text(row_label).size(11).style(primary_style)) .push(cell_in( " Text style:", "Standard", @@ -161,7 +139,7 @@ pub fn view_window<'a>( .push(cell_color(" Fill color:", &cell_fillcolor[r], "fillcolor")) .push( row![ - text(" Alignment:").size(11).color(DIM).width(150), + text(" Alignment:").size(11).style(muted_style).width(150), pick_list( [ "TopLeft", @@ -199,7 +177,7 @@ pub fn view_window<'a>( .push( text(" Borders (type / weight / color / spacing / hidden)") .size(10) - .color(DIM), + .style(muted_style), ); let borders: [(&'static str, &acadrust::objects::TableCellBorder); 6] = [ @@ -214,7 +192,7 @@ pub fn view_window<'a>( let bu = b as u8; col = col.push( row![ - text(format!(" {bname}")).size(11).color(DIM).width(28), + text(format!(" {bname}")).size(11).style(muted_style).width(28), pick_list( ["Single", "Double"] .iter() @@ -282,7 +260,7 @@ pub fn view_window<'a>( column![ info_row("Name:", s.name.clone()), row![ - text("Description:").size(11).color(DIM).width(160), + text("Description:").size(11).style(muted_style).width(160), text_input("", description_buf) .on_input(|v| Message::TableStyleEdit { field: "description", @@ -294,7 +272,7 @@ pub fn view_window<'a>( .spacing(8) .align_y(iced::Center), row![ - text("Flow direction:").size(11).color(DIM).width(160), + text("Flow direction:").size(11).style(muted_style).width(160), pick_list( ["Down", "Up"] .iter() @@ -314,7 +292,7 @@ pub fn view_window<'a>( .size(14) .text_size(11), row![ - text("H Margin:").size(11).color(DIM).width(160), + text("H Margin:").size(11).style(muted_style).width(160), text_input("1.5", hmargin_buf) .on_input(|v| Message::TableStyleEdit { field: "hmargin", @@ -326,7 +304,7 @@ pub fn view_window<'a>( .spacing(8) .align_y(iced::Center), row![ - text("V Margin:").size(11).color(DIM).width(160), + text("V Margin:").size(11).style(muted_style).width(160), text_input("1.5", vmargin_buf) .on_input(|v| Message::TableStyleEdit { field: "vmargin", @@ -362,7 +340,7 @@ pub fn view_window<'a>( .height(Fill) .into() } else { - container(text("Select a style to view details.").size(11).color(DIM)) + container(text("Select a style to view details.").size(11).style(muted_style)) .padding([12, 12]) .into() }; diff --git a/src/ui/style/textstyle.rs b/src/ui/style/textstyle.rs index 16a3cbab2..9983f6fdb 100644 --- a/src/ui/style/textstyle.rs +++ b/src/ui/style/textstyle.rs @@ -5,7 +5,7 @@ use crate::app::StyleKind; use iced::widget::{ button, canvas, checkbox, column, container, row, scrollable, text, text_input, Space, }; -use iced::{mouse, Background, Border, Color, Element, Fill, Length, Point, Rectangle, Theme}; +use iced::{mouse, Background, Border, Element, Fill, Length, Point, Rectangle, Theme}; /// View-model for the Text Style editor window. pub struct TextStyleView<'a> { @@ -28,49 +28,6 @@ pub struct TextStyleView<'a> { pub rename_buf: &'a str, } -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const FIELD: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; -const LIST: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; - const BUILTIN_FONTS: &[&str] = &[ "Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC", "ItalicT", "ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC", "Symbol", @@ -78,34 +35,52 @@ const BUILTIN_FONTS: &[&str] = &[ ]; fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (active, st) { - (true, _) => ACTIVE, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - text_color: TEXT, + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (active, st) { + (true, _) => Some(palette.primary.strong), + (false, button::Status::Hovered | button::Status::Pressed) => { + Some(palette.background.strong) + } + _ => None, + }; + button::Style { + background: pair.map(|p| Background::Color(p.color)), + text_color: pair.map(|p| p.text).unwrap_or(palette.background.base.text), ..Default::default() + } } } -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(FIELD), + background: Background::Color(palette.background.base.color), border: Border { - color: BORDER, + color: border, width: 1.0, radius: 3.0.into(), }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), } } @@ -113,8 +88,10 @@ fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -143,7 +120,7 @@ impl canvas::Program for TextPreviewCanvas { &self, _state: &(), renderer: &iced::Renderer, - _theme: &Theme, + theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { @@ -185,7 +162,7 @@ impl canvas::Program for TextPreviewCanvas { }; let stroke = canvas::Stroke { width: 1.4, - style: canvas::Style::Solid(TEXT), + style: canvas::Style::Solid(theme.extended_palette().background.base.text), ..Default::default() }; for s in &strokes { @@ -237,16 +214,19 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> { let font_panel = container( column![ - text("Font File").size(10).color(DIM), + text("Font File").size(10).style(muted_style), container(scrollable(column(font_items).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -275,7 +255,7 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> { field: &'static str, ) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(120), + text(label).size(11).style(muted_style).width(120), text_input(ph, buf) .on_input(move |v| Message::TextStyleEdit { field, value: v }) .style(field_style) @@ -312,7 +292,7 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> { // ── Right: Properties ───────────────────────────────────────────────── let props_panel = container( column![ - text("Properties").size(11).color(ACCENT), + text("Properties").size(11).style(primary_style), frow("Big Font:", "big-font file…", bigfont_buf, "bigfont"), frow("TrueType Font:", "e.g. Arial", ttf_buf, "ttf"), frow("Fixed Height:", "0 = variable", height_buf, "height"), @@ -337,16 +317,19 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> { .size(15) .text_size(11), Space::new().height(8), - text("Preview").size(10).color(DIM), + text("Preview").size(10).style(muted_style), container(preview) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(FIELD)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 4.0.into() }, ..Default::default() + } }) .padding(8) .width(Fill), @@ -382,16 +365,19 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> { let ttf_panel = container( column![ - text("TrueType (system)").size(10).color(DIM), + text("TrueType (system)").size(10).style(muted_style), container(scrollable(column(ttf_items).spacing(1)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into() }, ..Default::default() + } }) .width(Fill) .height(Fill) diff --git a/src/ui/window/about.rs b/src/ui/window/about.rs index ce170cb3e..53c3d4313 100644 --- a/src/ui/window/about.rs +++ b/src/ui/window/about.rs @@ -1,48 +1,16 @@ use crate::app::Message; use iced::widget::{button, column, container, row, text}; -use iced::{Background, Border, Color, Element, Theme}; - -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const WHITE: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; +use iced::{Background, Element, Theme}; fn info_row<'a>(label: &'static str, value: String) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(100), - text(value).size(11).color(WHITE), + text(label) + .size(11) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + }) + .width(100), + text(value).size(11), ] .spacing(8) .align_y(iced::Center) @@ -57,10 +25,16 @@ pub fn view_window<'a>() -> Element<'a, Message> { let logo = container( column![ - text("Open CAD Studio").size(32).color(ACCENT), + text("Open CAD Studio") + .size(32) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + }), text("CAD application for Architecture & Engineering") .size(11) - .color(DIM), + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + }), ] .spacing(4) .align_x(iced::Center), @@ -82,35 +56,11 @@ pub fn view_window<'a>() -> Element<'a, Message> { .spacing(2) .padding([12, 16]), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL)), - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }); + .style(container::bordered_box); let copy_btn = button(text("Copy Info").size(11)) .on_press(Message::AboutCopyInfo) - .style(|_: &Theme, st| button::Style { - background: Some(Background::Color(match st { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - _ => ACCENT, - })), - text_color: WHITE, - border: Border { - radius: 4.0.into(), - ..Default::default() - }, - ..Default::default() - }) + .style(button::primary) .padding([6, 16]); let footer = row![copy_btn] @@ -132,8 +82,10 @@ pub fn view_window<'a>() -> Element<'a, Message> { left: 20.0, }), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .into() diff --git a/src/ui/window/alias_editor.rs b/src/ui/window/alias_editor.rs index 6309beceb..6f453802e 100644 --- a/src/ui/window/alias_editor.rs +++ b/src/ui/window/alias_editor.rs @@ -6,7 +6,7 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; -use iced::{Background, Border, Color, Element, Fill, Length, Theme}; +use iced::{Background, Element, Fill, Length, Theme}; /// Which column of an alias row a text edit targets. #[derive(Clone, Copy, Debug)] @@ -18,33 +18,21 @@ pub enum AliasField { /// Right-hand lane reserved for the scrollbar so it never overlaps the βœ• column. const GUTTER: f32 = 16.0; -const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; -const FIELD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; -const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; -const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; -const WHITE: Color = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; -const ADD_C: Color = Color { r: 0.20, g: 0.40, b: 0.62, a: 1.0 }; - -fn field_style(_: &Theme, _s: text_input::Status) -> text_input::Style { - text_input::Style { - background: Background::Color(FIELD_BG), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - icon: WHITE, - placeholder: DIM, - value: WHITE, - selection: Color { r: 0.25, g: 0.40, b: 0.60, a: 1.0 }, +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } /// Build the alias editor content. `rows` is the live working buffer. pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> { - let title = text("Command Aliases").size(15).color(WHITE); + let title = text("Command Aliases").size(15); let hint = text( "Type an alias and the command it runs (e.g. L β†’ LINE). \ Apply to save to ocad.pgp; closing discards unapplied edits.", ) .size(11) - .color(DIM); + .style(muted_style); // Right gutter reserved so the scrollbar has its own lane and never sits on // top of the row delete (βœ•) buttons. Applied to both the header and the @@ -53,8 +41,8 @@ pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> { let head = container( row![ - container(text("Alias").size(11).color(DIM)).width(Length::Fixed(120.0)), - container(text("Command").size(11).color(DIM)).width(Fill), + container(text("Alias").size(11).style(muted_style)).width(Length::Fixed(120.0)), + container(text("Command").size(11).style(muted_style)).width(Fill), Space::new().width(Length::Fixed(30.0)), ] .spacing(8), @@ -65,31 +53,18 @@ pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> { for (idx, (alias, cmd)) in rows.iter().enumerate() { let alias_box = text_input("alias", alias) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Alias, value: v }) - .style(field_style) .size(13) .padding([3, 6]) .width(Length::Fixed(120.0)); let cmd_box = text_input("command", cmd) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Command, value: v }) - .style(field_style) .size(13) .padding([3, 6]) .width(Fill); - let del = button(crate::ui::icons::tinted(crate::ui::icons::CLOSE, 12.0, WHITE)) + let del = button(crate::ui::icons::themed_danger(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::AliasEditorRemove(idx)) .padding([2, 6]) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered) { - Color { r: 0.45, g: 0.22, b: 0.22, a: 1.0 } - } else { - Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 } - }; - button::Style { - background: Some(Background::Color(bg)), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - ..Default::default() - } - }); + .style(button::danger); list = list.push( row![alias_box, cmd_box, del] .spacing(8) @@ -97,40 +72,16 @@ pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> { ); } - let add = button(text("+ Add alias").size(12).color(WHITE)) + let add = button(text("+ Add alias").size(12)) .on_press(Message::AliasEditorAdd) .padding([4, 10]) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered) { - Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 } - } else { - Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 } - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - ..Default::default() - } - }); + .style(button::secondary); // Apply β€” primary action; commits the rows to ocad.pgp and stays open. - let apply = button(text("Apply").size(12).color(WHITE)) + let apply = button(text("Apply").size(12)) .on_press(Message::AliasEditorApply) .padding([4, 16]) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.24, g: 0.46, b: 0.74, a: 1.0 } - } else { - ADD_C - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }); + .style(button::primary); container( column![ @@ -149,8 +100,10 @@ pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> { .padding(12) .width(Fill) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .into() diff --git a/src/ui/window/attribute_editor.rs b/src/ui/window/attribute_editor.rs index 5c2efae05..ba3c1915c 100644 --- a/src/ui/window/attribute_editor.rs +++ b/src/ui/window/attribute_editor.rs @@ -19,23 +19,10 @@ use acadrust::types::{Color as AcadColor, LineWeight}; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, }; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::{Background, Border, Element, Length, Theme}; use crate::ui::properties::{lw_options, LwItem}; -// Palette shared with the style-manager windows so the editor reads as one of -// them: toolbar (TB) on top, BG panels, ACTIVE for the selected tab / row. -const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; -const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; -const WHITE: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; -const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; -const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 }; -const FIELD_BG: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }; -const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; -const ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; -const ROW_SEL: Color = ACTIVE; -const ROW_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; - const LABEL_W: f32 = 120.0; /// Which tab of the editor is showing. @@ -146,28 +133,25 @@ pub fn color_from_label(label: &str) -> Option { }) } -fn field_style(_t: &Theme, _s: text_input::Status) -> text_input::Style { - text_input::Style { - background: Background::Color(FIELD_BG), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - icon: WHITE, - placeholder: DIM, - value: WHITE, - selection: ACCENT, +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } -fn accent_btn(_t: &Theme, status: button::Status) -> button::Style { - let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 } - } else { - ACCENT +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, - ..Default::default() + text_input::Style { + background: Background::Color(palette.background.base.color), + border: Border { color: border, width: 1.0, radius: 3.0.into() }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), } } @@ -176,8 +160,10 @@ fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Length::Fill).height(1)) .width(Length::Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -186,7 +172,7 @@ fn hdivider<'a>() -> Element<'a, Message> { /// One `label : widget` row with a fixed-width label column. fn field_row<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { row![ - container(text(label).size(12).color(DIM)).width(LABEL_W), + container(text(label).size(12).style(muted_style)).width(LABEL_W), widget, ] .spacing(8) @@ -226,20 +212,28 @@ fn pick_field<'a>( fn tab_button<'a>(label: &'a str, this: AttrTab, active: AttrTab) -> Element<'a, Message> { let is_active = this == active; - button(text(label).size(11).color(WHITE)) + button(text(label).size(11)) .padding([4, 12]) .on_press(Message::AttrEditorTab(this)) - .style(move |_t: &Theme, status| button::Style { - background: Some(Background::Color(match (is_active, status) { - (true, _) => ACTIVE, + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match (is_active, status) { + (true, _) => palette.primary.strong, (false, button::Status::Hovered | button::Status::Pressed) => { - Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } + palette.background.strong } - _ => Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 }, - })), - text_color: WHITE, - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, ..Default::default() + } }) .into() } @@ -259,20 +253,22 @@ pub fn view_window<'a>( ) -> Element<'a, Message> { // ── Top toolbar: block name on the left, Apply on the right ─────────── // Mirrors the style-manager windows (actions left, primary action right). - let apply = button(text("Apply").size(11).color(WHITE)) + let apply = button(text("Apply").size(11)) .padding([4, 14]) .on_press(Message::AttrEditorApply) - .style(accent_btn); + .style(button::primary); let toolbar = container( row![ - text(format!("Block: {block}")).size(12).color(DIM), + text(format!("Block: {block}")).size(12).style(muted_style), Space::new().width(Length::Fill), apply, ] .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Length::Fill) @@ -286,7 +282,7 @@ pub fn view_window<'a>( .spacing(2); let body: Element<'_, Message> = if rows.is_empty() { - text("This block has no attributes.").size(13).color(DIM).into() + text("This block has no attributes.").size(13).style(muted_style).into() } else { match tab { AttrTab::Attribute => attribute_tab(rows, selected), @@ -303,8 +299,10 @@ pub fn view_window<'a>( .padding(12); container(column![toolbar, hdivider(), content]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Length::Fill) @@ -315,9 +313,9 @@ pub fn view_window<'a>( /// Attribute tab: tag / prompt / value list with row-select, plus a value box. fn attribute_tab<'a>(rows: &'a [AttrRow], selected: usize) -> Element<'a, Message> { let head = row![ - container(text("Tag").size(11).color(DIM)).width(130), - container(text("Prompt").size(11).color(DIM)).width(Length::Fill), - container(text("Value").size(11).color(DIM)).width(140), + container(text("Tag").size(11).style(muted_style)).width(130), + container(text("Prompt").size(11).style(muted_style)).width(Length::Fill), + container(text("Value").size(11).style(muted_style)).width(140), ] .spacing(6); @@ -325,27 +323,28 @@ fn attribute_tab<'a>(rows: &'a [AttrRow], selected: usize) -> Element<'a, Messag for (idx, r) in rows.iter().enumerate() { let is_sel = idx == selected; let line = row![ - container(text(r.tag.as_str()).size(12).color(WHITE)).width(130), - container(text(r.prompt.as_str()).size(12).color(DIM)).width(Length::Fill), - container(text(r.value.as_str()).size(12).color(WHITE)).width(140), + container(text(r.tag.as_str()).size(12)).width(130), + container(text(r.prompt.as_str()).size(12).style(muted_style)).width(Length::Fill), + container(text(r.value.as_str()).size(12)).width(140), ] .spacing(6); let btn = button(line) .on_press(Message::AttrEditorSelect(idx)) .padding([3, 4]) .width(Length::Fill) - .style(move |_t: &Theme, status| { + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); let hovered = matches!(status, button::Status::Hovered); - let bg = if is_sel { - ROW_SEL + let pair = if is_sel { + palette.primary.strong } else if hovered { - Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0 } + palette.background.strong } else { - ROW_BG + palette.background.weak }; button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, + background: Some(Background::Color(pair.color)), + text_color: pair.text, border: Border::default(), ..Default::default() } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index 1fcdc27ba..40583c844 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -47,6 +47,35 @@ const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; /// scrolled into view after it is added (#271). pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll"; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn table_input_style( + theme: &Theme, + status: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + iced::widget::text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; + iced::widget::text_input::Style { + background: Background::Color(palette.background.base.color), + border: Border { + color: border, + width: 1.0, + radius: 2.0.into(), + }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + // ── Layer data ──────────────────────────────────────────────────────────── #[derive(Clone, Debug)] @@ -313,39 +342,15 @@ impl LayerPanel { .size(FONT_SZ) .padding([3, 6]) .width(Length::Fixed(180.0)) - .style(|_: &Theme, _| iced::widget::text_input::Style { - background: Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, - }), - border: Border { - color: BORDER_COLOR, - width: 1.0, - radius: 2.0.into(), - }, - icon: Color::WHITE, - placeholder: Color { - r: 0.45, - g: 0.45, - b: 0.45, - a: 1.0, - }, - value: Color::WHITE, - selection: Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.5, - }, - }), + .style(table_input_style), ] .spacing(2) .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TOOLBAR_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -355,13 +360,15 @@ impl LayerPanel { let sc = self.sort_col; let sa = self.sort_asc; let mut header_row = row![ - text("Status").size(10).color(DIM).width(50), + text("Status").size(10).style(muted_style).width(50), sortable_header("Name", LayerSortCol::Name, Length::Fixed(name_col_w), sc, sa), // Draggable divider: adjusts the Name column width (#359). iced::widget::mouse_area( container(iced::widget::Space::new().width(2).height(14)).style( - |_: &Theme| container::Style { - background: Some(Background::Color(BORDER_COLOR)), + |theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }, ), @@ -390,20 +397,23 @@ impl LayerPanel { header_row = header_row.push( text(vp.label.as_str()) .size(10) - .color(DIM) + .style(muted_style) .width(Length::Fixed(COL_ICON)), ); } let col_header = container(header_row) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(COL_HEADER_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() + } }) .padding([4, 8]) .width(Fill); @@ -451,8 +461,10 @@ impl LayerPanel { // ── Full-window frame ───────────────────────────────────────────── container(column![toolbar, col_header, table].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) @@ -463,6 +475,48 @@ impl LayerPanel { // ── Sorting helpers ───────────────────────────────────────────────────────── +fn layer_cell_button_style( + theme: &Theme, + status: button::Status, + is_selected: bool, + index: usize, +) -> button::Style { + let palette = theme.extended_palette(); + let highlighted = matches!(status, button::Status::Hovered); + let pair = if highlighted { + palette.background.strong + } else if is_selected { + palette.primary.weak + } else if index % 2 == 0 { + palette.background.base + } else { + palette.background.weak + }; + button::Style { + background: highlighted.then_some(Background::Color(pair.color)), + text_color: pair.text, + ..Default::default() + } +} + +fn layer_header_button_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let highlighted = matches!( + status, + button::Status::Hovered | button::Status::Pressed + ); + let pair = if highlighted { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: highlighted.then_some(Background::Color(pair.color)), + text_color: pair.text, + ..Default::default() + } +} + /// Packed RGB key for ordering colours deterministically by hue-ish bytes. fn color_sort_key(c: Color) -> u32 { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32; @@ -478,30 +532,19 @@ fn sortable_header<'a>( active: Option, asc: bool, ) -> Element<'a, Message> { - let mut content = row![text(label).size(10).color(DIM)] + let mut content = row![text(label).size(10).style(muted_style)] .spacing(3) .align_y(iced::Center); if active == Some(col) { content = content.push(if asc { - crate::ui::icons::arrow_up(8.0, DIM) + crate::ui::icons::themed_arrow_up(8.0) } else { - crate::ui::icons::arrow_down(8.0, DIM) + crate::ui::icons::themed_arrow_down(8.0) }); } button(content) .on_press(Message::LayerSort(col)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) + .style(layer_header_button_style) .padding(Padding { top: 0.0, bottom: 0.0, @@ -517,41 +560,31 @@ fn sortable_header<'a>( fn toolbar_btn<'a>(icon: &'static [u8], label: &'a str, msg: Message) -> Element<'a, Message> { button( row![ - crate::ui::icons::tinted(icon, 12.0, Color::WHITE), - text(label).size(11).color(Color::WHITE), + crate::ui::icons::themed(icon, 12.0), + text(label).size(11), ] .spacing(5) .align_y(iced::Center), ) .on_press(msg) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, - }, - button::Status::Pressed => Color { - r: 0.25, - g: 0.25, - b: 0.25, - a: 1.0, - }, - _ => Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, - }, - })), + .style(|theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), border: Border { radius: 3.0.into(), - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, }, - text_color: Color::WHITE, + text_color: pair.text, ..Default::default() + } }) .padding([4, 10]) .into() @@ -563,46 +596,46 @@ fn toolbar_btn_cond<'a>( msg: Message, enabled: bool, ) -> Element<'a, Message> { - let fg = if enabled { - Color::WHITE - } else { - Color { - r: 0.45, - g: 0.45, - b: 0.45, - a: 1.0, - } - }; let mut b = button( row![ - crate::ui::icons::tinted(icon, 12.0, fg), - text(label).size(11).color(fg), + if enabled { + crate::ui::icons::themed(icon, 12.0) + } else { + crate::ui::icons::themed_disabled(icon, 12.0) + }, + if enabled { + text(label).size(11) + } else { + text(label).size(11).style(|theme: &Theme| iced::widget::text::Style { + color: Some( + theme.extended_palette().background.base.text.scale_alpha(0.42) + ), + }) + }, ] .spacing(5) .align_y(iced::Center), ) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, - }, - _ => Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, - }, - })), + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let pair = match status { + button::Status::Hovered if enabled => palette.background.strong, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), border: Border { radius: 3.0.into(), - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, }, - text_color: Color::WHITE, + text_color: if enabled { + pair.text + } else { + pair.text.scale_alpha(0.42) + }, ..Default::default() + } }) .padding([4, 10]); if enabled { @@ -616,21 +649,25 @@ fn toolbar_btn_cond<'a>( #[allow(clippy::too_many_arguments)] /// Hover popup showing a layer's full name when the cell truncates it. fn name_tip<'a>(name: &'a str) -> Element<'a, Message> { - container(text(name).size(FONT_SZ).color(ROW_TEXT)) + container(text(name).size(FONT_SZ)) .padding(Padding { top: 3.0, bottom: 3.0, left: 7.0, right: 7.0, }) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.strong.color)), border: Border { - color: BORDER_COLOR, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, + text_color: Some(palette.background.strong.text), ..Default::default() + } }) .into() } @@ -649,23 +686,10 @@ fn layer_row<'a>( name_col_w: f32, ) -> Element<'a, Message> { let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> { - button( - iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) - .width(ICON_SZ) - .height(ICON_SZ), - ) + button(crate::ui::icons::semantic(bytes, ICON_SZ)) .on_press(on_press) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - ..Default::default() + .style(move |theme: &Theme, status| { + layer_cell_button_style(theme, status, is_selected, index) }) .padding(Padding { top: COMBO_PAD_V, @@ -682,27 +706,9 @@ fn layer_row<'a>( let lck_svg = crate::ui::icons::layer_lock(layer.locked); let status_dot: Element<'_, Message> = if is_current { - crate::ui::icons::tinted( - crate::ui::icons::CHECK, - 13.0, - Color { - r: 0.25, - g: 0.85, - b: 0.45, - a: 1.0, - }, - ) + crate::ui::icons::themed_success(crate::ui::icons::CHECK, 13.0) } else { - crate::ui::icons::tinted( - crate::ui::icons::DOT, - 9.0, - Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, - }, - ) + crate::ui::icons::themed_secondary(crate::ui::icons::DOT, 9.0) }; // Name cell @@ -717,38 +723,7 @@ fn layer_row<'a>( left: 4.0, right: 4.0, }) - .style(|_: &Theme, _| iced::widget::text_input::Style { - background: iced::Background::Color(Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, - }), - border: Border { - radius: 2.0.into(), - width: 1.0, - color: Color { - r: 0.45, - g: 0.65, - b: 0.90, - a: 1.0, - }, - }, - icon: Color::WHITE, - placeholder: Color { - r: 0.4, - g: 0.4, - b: 0.4, - a: 1.0, - }, - value: Color::WHITE, - selection: Color { - r: 0.25, - g: 0.45, - b: 0.75, - a: 0.5, - }, - }) + .style(table_input_style) .width(Length::Fixed(name_col_w)) .into() } else { @@ -756,21 +731,11 @@ fn layer_row<'a>( let name_budget = ((name_col_w / 6.0) as usize).max(8); let name_btn = button( text(crate::ui::text_util::elide(&layer.name, name_budget)) - .size(FONT_SZ) - .color(ROW_TEXT), + .size(FONT_SZ), ) .on_press(Message::LayerRenameStart(index)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - ..Default::default() + .style(move |theme: &Theme, status| { + layer_cell_button_style(theme, status, is_selected, index) }) .padding(Padding { top: COMBO_PAD_V, @@ -837,7 +802,7 @@ fn layer_row<'a>( } else { text(layer.linetype.as_str()) .size(FONT_SZ) - .color(DIM) + .style(muted_style) .width(Length::Fixed(COL_LT)) .into() }; @@ -861,7 +826,7 @@ fn layer_row<'a>( } else { text(cur_lw_item.to_string()) .size(FONT_SZ) - .color(DIM) + .style(muted_style) .width(Length::Fixed(COL_LW)) .into() }; @@ -877,33 +842,13 @@ fn layer_row<'a>( left: 4.0, right: 4.0, }) - .style(|_: &Theme, _| iced::widget::text_input::Style { - background: iced::Background::Color(Color::TRANSPARENT), - border: Border { - radius: 2.0.into(), - width: 1.0, - color: BORDER_COLOR, - }, - icon: Color::WHITE, - placeholder: DIM, - value: ROW_TEXT, - selection: Color { - r: 0.25, - g: 0.45, - b: 0.75, - a: 0.5, - }, + .style(|theme: &Theme, status| { + let mut style = table_input_style(theme, status); + style.background = iced::Background::Color(Color::TRANSPARENT); + style }) .width(Length::Fixed(COL_TRANS)); - let bg = if is_selected { - ROW_SEL - } else if index % 2 == 0 { - ROW_EVEN - } else { - ROW_ODD - }; - let mut row_content = row![ container(status_dot) .width(50) @@ -945,9 +890,20 @@ fn layer_row<'a>( mouse_area( container(row_content) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + let pair = if is_selected { + palette.primary.weak + } else if index % 2 == 0 { + palette.background.base + } else { + palette.background.weak + }; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), ..Default::default() + } }) .padding(Padding { top: 0.0, @@ -965,31 +921,10 @@ fn layer_row<'a>( // ── Combo style ──────────────────────────────────────────────────────────── fn combo_input_style( - _theme: &Theme, - _status: iced::widget::text_input::Status, + theme: &Theme, + status: iced::widget::text_input::Status, ) -> iced::widget::text_input::Style { - iced::widget::text_input::Style { - background: iced::Background::Color(Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, - }), - border: Border { - radius: 2.0.into(), - width: 1.0, - color: BORDER_COLOR, - }, - icon: Color::WHITE, - placeholder: DIM, - value: Color::WHITE, - selection: Color { - r: 0.25, - g: 0.45, - b: 0.75, - a: 0.5, - }, - } + table_input_style(theme, status) } // ── Display helpers ─────────────────────────────────────────────────────── @@ -1054,67 +989,3 @@ const COL_COLOR: f32 = 90.0; const COL_LT: f32 = 110.0; const COL_LW: f32 = 90.0; const COL_TRANS: f32 = 80.0; - -// ── Colors ──────────────────────────────────────────────────────────────── - -const PANEL_BG: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -const TOOLBAR_BG: Color = Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, -}; -const COL_HEADER_BG: Color = Color { - r: 0.21, - g: 0.21, - b: 0.21, - a: 1.0, -}; -const ROW_EVEN: Color = Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, -}; -const ROW_ODD: Color = Color { - r: 0.21, - g: 0.21, - b: 0.21, - a: 1.0, -}; -const ROW_SEL: Color = Color { - r: 0.18, - g: 0.32, - b: 0.52, - a: 1.0, -}; -const ROW_TEXT: Color = Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.50, - g: 0.50, - b: 0.50, - a: 1.0, -}; -const BORDER_COLOR: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, -}; -#[allow(dead_code)] -const ICON_COLOR: Color = Color { - r: 0.80, - g: 0.80, - b: 0.80, - a: 1.0, -}; diff --git a/src/ui/window/layout_manager.rs b/src/ui/window/layout_manager.rs index 1c2316aa2..28a616dc8 100644 --- a/src/ui/window/layout_manager.rs +++ b/src/ui/window/layout_manager.rs @@ -2,131 +2,37 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Element, Fill, Theme}; -const TB: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const TEXT: Color = Color { - r: 0.88, - g: 0.88, - b: 0.88, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const FIELD: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; -const LIST: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const WARN: Color = Color { - r: 0.80, - g: 0.35, - b: 0.25, - a: 1.0, -}; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + } +} fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + move |theme: &Theme, status| { + if accent { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } } } fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (active, st) { - (true, _) => ACTIVE, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.26, - g: 0.26, - b: 0.26, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - text_color: TEXT, - ..Default::default() - } -} - -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { - text_input::Style { - background: Background::Color(FIELD), - border: Border { - color: BORDER, - width: 1.0, - radius: 3.0.into(), - }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, + move |theme: &Theme, status| { + if active { + button::primary(theme, status) + } else { + button::subtle(theme, status) + } } } @@ -134,8 +40,10 @@ fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color, + )), ..Default::default() }) .into() @@ -145,8 +53,10 @@ fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color, + )), ..Default::default() }) .into() @@ -169,34 +79,18 @@ pub fn view_window<'a>( .padding([4, 10]), button(text("Delete").size(11)) .on_press(Message::LayoutManagerDelete) - .style(move |_: &Theme, st| button::Style { - background: Some(Background::Color(match st { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.60, - g: 0.20, - b: 0.18, - a: 1.0 - }, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0 - }, - })), - text_color: if is_model { DIM } else { WARN }, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into() - }, - ..Default::default() + .style(move |theme: &Theme, status| { + if is_model { + button::secondary(theme, status) + } else { + button::danger(theme, status) + } }) .padding([4, 10]), Space::new().width(Fill), button( row![ - crate::ui::icons::tinted(crate::ui::icons::TRI_LEFT_B, 9.0, Color::WHITE), + crate::ui::icons::themed_arrow_left(9.0), text("Move Left").size(11), ] .spacing(4) @@ -208,7 +102,7 @@ pub fn view_window<'a>( button( row![ text("Move Right").size(11), - crate::ui::icons::arrow_right(9.0, Color::WHITE), + crate::ui::icons::themed_arrow_right(9.0), ] .spacing(4) .align_y(iced::Center), @@ -224,8 +118,10 @@ pub fn view_window<'a>( .spacing(4) .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), ..Default::default() }) .width(Fill) @@ -241,11 +137,7 @@ pub fn view_window<'a>( .spacing(5) .align_y(iced::Center); if is_cur { - item_row = item_row.push(crate::ui::icons::tinted( - crate::ui::icons::TRI_LEFT_B, - 8.0, - Color::WHITE, - )); + item_row = item_row.push(crate::ui::icons::themed_arrow_left(8.0)); } button(item_row) .on_press(Message::LayoutManagerSelect(name.clone())) @@ -258,17 +150,9 @@ pub fn view_window<'a>( let layout_list = container( column![ - text("Layouts").size(10).color(DIM), + text("Layouts").size(10).style(muted_style), container(scrollable(column(list_items).spacing(2)).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), - border: Border { - color: BORDER, - width: 1.0, - radius: 3.0.into() - }, - ..Default::default() - }) + .style(container::bordered_box) .width(Fill) .height(Fill) .padding(2), @@ -293,38 +177,38 @@ pub fn view_window<'a>( } else { "Paper Space Layout" }) - .size(13) - .color(TEXT), + .size(13), Space::new().height(8), row![ - text("Name:").size(11).color(DIM).width(80), + text("Name:").size(11).style(muted_style).width(80), text(selected).size(11), ] .spacing(8) .align_y(iced::Center), row![ - text("Status:").size(11).color(DIM).width(80), + text("Status:").size(11).style(muted_style).width(80), text(if selected == current.as_str() { "Active" } else { "Inactive" }) .size(11) - .color(if selected == current.as_str() { - ACCENT - } else { - DIM + .style(move |theme: &Theme| { + if selected == current.as_str() { + primary_style(theme) + } else { + muted_style(theme) + } }), ] .spacing(8) .align_y(iced::Center), Space::new().height(16), - text("Rename").size(10).color(DIM), + text("Rename").size(10).style(muted_style), row![ text_input("New name…", rename_buf) .on_input(Message::LayoutManagerRenameBuf) .on_submit(Message::LayoutManagerRenameCommit) - .style(field_style) .size(11) .padding([4, 8]), button(text("OK").size(11)) @@ -343,8 +227,10 @@ pub fn view_window<'a>( let body = row![layout_list, vsep(), details].height(Fill); container(column![toolbar, hdivider(), body].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/window/open_progress.rs b/src/ui/window/open_progress.rs index 9bcf36aaa..661687eab 100644 --- a/src/ui/window/open_progress.rs +++ b/src/ui/window/open_progress.rs @@ -5,7 +5,7 @@ use iced::time::Instant; use iced::widget::{button, column, container, row, stack, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Length, Theme}; +use iced::{Background, Border, Element, Fill, Length, Theme}; use std::sync::atomic::Ordering; use crate::app::{ @@ -60,13 +60,10 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag .width(Length::Fixed(fill_width)) .height(Length::Fixed(BAR_TRACK_HEIGHT)), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.30, - g: 0.62, - b: 0.95, - a: 1.0, - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().primary.base.color + )), border: Border { radius: 3.0.into(), ..Default::default() @@ -86,13 +83,10 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag let bar_track: Element<'_, Message> = container( stack![ container(Space::new().width(Length::Fixed(BAR_TRACK_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT))) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.strong.color + )), border: Border { radius: 3.0.into(), ..Default::default() @@ -107,9 +101,7 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag .into(); // ── Card body ──────────────────────────────────────────────────────── - let title = text("Opening file") - .size(15) - .color(Color::WHITE); + let title = text("Opening file").size(15); let name_line = text(format!( "{} ({})", @@ -117,11 +109,8 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag format_size(progress.size_bytes) )) .size(13) - .color(Color { - r: 0.82, - g: 0.82, - b: 0.82, - a: 1.0, + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.82)), }); let phase_line = text(format!( @@ -130,52 +119,13 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag basis_points as f32 / 100.0 )) .size(12) - .color(Color { - r: 0.70, - g: 0.80, - b: 0.95, - a: 1.0, + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), }); - let cancel_btn: Element<'_, Message> = button(text("Cancel").size(12).color(Color::WHITE)) + let cancel_btn: Element<'_, Message> = button(text("Cancel").size(12)) .on_press(Message::OpenCancel) - .style(|_: &Theme, status| { - let bg = match status { - button::Status::Hovered => Color { - r: 0.32, - g: 0.32, - b: 0.32, - a: 1.0, - }, - button::Status::Pressed => Color { - r: 0.42, - g: 0.18, - b: 0.18, - a: 1.0, - }, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - }; - button::Style { - background: Some(Background::Color(bg)), - border: Border { - color: Color { - r: 0.40, - g: 0.40, - b: 0.40, - a: 1.0, - }, - width: 1.0, - radius: 3.0.into(), - }, - text_color: Color::WHITE, - ..Default::default() - } - }) + .style(button::danger) .padding([4, 14]) .into(); @@ -187,35 +137,25 @@ pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Messag .width(Length::Fixed(CARD_WIDTH)), ) .padding([18, 22]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 0.98, - })), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: Color { - r: 0.45, - g: 0.45, - b: 0.45, - a: 1.0, - }, + color: palette.background.neutral.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() + } }); // ── Backdrop (click-blocker + dim) ──────────────────────────────────── let backdrop: Element<'_, Message> = container(Space::new().width(Fill).height(Fill)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.55, - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.strong.color.scale_alpha(0.72) + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 1e68316d8..15913622e 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1,96 +1,123 @@ +use crate::app::config::UiThemeConfig; use crate::app::Message; -use iced::widget::{button, column, container, pick_list, row, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::widget::{ + button, column, container, pick_list, row, scrollable, text, text_input, Space, +}; +use iced::{Background, Border, Element, Fill, Theme}; -pub fn view_window<'a>(default_save_format: &'a str) -> Element<'a, Message> { - const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.17, - a: 1.0, - }; - const BORDER: Color = Color { - r: 0.32, - g: 0.32, - b: 0.36, - a: 1.0, - }; - const TEXT: Color = Color { - r: 0.90, - g: 0.90, - b: 0.90, - a: 1.0, - }; - const DIM: Color = Color { - r: 0.60, - g: 0.60, - b: 0.64, - a: 1.0, - }; - - let selected = crate::io::SAVE_FORMAT_OPTIONS +pub fn view_window<'a>( + default_save_format: &'a str, + ui_theme: &'a UiThemeConfig, + theme_color_inputs: &'a [String; 6], +) -> Element<'a, Message> { + let selected_format = crate::io::SAVE_FORMAT_OPTIONS .iter() .copied() .find(|candidate| *candidate == default_save_format); - let close = button(text("Close").size(12).color(TEXT)) - .on_press(Message::CloseModal) - .padding([5, 16]) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.34, - g: 0.34, - b: 0.38, - a: 1.0, - }, - _ => Color { - r: 0.26, - g: 0.26, - b: 0.29, - a: 1.0, - }, - })), - text_color: TEXT, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }); + let theme_options = Theme::ALL + .iter() + .map(ToString::to_string) + .chain(std::iter::once("Custom".to_string())) + .collect::>(); + let selected_theme = Some(ui_theme.name.clone()); - let body = column![ - text("Open and Save").size(15).color(TEXT), - Space::new().height(16), + let palette = ui_theme.palette.to_iced(); + let colors = [ + ("Background", palette.background), + ("Text", palette.text), + ("Primary", palette.primary), + ("Success", palette.success), + ("Warning", palette.warning), + ("Danger", palette.danger), + ]; + + let mut color_controls = column![].spacing(8); + for (index, (label, color)) in colors.into_iter().enumerate() { + let swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(color)), + border: Border { + color: theme.extended_palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + color_controls = color_controls.push( + row![ + text(label).size(12).width(110), + swatch, + text_input("#RRGGBB", theme_color_inputs[index].as_str()) + .on_input(move |value| Message::OptionsThemeColorChanged(index, value)) + .width(130), + ] + .spacing(10) + .align_y(iced::Center), + ); + } + + let close = button(text("Close").size(12)) + .on_press(Message::CloseModal) + .padding([6, 18]) + .style(button::secondary); + + let content = column![ + text("Open and Save").size(15), + Space::new().height(10), row![ - text("Default save format:").size(12).color(TEXT).width(150), - pick_list(crate::io::SAVE_FORMAT_OPTIONS, selected, |format: &str| { - Message::DefaultSaveFormatChanged(format.to_string()) - }) + text("Default save format:").size(12).width(150), + pick_list( + crate::io::SAVE_FORMAT_OPTIONS, + selected_format, + |format: &str| Message::DefaultSaveFormatChanged(format.to_string()) + ) .width(Fill), ] .spacing(12) .align_y(iced::Center), - Space::new().height(10), + Space::new().height(8), text( - "Used when a new drawing is saved for the first time. Existing drawings keep their current file type and version." + "Used for the first save of a new drawing. Existing drawings keep their file type and version." ) - .size(11) - .color(DIM) - .width(Fill), - Space::new().height(Fill), - row![Space::new().width(Fill), close], + .size(11), + Space::new().height(22), + text("Theme").size(15), + Space::new().height(10), + row![ + text("Iced theme:").size(12).width(150), + pick_list( + theme_options, + selected_theme, + Message::OptionsThemeChanged, + ) + .width(Fill), + ] + .spacing(12) + .align_y(iced::Center), + Space::new().height(8), + text( + "Changing a base colour switches to Custom. Iced generates every component shade from these six colours." + ) + .size(11), + Space::new().height(12), + color_controls, ] .spacing(0) + .width(Fill); + + let body = column![ + scrollable(content).height(Fill), + Space::new().height(12), + row![Space::new().width(Fill), close], + ] .width(Fill) .height(Fill); container(body) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) + .style(container::rounded_box) .padding([16, 18]) .width(Fill) .height(Fill) diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index cf0d858c3..133fa54aa 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -10,17 +10,7 @@ use iced::widget::{ button, checkbox, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Space, }; -use iced::{Background, Border, Color, Element, Fill, Length, Theme}; - -const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; -const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; -const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; -const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; -const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; -const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 }; -const FIELD: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }; -const ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; -const LIST: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; +use iced::{Background, Border, Element, Fill, Length, Theme}; /// Sentinel entries in the printer dropdown (not real printer names). pub const OUT_DEFAULT: &str = "System default printer"; @@ -221,32 +211,49 @@ impl PlotDialogState { } fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => { - Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 } - } + move |theme: &Theme, st| { + let palette = theme.extended_palette(); + let pair = match (accent, st) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, (false, button::Status::Hovered | button::Status::Pressed) => { - Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } + palette.background.strong } - (true, _) => ACCENT, - _ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }, - })), - text_color: TEXT, - border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, shadow: iced::Shadow::default(), snap: false, + } } } -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { +fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.extended_palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; text_input::Style { - background: Background::Color(FIELD), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, + background: Background::Color(palette.background.base.color), + border: Border { color: border, width: 1.0, radius: 3.0.into() }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), } } @@ -254,23 +261,27 @@ fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() } fn section_label<'a>(s: &'static str) -> Element<'a, Message> { - text(s).size(11).color(DIM).into() + text(s).size(11).style(muted_style).into() } fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color + )), ..Default::default() }) .into() @@ -295,13 +306,16 @@ fn setup_row<'a>( .into(); } let is_sel = name == selected; - let cell = container(text(name.to_string()).size(11).color(TEXT)) + let cell = container(text(name.to_string()).size(11)) .padding([4, 8]) .width(Fill) - .style(move |_: &Theme| container::Style { - background: is_sel.then_some(Background::Color(ACTIVE)), - text_color: Some(TEXT), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: is_sel.then_some(Background::Color(palette.primary.strong.color)), + text_color: is_sel.then_some(palette.primary.strong.text), ..Default::default() + } }); mouse_area(cell) .on_press(Message::PlotDlg(PlotDlgMsg::SelectSetup(name.to_string()))) @@ -321,7 +335,7 @@ fn drop_row<'a>( .text_size(12) .padding([3, 6]) .width(Length::Fill); - row![text(label).size(11).color(DIM).width(92), pl] + row![text(label).size(11).style(muted_style).width(92), pl] .spacing(8) .align_y(iced::Center) .into() @@ -335,7 +349,7 @@ fn field_row<'a>( width: u16, ) -> Element<'a, Message> { row![ - text(label).size(11).color(DIM).width(92), + text(label).size(11).style(muted_style).width(92), text_input("", value) .on_input(move |s| Message::PlotDlg(ctor(s))) .style(field_style) @@ -413,8 +427,10 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { ] .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weak.color + )), ..Default::default() }) .width(Fill) @@ -445,7 +461,7 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { .map(|name| setup_row(name, &s.selected_setup, renaming, rename_buf)) .collect(); let list_body: Element<'_, Message> = if rows.is_empty() { - container(text("(no page setups)").size(11).color(DIM)) + container(text("(no page setups)").size(11).style(muted_style)) .padding([6, 8]) .into() } else { @@ -453,16 +469,19 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { }; let list_panel = container( column![ - text("Page setups").size(10).color(DIM), + text("Page setups").size(10).style(muted_style), container(list_body) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(LIST)), + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), border: Border { - color: BORDER, + color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() + } }) .width(Fill) .height(Fill) @@ -550,11 +569,18 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { hdivider(), section_label("Plot style table (pen assignments)"), row![ - container(text(style_label).size(12).color(TEXT)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(FIELD)), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + container(text(style_label).size(12)) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, ..Default::default() + } }) .padding([4, 8]) .width(Fill), @@ -583,7 +609,7 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { .width(Length::Fill) ) .width(Fill), - text("DPI").size(11).color(DIM), + text("DPI").size(11).style(muted_style), text_input("", &s.dpi) .on_input(move |v| Message::PlotDlg(PlotDlgMsg::Dpi(v))) .style(field_style) @@ -631,8 +657,10 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { let body = row![list_panel, vsep(), detail].height(Fill); container(column![toolbar, hdivider(), body].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index bd8e43d97..3f1b903e0 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -7,7 +7,7 @@ use crate::app::Message; use crate::plugin::external::{ExternalPlugin, RegistryEntry}; use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Border, Element, Fill, Theme}; use rustc_hash::{FxHashMap, FxHashSet}; /// Marketplace state passed to the Plugin Manager view. @@ -25,124 +25,105 @@ inventory::submit!(crate::command::CommandRegistration { names: &["PLUGINS", "PLUGINMANAGER"] }); -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const CARD: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.30, - g: 0.62, - b: 0.95, - a: 1.0, -}; -const WHITE: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + } +} fn badge<'a>(label: String) -> Element<'a, Message> { - container(text(label).size(11).color(WHITE)) + container(text(label).size(11)) .padding([2, 8]) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.20, - g: 0.34, - b: 0.52, - a: 1.0, - })), + .style(|theme: &Theme| { + let pair = theme.extended_palette().primary.weak; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() + } }) .into() } fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> { // Label shows the action the click performs. - let (label, on, off) = if disabled { - ("Enable", Color { r: 0.18, g: 0.5, b: 0.25, a: 1.0 }, Color { r: 0.22, g: 0.6, b: 0.3, a: 1.0 }) + let label = if disabled { + "Enable" } else { - ("Disable", Color { r: 0.4, g: 0.22, b: 0.22, a: 1.0 }, Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 }) + "Disable" }; let want_enabled = disabled; // clicking flips the state let id_owned = id.to_string(); - button(text(label).size(12).color(WHITE)) + button(text(label).size(12)) .padding([3, 12]) .on_press(Message::SetPluginEnabled(id_owned, want_enabled)) - .style(move |_: &Theme, status| { - let bg = match status { - button::Status::Hovered | button::Status::Pressed => off, - _ => on, - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }) + .style(if disabled { button::success } else { button::danger }) .into() } +#[derive(Clone, Copy)] +enum StatusKind { + Muted, + Success, + Danger, + Warning, +} + /// Coloured status pill for a discovered external package. -fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> { - container(text(label.to_string()).size(11).color(WHITE)) +fn status_badge<'a>(label: &str, kind: StatusKind) -> Element<'a, Message> { + container(text(label.to_string()).size(11)) .padding([2, 8]) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(color)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + let pair = match kind { + StatusKind::Muted => palette.background.weak, + StatusKind::Success => palette.success.weak, + StatusKind::Danger => palette.danger.weak, + StatusKind::Warning => palette.warning.weak, + }; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() + } }) .into() } fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Element<'a, Message> { - let (status, color) = if loaded && disabled { - ("Disabled", Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 }) + let (status, kind) = if loaded && disabled { + ("Disabled", StatusKind::Muted) } else if loaded { - ("Loaded", Color { r: 0.2, g: 0.5, b: 0.3, a: 1.0 }) + ("Loaded", StatusKind::Success) } else if !p.api_compatible() { - ("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 }) + ("API incompatible", StatusKind::Danger) } else if !p.lib_present { - ("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 }) + ("No library", StatusKind::Warning) } else { - ("Restart to load", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 }) + ("Restart to load", StatusKind::Warning) }; let mut header = row![ - text(p.name.clone()).size(15).color(WHITE), + text(p.name.clone()).size(15), Space::new().width(8), badge(format!("v{}", p.version)), Space::new().width(8), badge(format!("API {}", p.api_version)), Space::new().width(Fill), - status_badge(status, color), + status_badge(status, kind), ] .align_y(iced::Center); // A loaded plugin can be turned off (drops its ribbon tab + dispatch). @@ -154,75 +135,52 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Elemen header = header.push(pill_button( "Uninstall", Message::PluginUninstall(p.id.clone()), - Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 }, + button::danger, )); - let id_line = text(p.id.clone()).size(11).color(ACCENT); + let id_line = text(p.id.clone()).size(11).style(primary_style); let mut body = column![header, id_line].spacing(5); if !p.description.is_empty() { - body = body.push(text(p.description.clone()).size(12).color(DIM)); + body = body.push(text(p.description.clone()).size(12).style(muted_style)); } if !p.command_prefixes.is_empty() { body = body.push( text(format!("Commands: {}", p.command_prefixes.join(", "))) .size(11) - .color(DIM), + .style(muted_style), ); } container(body.padding([12, 14])) .width(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD)), - border: Border { color: BORDER, width: 1.0, radius: 6.0.into() }, - ..Default::default() - }) + .style(container::bordered_box) .into() } -fn pill_button<'a>(label: &str, msg: Message, bg: Color) -> Element<'a, Message> { - button(text(label.to_string()).size(12).color(WHITE)) +fn pill_button<'a>( + label: &str, + msg: Message, + style: fn(&Theme, button::Status) -> button::Style, +) -> Element<'a, Message> { + button(text(label.to_string()).size(12)) .padding([4, 12]) .on_press(msg) - .style(move |_: &Theme, status| { - let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 } - } else { - bg - }; - button::Style { - background: Some(Background::Color(c)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }) + .style(style) .into() } /// Square icon variant of [`pill_button`] for glyph-free actions (e.g. remove). -fn pill_icon_button<'a>(icon: &'static [u8], msg: Message, bg: Color) -> Element<'a, Message> { - button(crate::ui::icons::tinted(icon, 11.0, WHITE)) +fn pill_icon_button<'a>( + icon: &'static [u8], + msg: Message, + style: fn(&Theme, button::Status) -> button::Style, +) -> Element<'a, Message> { + button(crate::ui::icons::themed(icon, 11.0)) .padding([5, 9]) .on_press(msg) - .style(move |_: &Theme, status| { - let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 } - } else { - bg - }; - button::Style { - background: Some(Background::Color(c)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }) + .style(style) .into() } -const GREEN: Color = Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 }; -const RED: Color = Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 }; - /// Release dropdown + Install (+ optional unlink) for one repo. fn install_controls<'a>( repo: &str, @@ -232,7 +190,7 @@ fn install_controls<'a>( ) -> Element<'a, Message> { let repo_s = repo.to_string(); let picker: Element<'_, Message> = if tags.is_empty() { - text("no releases").size(11).color(DIM).into() + text("no releases").size(11).style(muted_style).into() } else { let r = repo_s.clone(); pick_list(tags, selected, move |tag| { @@ -244,7 +202,11 @@ fn install_controls<'a>( let mut controls = row![ picker, Space::new().width(8), - pill_button("Install", Message::PluginInstall(repo_s.clone()), GREEN), + pill_button( + "Install", + Message::PluginInstall(repo_s.clone()), + button::success, + ), ] .align_y(iced::Center) .spacing(4); @@ -253,7 +215,7 @@ fn install_controls<'a>( controls = controls.push(pill_icon_button( crate::ui::icons::CLOSE, Message::PluginRepoRemove(repo_s), - RED, + button::danger, )); } controls.into() @@ -262,37 +224,33 @@ fn install_controls<'a>( fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> { container(body.spacing(4).padding([10, 12])) .width(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD)), - border: Border { color: BORDER, width: 1.0, radius: 6.0.into() }, - ..Default::default() - }) + .style(container::bordered_box) .into() } fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { - let mut col = column![text("Available plugins").size(13).color(ACCENT)].spacing(6); + let mut col = column![text("Available plugins").size(13).style(primary_style)].spacing(6); // Curated registry entries (from the OpenCADStudio repo). for e in m.registry { let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(&e.repo).cloned(); let header = row![ - text(e.name.clone()).size(14).color(WHITE), + text(e.name.clone()).size(14), Space::new().width(Fill), install_controls(&e.repo, tags, selected, false), ] .align_y(iced::Center); - let mut body = column![header, text(e.repo.clone()).size(11).color(ACCENT)]; + let mut body = column![header, text(e.repo.clone()).size(11).style(primary_style)]; if !e.description.is_empty() { - body = body.push(text(e.description.clone()).size(12).color(DIM)); + body = body.push(text(e.description.clone()).size(12).style(muted_style)); } col = col.push(market_card(body)); } // Manual: link any repo by owner/repo. col = col.push(Space::new().height(6)); - col = col.push(text("Add a repository").size(12).color(DIM)); + col = col.push(text("Add a repository").size(12).style(muted_style)); col = col.push( row![ text_input("owner/repo", m.input) @@ -301,7 +259,7 @@ fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { .size(13) .width(Fill), Space::new().width(8), - pill_button("Add", Message::PluginRepoAdd, Color { r: 0.2, g: 0.4, b: 0.62, a: 1.0 }), + pill_button("Add", Message::PluginRepoAdd, button::primary), ] .align_y(iced::Center), ); @@ -309,7 +267,7 @@ fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { let tags = m.release_tags.get(repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(repo).cloned(); let header = row![ - text(repo.clone()).size(13).color(WHITE), + text(repo.clone()).size(13), Space::new().width(Fill), install_controls(repo, tags, selected, true), ] @@ -318,7 +276,7 @@ fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> { } if !m.status.is_empty() { - col = col.push(text(m.status.to_string()).size(11).color(DIM)); + col = col.push(text(m.status.to_string()).size(11).style(muted_style)); } col.into() } @@ -329,17 +287,17 @@ pub fn view_window<'a>( loaded: &FxHashSet, market: MarketView, ) -> Element<'a, Message> { - let title = text("Plugins").size(20).color(WHITE); + let title = text("Plugins").size(20); let subtitle = text("Add-ons load from the plugins folder. Install from a repository below.") .size(12) - .color(DIM); + .style(muted_style); let mut list = column![].spacing(10); // Installed external packages (from the plugins folder). if externals.is_empty() { - list = list.push(text("No plugins installed yet.").size(13).color(DIM)); + list = list.push(text("No plugins installed yet.").size(13).style(muted_style)); } else { - list = list.push(text("Installed").size(13).color(ACCENT)); + list = list.push(text("Installed").size(13).style(primary_style)); for p in externals { list = list.push(external_card( p, @@ -360,8 +318,10 @@ pub fn view_window<'a>( .width(Fill) .height(Fill), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 48f983cea..2f5cdc693 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -2,7 +2,7 @@ use crate::app::Message; use iced::widget::{column, container, row, scrollable, text, Space}; -use iced::{Background, Color, Element, Fill, Theme}; +use iced::{Background, Element, Fill, Theme}; use std::borrow::Cow; /// Display name of the primary accelerator modifier on this platform. @@ -14,49 +14,26 @@ const MOD: &str = "Cmd"; #[cfg(not(target_os = "macos"))] const MOD: &str = "Ctrl"; -const TB: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const KEY: Color = Color { - r: 0.40, - g: 0.70, - b: 1.00, - a: 1.0, -}; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + } +} fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.neutral.color, + )), ..Default::default() }) .into() @@ -69,7 +46,7 @@ fn shortcut_row<'a>( row![ text(key.into()) .size(11) - .color(KEY) + .style(primary_style) .font(iced::Font::MONOSPACE) .width(160), text(action).size(11), @@ -81,7 +58,7 @@ fn shortcut_row<'a>( } fn section<'a>(title: impl Into>) -> Element<'a, Message> { - container(text(title.into()).size(11).color(DIM)) + container(text(title.into()).size(11).style(muted_style)) .padding(iced::Padding { top: 6.0, right: 0.0, @@ -99,12 +76,14 @@ pub fn view_window<'a>( row![ text("Type SHORTCUTS SET to add custom shortcuts.") .size(10) - .color(DIM), + .style(muted_style), ] .align_y(iced::Center), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.weakest.color, + )), ..Default::default() }) .width(Fill) @@ -147,7 +126,7 @@ pub fn view_window<'a>( rows.push( text(" (none β€” use: SHORTCUTS SET )") .size(11) - .color(DIM) + .style(muted_style) .into(), ); } else { @@ -158,7 +137,7 @@ pub fn view_window<'a>( row![ text(key.as_str()) .size(11) - .color(KEY) + .style(primary_style) .font(iced::Font::MONOSPACE) .width(160), text(cmd.as_str()).size(11), @@ -179,26 +158,25 @@ pub fn view_window<'a>( // ── Header row with accent ──────────────────────────────────────────── let header = container( row![ - text("Key").size(10).color(ACCENT).width(160), - text("Action").size(10).color(ACCENT), + text("Key").size(10).style(primary_style).width(160), + text("Action").size(10).style(primary_style), ] .spacing(8) .padding([4, 16]), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.13, - g: 0.13, - b: 0.18, - a: 1.0, - })), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().primary.weak.color, + )), ..Default::default() }) .width(Fill); container(column![toolbar, hdivider(), header, hdivider(), content].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/window/update_notice.rs b/src/ui/window/update_notice.rs index 95e845632..39d7c2273 100644 --- a/src/ui/window/update_notice.rs +++ b/src/ui/window/update_notice.rs @@ -1,60 +1,33 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; +use iced::{Background, Border, Element, Fill, Theme}; -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const PANEL: Color = Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, -}; -const DIM: Color = Color { - r: 0.55, - g: 0.55, - b: 0.55, - a: 1.0, -}; -const ACCENT: Color = Color { - r: 0.25, - g: 0.50, - b: 0.85, - a: 1.0, -}; -const WHITE: Color = Color { - r: 0.92, - g: 0.92, - b: 0.92, - a: 1.0, -}; +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().background.base.text.scale_alpha(0.68)), + } +} + +fn primary_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.extended_palette().primary.base.color), + } +} /// Renders one of the two "Installed" / "Latest" cards. The `highlight` /// flag tints the border + label with the accent colour, making the new /// version the visual anchor of the row. fn version_card<'a>(label: &'static str, value: String, highlight: bool) -> Element<'a, Message> { - let label_color = if highlight { ACCENT } else { DIM }; - let value_color = WHITE; - let border_color = if highlight { ACCENT } else { BORDER }; - let bg = if highlight { - Color { r: 0.10, g: 0.16, b: 0.22, a: 1.0 } // accent-tinted dark - } else { - PANEL - }; container( column![ - text(label).size(10).color(label_color), - text(value).size(20).color(value_color), + text(label) + .size(10) + .style(move |theme: &Theme| if highlight { + primary_style(theme) + } else { + muted_style(theme) + }), + text(value).size(20), ] .spacing(4) .align_x(iced::Center), @@ -67,14 +40,26 @@ fn version_card<'a>(label: &'static str, value: String, highlight: bool) -> Elem left: 12.0, }) .align_x(iced::Center) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg)), + .style(move |theme: &Theme| { + let palette = theme.extended_palette(); + let pair = if highlight { + palette.primary.weak + } else { + palette.background.base + }; + container::Style { + background: Some(Background::Color(pair.color)), border: Border { - color: border_color, + color: if highlight { + palette.primary.base.color + } else { + palette.background.neutral.color + }, width: 1.0, radius: 6.0.into(), }, ..Default::default() + } }) .into() } @@ -98,24 +83,23 @@ fn render_notes_line<'a>(raw: &str) -> Element<'a, Message> { if let Some(rest) = trimmed.strip_prefix("## ") { return text(strip_inline_md(rest)) .size(13) - .color(ACCENT) + .style(primary_style) .into(); } if let Some(rest) = trimmed.strip_prefix("### ") { return text(strip_inline_md(rest)) .size(12) - .color(WHITE) .into(); } if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) { return row![ - container(crate::ui::icons::tinted(crate::ui::icons::DOT, 5.0, DIM)).width(14), - text(strip_inline_md(rest)).size(11).color(WHITE), + container(crate::ui::icons::themed_secondary(crate::ui::icons::DOT, 5.0)).width(14), + text(strip_inline_md(rest)).size(11), ] .spacing(4) .into(); } - text(strip_inline_md(trimmed)).size(11).color(WHITE).into() + text(strip_inline_md(trimmed)).size(11).into() } /// Drop `**…**` and `` `…` `` markers without preserving emphasis (iced 0.14 @@ -139,10 +123,10 @@ fn strip_inline_md(s: &str) -> String { pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { let header = container( column![ - text("New Release Available").size(20).color(ACCENT), + text("New Release Available").size(20).style(primary_style), text("A newer Open CAD Studio version is published on GitHub.") .size(11) - .color(DIM), + .style(muted_style), ] .spacing(4) .align_x(iced::Center), @@ -165,10 +149,9 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { false, ); let latest_card = version_card("Latest", format!("v{}", latest), true); - let arrow = container(crate::ui::icons::tinted( + let arrow = container(crate::ui::icons::themed_secondary( crate::ui::icons::ARROW_LONG_RIGHT, 20.0, - DIM, )) .width(iced::Length::Fixed(32.0)) .height(Fill) @@ -181,50 +164,12 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { let later_btn = button(text("Later").size(11)) .on_press(Message::UpdateNoticeClose) - .style(|_: &Theme, st| button::Style { - background: Some(Background::Color(match st { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - _ => Color { - r: 0.18, - g: 0.18, - b: 0.18, - a: 1.0, - }, - })), - text_color: WHITE, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }) + .style(button::secondary) .padding([6, 16]); let open_btn = button(text("Open Release Page").size(11)) .on_press(Message::UpdateNoticeOpenRelease) - .style(|_: &Theme, st| button::Style { - background: Some(Background::Color(match st { - button::Status::Hovered | button::Status::Pressed => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - _ => ACCENT, - })), - text_color: WHITE, - border: Border { - radius: 4.0.into(), - ..Default::default() - }, - ..Default::default() - }) + .style(button::primary) .padding([6, 16]); let footer = row![Space::new().width(Fill), later_btn, open_btn] @@ -240,7 +185,7 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { // Release notes panel. Rendered as a light-markdown column inside a // bordered scrollable so long bodies stay contained and don't // explode the window. Empty body β†’ "No release notes provided." - let notes_heading = container(text("What's new").size(11).color(DIM)) + let notes_heading = container(text("What's new").size(11).style(muted_style)) .padding(iced::Padding { top: 10.0, right: 0.0, @@ -251,7 +196,7 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { let notes_body: Element<'a, Message> = if body.trim().is_empty() { text("No release notes provided.") .size(11) - .color(DIM) + .style(muted_style) .into() } else { let mut col = column![].spacing(4); @@ -264,15 +209,7 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { let notes_block = container(notes_body) .width(Fill) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL)), - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }); + .style(container::bordered_box); // Wrap notes_block in a Fill-height container outside the column so it // greedily claims every pixel left over after the fixed-height rows @@ -294,8 +231,10 @@ pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { left: 20.0, }), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.extended_palette().background.base.color, + )), ..Default::default() }) .width(Fill) diff --git a/src/ui/wrap_bar.rs b/src/ui/wrap_bar.rs index c088c472e..32dae9b65 100644 --- a/src/ui/wrap_bar.rs +++ b/src/ui/wrap_bar.rs @@ -25,7 +25,7 @@ use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, tree, Widget}; use iced::advanced::{mouse, overlay, renderer, Clipboard, Renderer as _, Shell}; use iced::{ - Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, + Background, Border, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector, }; @@ -482,6 +482,9 @@ pub struct WrapFlow<'a> { row_h: f32, /// Align every wrapped row to the right edge of the available width. justify_end: bool, + /// Receives the natural single-row width (as `f32` bits). Multiple flows + /// may share one output; the widest measured value wins. + natural_width_out: Option>, } impl<'a> WrapFlow<'a> { @@ -493,6 +496,7 @@ impl<'a> WrapFlow<'a> { spacing_y: 0.0, row_h: 28.0, justify_end: false, + natural_width_out: None, } } @@ -517,6 +521,11 @@ impl<'a> WrapFlow<'a> { self.justify_end = justify; self } + + pub fn report_natural_width(mut self, out: Arc) -> Self { + self.natural_width_out = Some(out); + self + } } impl<'a> Widget for WrapFlow<'a> { @@ -557,6 +566,11 @@ impl<'a> Widget for WrapFlow<'a> { // bit closer before wrapping. Only fires when max_w is a real bound // (i.e. the flow is already width-constrained, e.g. overflowing tabs). let n = measured.len(); + if let Some(out) = &self.natural_width_out { + let natural_width = + sum_w + n.saturating_sub(1) as f32 * self.spacing_x; + out.fetch_max(natural_width.to_bits(), Ordering::Relaxed); + } let mut eff_gap = self.spacing_x; if self.min_spacing_x < self.spacing_x && max_w.is_finite() && n > 1 { let line_w = sum_w + (n - 1) as f32 * self.spacing_x; @@ -1360,12 +1374,7 @@ impl<'a> Widget for ReorderTab<'a> { shadow: Shadow::default(), snap: true, }, - Background::Color(Color { - r: 0.20, - g: 0.55, - b: 0.90, - a: 1.0, - }), + Background::Color(theme.extended_palette().primary.base.color), ); } } diff --git a/web/discussions.json b/web/discussions.json new file mode 100644 index 000000000..a08409e91 --- /dev/null +++ b/web/discussions.json @@ -0,0 +1,42 @@ +[ + { + "number": 531, + "title": "Choose The Default Theme", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/531", + "author": "HakanSeven12", + "updated_at": "2026-07-28T07:21:51Z", + "pinned": true + }, + { + "number": 518, + "title": "Addin ideas", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/518", + "author": "UserDevtec", + "updated_at": "2026-07-27T14:09:44Z", + "pinned": false + }, + { + "number": 430, + "title": "Inline Python REPL", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/430", + "author": "schoeller", + "updated_at": "2026-07-24T16:13:26Z", + "pinned": false + }, + { + "number": 214, + "title": "New improvements", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/214", + "author": "JuanJoseJimenezdeCisneros", + "updated_at": "2026-07-14T14:10:11Z", + "pinned": false + }, + { + "number": 382, + "title": "macOS 27 Golden Gate - Public Beta", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/382", + "author": "ZoomerHub", + "updated_at": "2026-07-14T12:57:50Z", + "pinned": false + } +]