Skip to content

Commit b4c7e06

Browse files
feat(style): cascading theme system and demo (#110)
* feat(style): add cascading style system with CSS-like selectors Introduce a new miaou_style library providing: - Style.t: core style type with fg/bg colors, text attributes, adaptive colors - Border: border style variants (Single, Double, Rounded, Ascii, Heavy) - Selector: CSS-like selector parser with pseudo-classes (:focus, :selected, :nth-child(even/odd/n)) and combinators (> for child, space for descendant) - Theme.t: semantic tokens (primary, error, text, etc.) + CSS-like rules - Theme_loader: file discovery, JSON parsing, theme merging - Style_context: effect-based implicit theme access Theme file discovery order (later overrides earlier): 1. Built-in default theme 2. ~/.config/miaou/theme.json (user global) 3. .miaou/theme.json (project local) 4. $MIAOU_THEME env var Also includes example theme files (dark.json, light.json, high-contrast.json) and unit tests for all modules. * feat(layout): integrate style context into Flex_layout and Grid_layout Update layout widgets to set up style context for child widgets when rendering: - Pass widget_name, index, and count to Style_context.with_child_context - This enables CSS-like selectors such as :nth-child(even) to work for alternating row styles in flex/grid layouts Child widgets can now access their position via Style_context.current_context() to apply position-based styling rules from the theme. * feat(widgets): add semantic themed styling functions Add themed_* functions to Widgets module that use Style_context to access the current theme: - themed_primary, themed_secondary, themed_accent - themed_error, themed_warning, themed_success, themed_info - themed_text, themed_muted, themed_emphasis - themed_border, themed_selection - themed_background, themed_background_alt - themed_contextual (for CSS-like selector-based styling) - current_widget_style() for accessing full style context Update AGENTS.md with comprehensive styling guidelines: - Document two-layer approach (semantic + contextual) - List all available themed functions with use cases - Explain when raw fg/bg is acceptable (gradients, charts) - Show how to set up style context for child widgets Widget authors should use these functions instead of hardcoded fg/bg colors to ensure consistent, themeable rendering across all widgets. * refactor(widgets): migrate simple widgets to themed styling Update widgets to use semantic themed functions instead of hardcoded colors: - button_widget: Use themed_selection for focus state, themed_muted for disabled - card_widget: Use themed_emphasis for title (when no accent override) - Mark accent field as deprecated, encourage semantic styling - line_chart_widget: Use themed_emphasis for chart titles - sparkline_widget: Use themed_emphasis for focus state - bar_chart_widget: Use themed_emphasis for chart titles Chart widgets continue to accept colors from their data model (series colors, threshold colors) as this is appropriate for data visualization where colors convey data meaning rather than UI semantics. * refactor(widgets): migrate medium widgets to themed styling - select_widget: use themed_selection for highlighted items - textarea_widget: use themed_border for box lines and themed_muted for placeholder/indicator text - toast_widget: map severities to themed_info/success/warning/error - progress_widget: use themed_muted for percentage and themed_secondary for labels (retain gradient fill) Spinner and progress gradients remain raw-color by design for animated/visual feedback; these are accepted uses of raw colors per styling guidelines. * refactor(widgets): migrate complex widgets to themed styling Update complex widgets and markdown rendering to use semantic themed styles: - pager_widget: replace hardcoded ANSI colors in help modal, status line, search prompt, and cursor highlight with themed_* functions - table_widget: use themed borders, emphasis, selection, accent, and background styles across both terminal and SDL renderers - file_browser_widget: apply themed selection, accent, muted, success, warning, and error styles for path bar, entries, and status messages - box_widget: default border styling now uses themed_border; title uses themed_emphasis while preserving legacy per-side color overrides - modal_utils: markdown_to_ansi now uses themed semantic styles for code, headings, links, quotes, and list markers - tui_page.mli: document styling requirements for PAGE_SIG view functions Raw color codes remain only where gradients or data-driven chart colors are intended, aligning with the new styling guidelines. * feat(demo): add style system demo Add a new demo showing the cascading style system with runtime theme switching and contextual styling via flex-child selectors. The demo includes three embedded themes (dark, light, high-contrast) and a simple flex layout of tiles to highlight semantic tokens and contextual rules. * fix(demo): make style system demo respond to theme changes - Ensure embedded themes provide all required fields so JSON parsing succeeds - Preserve flex-child index/count when adding focus context so nth-child rules apply * chore(demo): surface theme parse status in style demo Show whether embedded themes parsed successfully so it is obvious when the style context falls back to the default theme. This helps diagnose why visual changes might not appear when switching themes. * fix(style): allow partial JSON style objects Make Style.t fields optional in JSON parsing so theme files can specify only the attributes they care about. This fixes demo theme parsing errors and aligns with the intended cascading behavior. * fix(style): accept multiple JSON formats for colors Allow color values in theme JSON to be provided as: - {Fixed: 75} - [Fixed, 75] - 75 (defaults to Fixed) - {Adaptive: {light: 15, dark: 231}} - [Adaptive, {light: 15, dark: 231}] This preserves backward compatibility and fixes theme parsing failures in the demo and bundled theme files. * fix(style): make Style.t JSON parsing tolerant Replace derived Style.t JSON parsing with a custom decoder that tolerates missing fields and ignores nulls. This prevents theme parsing failures for partial style objects and supports the intended cascading behavior. Also expose to_yojson/of_yojson aliases for compatibility with existing Theme JSON serialization. * fix(style): allow partial widget style JSON objects Provide a tolerant widget_style JSON parser so theme rules can omit optional fields like border_style/border_fg/border_bg without failing. This resolves Border.style parse errors when loading demo themes. * fix(style): accept string JSON values for border styles Allow Border.style to parse from simple string values like Rounded in JSON theme files, fixing Border.style parse errors in demo themes. * fix(widgets): fill contextual background across container width Apply contextual background to padded lines in Box_widget and render_frame, reapplying background across ANSI resets so inline styling doesn't punch holes. This makes container backgrounds fill the full content width. * chore(demo): distinguish selection from background Adjust demo theme selection colors so selection stands out from contextual background fills and focus styles. * feat(style): add optional contrast validation for themes Introduce Theme.validate to detect low-contrast fg/bg pairs in semantic styles and CSS-like rule styles. The style-system demo now surfaces the first warning (if any) to help authors avoid unreadable combinations. * feat(box): support borderless boxes for container backgrounds Add a None_ border style to Box_widget so containers can render without borders. The style-system demo now uses borderless tiles with padded content, matching the OpenCode-style layout with contrasted backgrounds. * fix(demo): restore flex-child selector context Ensure the style-system demo preserves the flex-child widget name when adding focus/selection context so nth-child rules apply and tiles alternate backgrounds. * docs(changelog): add 0.4.0 style system notes Document the new cascading style system, theme JSON support, widget theming changes, and the Box_widget None_ border style breaking change. * feat(demo): add editable theme.json for style system Ship example/demos/style_system/theme.json as an OpenCode-like flex theme and load it as the dark theme when present, so users can iterate on CSS-like rules without recompiling. * fix(demo): fall back to embedded theme with error notice When example/demos/style_system/theme.json fails to load, the demo now falls back to the embedded theme JSON and surfaces a warning so users know the file-based theme was ignored. * chore(release): bump version to 0.4.0 * feat(gallery): add style system demo * fix(test): adapt ascii_style test for themed border rendering Box_widget now wraps border chars in ANSI codes via themed_border, so first.[0] is no longer '+'. Check for '+' using Str.search_forward to match the pattern used by other tests in the file.
1 parent c9ec473 commit b4c7e06

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+3189
-259
lines changed

AGENTS.md

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,12 @@ val is_valid : t -> bool
212212

213213
### ANSI Output
214214

215-
Widgets return ANSI-formatted strings. Use helpers from `Miaou_widgets_display.Widgets`:
215+
Widgets return ANSI-formatted strings. Use themed helpers from `Miaou_widgets_display.Widgets`:
216216

217217
```ocaml
218218
let open Miaou_widgets_display.Widgets in
219-
let line = bold (fg 81 "Title") ^ " - " ^ dim "subtitle" in
219+
(* Use semantic styles - NOT raw fg/bg with hardcoded numbers *)
220+
let line = themed_emphasis "Title" ^ " - " ^ themed_muted "subtitle" in
220221
let box = render_frame ~title:"Box" ~cols:40 ~body:content () in
221222
```
222223

@@ -228,6 +229,114 @@ let box = render_frame ~title:"Box" ~cols:40 ~body:content () in
228229

229230
---
230231

232+
## Styling System (CRITICAL)
233+
234+
Miaou uses a **cascading style system** with CSS-like selectors. Understanding this is essential for creating consistent, themeable widgets.
235+
236+
### Two-Layer Styling
237+
238+
1. **Semantic Styles (explicit)** - Widget authors choose these based on content meaning
239+
2. **Contextual Styles (automatic)** - Applied by parent widgets based on position/state
240+
241+
### MANDATORY: Use Semantic Style Functions
242+
243+
**NEVER use hardcoded color numbers.** Always use the themed functions from `Miaou_widgets_display.Widgets`:
244+
245+
```ocaml
246+
(* CORRECT - semantic styling *)
247+
let render_status status msg =
248+
let open Miaou_widgets_display.Widgets in
249+
match status with
250+
| `Error -> themed_error msg
251+
| `Warning -> themed_warning msg
252+
| `Success -> themed_success msg
253+
| `Info -> themed_info msg
254+
| `Normal -> themed_text msg
255+
256+
(* WRONG - hardcoded colors *)
257+
let render_status status msg =
258+
match status with
259+
| `Error -> fg 196 msg (* BAD: hardcoded red *)
260+
| `Normal -> fg 255 msg (* BAD: hardcoded white *)
261+
```
262+
263+
### Available Semantic Functions
264+
265+
| Function | Use Case |
266+
|----------|----------|
267+
| `themed_primary` | Main UI elements, important content |
268+
| `themed_secondary` | Less prominent elements |
269+
| `themed_accent` | Highlights, links, interactive elements |
270+
| `themed_error` | Errors, failures, critical issues |
271+
| `themed_warning` | Cautions, potential problems |
272+
| `themed_success` | Confirmations, completed actions |
273+
| `themed_info` | Neutral information, tips |
274+
| `themed_text` | Normal readable content (DEFAULT for text) |
275+
| `themed_muted` | Secondary info, hints, disabled text |
276+
| `themed_emphasis` | Bold/highlighted content |
277+
| `themed_border` | Widget frames, separators |
278+
| `themed_selection` | Selected items in lists/tables |
279+
| `themed_background` | Primary background |
280+
| `themed_background_alt` | Alternate background (zebra stripes) |
281+
282+
### Contextual Styling (Automatic)
283+
284+
Parent widgets (like `Flex_layout`, `Grid_layout`) automatically set up style context for children. This enables CSS-like rules in themes:
285+
286+
```json
287+
{
288+
"rules": [
289+
{ "selector": "table-row:nth-child(even)", "style": { "bg": 236 } },
290+
{ "selector": "list-item:selected", "style": { "bg": 24, "bold": true } },
291+
{ "selector": "button:focus", "style": { "fg": 81, "bold": true } }
292+
]
293+
}
294+
```
295+
296+
Widgets can access their contextual style with:
297+
298+
```ocaml
299+
(* Apply contextual style (respects :nth-child, :focus, etc.) *)
300+
let content = themed_contextual "my content" in
301+
302+
(* Or get the full style record for complex rendering *)
303+
let ws = current_widget_style () in
304+
let border = ws.border_style in (* Border.Single, Border.Rounded, etc. *)
305+
```
306+
307+
### When Raw Colors Are Acceptable
308+
309+
Use `fg`/`bg` with numbers ONLY for:
310+
- **Gradients and charts** where you need precise color interpolation
311+
- **SDL rendering** that doesn't use ANSI
312+
- **Legacy code** being migrated (add TODO comment)
313+
314+
```ocaml
315+
(* Acceptable: gradient for progress bar *)
316+
let gradient_colors = [| 21; 27; 33; 39; 45 |] in
317+
let color = gradient_colors.(progress * 4 / 100) in
318+
fg color block
319+
```
320+
321+
### Style Context in Custom Widgets
322+
323+
If your widget renders children, set up the style context:
324+
325+
```ocaml
326+
let render_children children =
327+
children |> List.mapi (fun i child ->
328+
Style_context.with_child_context
329+
~widget_name:"my-widget-item"
330+
~index:i
331+
~count:(List.length children)
332+
(fun () -> child.render ())
333+
)
334+
```
335+
336+
This enables theme rules like `my-widget-item:nth-child(even)` to work.
337+
338+
---
339+
231340
## Modal System
232341

233342
### Basic Modal Usage

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.0] - Unreleased
9+
10+
### Breaking Changes
11+
12+
- **Box_widget border style**: added `None_` to `Box_widget.border_style` for borderless containers. Pattern matches on `border_style` may need a new case.
13+
14+
### Added
15+
16+
- **Cascading style system** (`miaou_style`): semantic styles + CSS-like selectors with effect-based context (`Style_context`).
17+
- **Theme JSON support** with discovery/merge rules and optional validation for low-contrast fg/bg combinations.
18+
- **Style system demo** (`miaou.style_system-demo`) with runtime theme switching and contextual styling.
19+
20+
### Changed
21+
22+
- **Widget theming**: widgets now use semantic themed styles; containers fill contextual backgrounds across full line width.
23+
24+
### Fixed
25+
26+
- **Theme JSON parsing**: tolerant parsing for partial style objects, multiple color formats, and string border styles.
27+
828
## [0.3.2] - Unreleased
929

1030
### Added

dune-project

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
(lang dune 3.15)
22
(name miaou)
3-
(version 0.3.2)
3+
(version 0.4.0)
44
(generate_opam_files true)
55
(license MIT)
66

@@ -11,20 +11,21 @@
1111
(authors "Nomadic Labs <contact@nomadic-labs.com>")
1212
(homepage "https://github.com/trilitech/miaou")
1313
(bug_reports "https://github.com/trilitech/miaou/issues")
14-
(depends
15-
cohttp
16-
cohttp-eio
17-
eio
18-
eio_main
19-
lambda-term
20-
rresult
21-
uri
22-
yojson
23-
imagelib
24-
qrc
25-
ppx_blob
26-
(alcotest :with-test)
27-
(bisect_ppx :with-test)))
14+
(depends
15+
cohttp
16+
cohttp-eio
17+
eio
18+
eio_main
19+
lambda-term
20+
rresult
21+
uri
22+
yojson
23+
ppx_deriving_yojson
24+
imagelib
25+
qrc
26+
ppx_blob
27+
(alcotest :with-test)
28+
(bisect_ppx :with-test)))
2829

2930
(package
3031
(name miaou-driver-term)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Style System Demo
2+
3+
This demo showcases the cascading style system in Miaou.
4+
5+
## Highlights
6+
7+
- Semantic styles: `themed_text`, `themed_error`, `themed_success`, etc.
8+
- Contextual styles: background changes via `flex-child:nth-child(...)` rules
9+
- Theme switching at runtime
10+
11+
## Controls
12+
13+
- `1` / `2` / `3`: Switch theme (dark / light / high-contrast)
14+
- `Left` / `Right`: Move focus across tiles
15+
- `Esc`: Return to launcher
16+
17+
## Theme File
18+
19+
The demo also ships a CSS-like theme file you can edit live:
20+
21+
- `example/demos/style_system/theme.json`
22+
23+
If the file exists, the demo will load it as the **dark** theme.

example/demos/style_system/dune

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
(library
2+
(name style_system_demo)
3+
(modules page)
4+
(preprocess
5+
(pps ppx_blob))
6+
(preprocessor_deps
7+
(file README.md)
8+
(file themes/dark.json)
9+
(file themes/light.json)
10+
(file themes/high-contrast.json)
11+
(file theme.json))
12+
(libraries
13+
demo_shared
14+
miaou
15+
miaou-core.widgets.display
16+
miaou-core.widgets.layout
17+
miaou-core.style))
18+
19+
(executable
20+
(name main)
21+
(public_name miaou.style_system-demo)
22+
(package miaou)
23+
(modules main)
24+
(libraries style_system_demo miaou-runner.tui miaou-core.helpers eio_main))

example/demos/style_system/main.ml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
(******************************************************************************)
2+
(* *)
3+
(* SPDX-License-Identifier: MIT *)
4+
(* Copyright (c) 2026 Nomadic Labs <contact@nomadic-labs.com> *)
5+
(* *)
6+
(******************************************************************************)
7+
8+
let () =
9+
Eio_main.run @@ fun env ->
10+
Eio.Switch.run @@ fun sw ->
11+
Miaou_helpers.Fiber_runtime.init ~env ~sw ;
12+
Demo_shared.Demo_config.register_mocks () ;
13+
Demo_shared.Demo_config.ensure_system_capability () ;
14+
let page : Miaou.Core.Registry.page =
15+
(module Style_system_demo.Page : Miaou.Core.Tui_page.PAGE_SIG)
16+
in
17+
ignore (Miaou_runner_tui.Runner_tui.run page)

0 commit comments

Comments
 (0)