From 986a10f9f00693370c6c74ba0205f21bc6e47f93 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 12:34:58 -0400 Subject: [PATCH 1/2] fix(#180): make a paint's fill_to follow its fill every write of a fill through a path, set, set_path, TOML or a class also writes fill_to, so a fill alone is a flat face, and fill_to written with it or after it makes the gradient. a class or document writes fill_to after every fill whatever the order, a pop restores both, and a save writes fill_to after a changed fill whenever the two differ. --- README.md | 9 +++- src/sheet.mach | 49 ++++++++++++++------- src/style.mach | 9 ++-- src/theme.mach | 114 ++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 137 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 0bf1616..59ae9d1 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,13 @@ blit.style.pop(?ctx); blit.style.pop(?ctx); ``` +A paint's `fill_to` follows its `fill`: every write of a fill through a path +(a push, a class, `theme.set` and `set_path`, a TOML document) writes `fill_to` +too, so a fill alone gives a flat face. Writing `fill_to` as well, in the same +push, class or document in any order, or in a later push, gives the gradient, +and a pop restores both. A save writes `fill_to` after `fill` whenever the two +differ, so the gradient reads back. + `push_color`, `push_length`, `push_radii`, `push_edges` and the untyped `push` refuse a path naming no field of their unit, and still open an empty push so every push pairs with its pop. A frame left with pushes open is restored at @@ -347,7 +354,7 @@ shows. | `.shadow_y` | length | shadow offset down | | `.shadow_blur` | length | how far the shadow fades out, 0 for a hard edge | | `..fill` | color | the face, at its top when it is a gradient | -| `..fill_to` | color | the face at its bottom: equal to fill for a flat face, else a vertical gradient | +| `..fill_to` | color | the face at its bottom, a vertical gradient from fill: written with every write of fill, so a fill alone is flat | | `..text` | color | text drawn on the face | | `..mark` | color | secondary ink: a glyph, a caret, a reading or a hint | | `..border` | color | the outline, transparent for none | diff --git a/src/sheet.mach b/src/sheet.mach index 5576435..7cc762f 100644 --- a/src/sheet.mach +++ b/src/sheet.mach @@ -7,7 +7,9 @@ # popping it restores the theme exactly. one push writes every field its path # names, so "*.hot.fill" is one push and one pop. a class is a named list of # path and value settings, and pushing it is one push of all of them in -# order, so a later setting wins over an earlier one for the same field. +# order, so a later setting wins over an earlier one for the same field. a +# write of a paint's fill writes its fill_to too (see theme.follower), and the +# pop restores both. # classes are allocator-backed and a sheet grows them; a push the allocator # refuses writes nothing and reports it. @@ -121,22 +123,30 @@ pub fun free(s: *Sheet) err[allo.Error] { ret first; } -# write every field a path names and save what it replaced, without a mark +# write every field a path names, each with the field that follows it (see +# theme.follower), and save what they replaced, without a mark fun write(s: *Sheet, t: *th.Theme, p: th.Path, v: th.Value) bool { val n: usize = th.span(t, p); - val room: res[usize, allo.Error] = vec.reserve[Saved](?s.saved, n); + val room: res[usize, allo.Error] = vec.reserve[Saved](?s.saved, n * 2); if (sel room.err) { ret false; } var i: usize = 0; for (i < n) { val r: th.Ref = th.ref_at(p, i); - s.saved.data[s.saved.len] = Saved{r: r, old: th.get(t, r)}; - s.saved.len = s.saved.len + 1; - th.put(t, r, v); + keep(s, t, r, v); + val f: opt[th.Ref] = th.follower(r); + if (sel f.some) { keep(s, t, f.some, v); } i = i + 1; } ret true; } +# write one field and save what it replaced, its room reserved +fun keep(s: *Sheet, t: *th.Theme, r: th.Ref, v: th.Value) { + s.saved.data[s.saved.len] = Saved{r: r, old: th.get(t, r)}; + s.saved.len = s.saved.len + 1; + th.put(t, r, v); +} + # open a push: one mark the next pop returns to fun open(s: *Sheet) bool { val pushed: res[usize, allo.Error] = vec.push[usize](?s.marks, s.saved.len); @@ -332,7 +342,9 @@ pub fun class_clear(s: *Sheet, id: u32) { # # each setting's path is resolved against the theme now, and a setting # naming no field of it, such as a kind the theme has not registered, is -# skipped. +# skipped. settings of fields that follow another (see theme.follower) are +# written after the rest, so a class's fill_to wins over its fills wherever +# it is listed. # --- # s: sheet # t: the theme it overrides @@ -341,17 +353,22 @@ pub fun class_clear(s: *Sheet, id: u32) { pub fun push_class(s: *Sheet, t: *th.Theme, id: u32) bool { if (id::usize >= s.classes.len) { ret false; } if (!open(s)) { ret false; } - val c: *Class = ?s.classes.data[id]; - var i: usize = 0; - for (i < c.sets.len) { - val p: opt[th.Path] = th.path(t, c.sets.data[i].text::str); - if (sel p.some) { - if (th.FIELDS[p.some.field].unit == c.sets.data[i].unit && !write(s, t, p.some, c.sets.data[i].v)) { - pop(s, t); - ret false; + val c: *Class = ?s.classes.data[id]; + var late: u8 = 0; + for (late < 2) { + var i: usize = 0; + for (i < c.sets.len) { + val p: opt[th.Path] = th.path(t, c.sets.data[i].text::str); + if (sel p.some) { + val fits: bool = th.FIELDS[p.some.field].unit == c.sets.data[i].unit; + if (fits && th.follows(th.ref_at(p.some, 0)) == (late == 1) && !write(s, t, p.some, c.sets.data[i].v)) { + pop(s, t); + ret false; + } } + i = i + 1; } - i = i + 1; + late = late + 1; } ret true; } diff --git a/src/style.mach b/src/style.mach index c136bd4..4fd3fdd 100644 --- a/src/style.mach +++ b/src/style.mach @@ -7,9 +7,12 @@ # blit.widget.button(?ctx, "delete"); # blit.style.pop(?ctx); # -# draws one red button. a class is a named list of such settings, defined in -# code with add_class and set, or in a theme document's [class.] -# tables, and push_class applies all of them to the widgets until its pop. +# draws one solid red button: a fill pushed alone writes fill_to too, and +# pushing fill_to as well, after it or in a class with it, makes the face a +# gradient (see blit.theme.follower). a class is a named list of such +# settings, defined in code with add_class and set, or in a theme document's +# [class.] tables, and push_class applies all of them to the widgets +# until its pop. # pushes nest, and begin undoes any a frame left open. # # load_theme replaces the context's theme with one read from TOML and reads diff --git a/src/theme.mach b/src/theme.mach index 0230d95..dec71d5 100644 --- a/src/theme.mach +++ b/src/theme.mach @@ -29,6 +29,11 @@ # meaning, and TOML, overrides (see context.push_color), classes and the # theme editor all address fields through it. # +# a paint's fill_to follows its fill (see follower): every write through a +# path, set, TOML or a class that writes a fill writes fill_to too, so a fill +# alone is a flat face, and fill_to written with it or after it makes the +# gradient. put writes one field as it is. +# # motion is data too: how long hover and press transitions, sections opening # and scroll glides take and how they ease are the motion fields, at # motion. (see Motion), which blit.anim reads through motion alone. @@ -252,7 +257,8 @@ pub fun edges(v: f32) Edges { # how a style draws in one state # --- # fill: the face, at its top when it is a gradient -# fill_to: the face at its bottom: the same as fill for a flat face, else a vertical gradient +# fill_to: the face at its bottom: the same as fill for a flat face, else a vertical gradient, +# following fill when fill is written through a path (see follower) # text: text drawn on the face # mark: secondary ink: a glyph, a caret, a reading or a hint # border: the outline, transparent for none @@ -473,7 +479,7 @@ pub val FIELDS: [58]Field = [58]Field{ Field{group: GROUP_STYLE, name: "shadow_y", unit: UNIT_LENGTH, off: $offset_of(Style, shadow_y), count: 1, doc: "shadow offset down"}, Field{group: GROUP_STYLE, name: "shadow_blur", unit: UNIT_LENGTH, off: $offset_of(Style, shadow_blur), count: 1, doc: "how far the shadow fades out, 0 for a hard edge"}, Field{group: GROUP_PAINT, name: "fill", unit: UNIT_COLOR, off: $offset_of(Paint, fill), count: 1, doc: "the face, at its top when it is a gradient"}, - Field{group: GROUP_PAINT, name: "fill_to", unit: UNIT_COLOR, off: $offset_of(Paint, fill_to), count: 1, doc: "the face at its bottom: equal to fill for a flat face, else a vertical gradient"}, + Field{group: GROUP_PAINT, name: "fill_to", unit: UNIT_COLOR, off: $offset_of(Paint, fill_to), count: 1, doc: "the face at its bottom, a vertical gradient from fill: written with every write of fill, so a fill alone is flat"}, Field{group: GROUP_PAINT, name: "text", unit: UNIT_COLOR, off: $offset_of(Paint, text), count: 1, doc: "text drawn on the face"}, Field{group: GROUP_PAINT, name: "mark", unit: UNIT_COLOR, off: $offset_of(Paint, mark), count: 1, doc: "secondary ink: a glyph, a caret, a reading or a hint"}, Field{group: GROUP_PAINT, name: "border", unit: UNIT_COLOR, off: $offset_of(Paint, border), count: 1, doc: "the outline, transparent for none"}, @@ -1025,16 +1031,17 @@ pub fun is_token(r: Ref) bool { # write one field of a theme, moving the styles with a token # -# a style or plot field is written as it is. a token is written and the -# styles rederived, so every style field that took its value from the old -# token takes the new one and fields set by hand stay as they are. +# a style or plot field is written as it is, with the field that follows it +# (see follower). a token is written and the styles rederived, so every style +# field that took its value from the old token takes the new one and fields +# set by hand stay as they are. # --- # t: the theme # r: the field, from ref_at # v: its new value pub fun set(t: *Theme, r: Ref, v: Value) { if (!is_token(r)) { - put(t, r, v); + write(t, r, v); ret; } val before: Theme = @t; @@ -1042,7 +1049,7 @@ pub fun set(t: *Theme, r: Ref, v: Value) { rederive(t, ?before); } -# write every field a path names +# write every field a path names, each with the field that follows it # --- # t: the theme # p: the path @@ -1053,13 +1060,47 @@ pub fun set_path(t: *Theme, p: Path, v: Value) usize { val before: Theme = @t; var i: usize = 0; for (i < n) { - put(t, ref_at(p, i), v); + write(t, ref_at(p, i), v); i = i + 1; } if (n > 0 && is_token(ref_at(p, 0))) { rederive(t, ?before); } ret n; } +# the field a write of a field writes too, with the same value +# +# a paint's fill_to follows its fill, so a fill written alone gives a flat +# face and fill_to written with it or after it gives the gradient. a write +# of several fields at once writes the following ones last (see follows), so +# the result does not depend on the order they are listed in. +# --- +# r: the field +# ret: the fill_to of the same paint for a fill, else none +pub fun follower(r: Ref) opt[Ref] { + if (r.group != GROUP_PAINT) { ret opt[Ref].none{}; } + val in: usize = (r.off - $offset_of(Style, states)) % $size_of(Paint); + if (in != $offset_of(Paint, fill)) { ret opt[Ref].none{}; } + var f: Ref = r; + f.off = r.off - $offset_of(Paint, fill) + $offset_of(Paint, fill_to); + ret opt[Ref].some{f}; +} + +# whether a field follows another (see follower) +# --- +# r: the field +# ret: true for a paint's fill_to +pub fun follows(r: Ref) bool { + if (r.group != GROUP_PAINT) { ret false; } + ret (r.off - $offset_of(Style, states)) % $size_of(Paint) == $offset_of(Paint, fill_to); +} + +# write one field and the field that follows it +fun write(t: *Theme, r: Ref, v: Value) { + put(t, r, v); + val f: opt[Ref] = follower(r); + if (sel f.some) { put(t, f.some, v); } +} + # a style's paint with one fill and the theme's text inks fun paint(t: *Theme, fill: draw.Color) Paint { var p: Paint; @@ -1990,7 +2031,7 @@ fun apply(t: *Theme, p: Path, v: *toml.Value) err[LoadError] { val n: usize = span(t, p); var i: usize = 0; for (i < n) { - put(t, ref_at(p, i), x.some); + write(t, ref_at(p, i), x.some); i = i + 1; } ret err[LoadError].ok{}; @@ -2007,16 +2048,21 @@ fun entry(tb: *toml.Table, i: usize, key: *str, v: **toml.Value) bool { ret true; } -# apply every field of one top-level table: tokens when tokens is true, the -# rest otherwise -fun read_table(t: *Theme, top: str, tb: *toml.Table, tokens: bool) err[LoadError] { +# the passes a document is read in: tokens, then the other fields, then the +# fields that follow another (see follower), so they win whatever the order +val PASS_TOKENS: u8 = 0; +val PASS_FIELDS: u8 = 1; +val PASS_FOLLOWS: u8 = 2; + +# apply the fields of one top-level table that belong to a pass +fun read_table(t: *Theme, top: str, tb: *toml.Table, pass: u8) err[LoadError] { var segs: [3]str; var lens: [3]usize; segs[0] = top; lens[0] = str_len(top); val g: opt[u8] = group_named(top, lens[0]); val is_tok: bool = sel g.some && (g.some == GROUP_PALETTE || g.some == GROUP_METRICS); - if (is_tok != tokens) { ret err[LoadError].ok{}; } + if (is_tok != (pass == PASS_TOKENS)) { ret err[LoadError].ok{}; } var i: usize = 0; for (i < toml.table_len(tb)) { var key: str; @@ -2034,29 +2080,39 @@ fun read_table(t: *Theme, top: str, tb: *toml.Table, tokens: bool) err[LoadError lens[2] = str_len(k2); val p: opt[Path] = resolve(t, ?segs[0], ?lens[0], 3); if (sel p.none) { ret err[LoadError].err{LoadError.unknown{v2.key.at}}; } - val e: err[LoadError] = apply(t, p.some, v2); - if (sel e.err) { ret e; } + if (in_pass(p.some, pass)) { + val e: err[LoadError] = apply(t, p.some, v2); + if (sel e.err) { ret e; } + } j = j + 1; } } or { val p: opt[Path] = resolve(t, ?segs[0], ?lens[0], 2); if (sel p.none) { ret err[LoadError].err{LoadError.unknown{v.key.at}}; } - val e: err[LoadError] = apply(t, p.some, v); - if (sel e.err) { ret e; } + if (in_pass(p.some, pass)) { + val e: err[LoadError] = apply(t, p.some, v); + if (sel e.err) { ret e; } + } } i = i + 1; } ret err[LoadError].ok{}; } +# whether a path's fields are read in a pass past the tokens +fun in_pass(p: Path, pass: u8) bool { + ret pass == PASS_TOKENS || follows(ref_at(p, 0)) == (pass == PASS_FOLLOWS); +} + # the theme a parsed document describes, read into a theme keeping its kinds # # the document starts from the built-in named by its base key (default when # it has none). its palette and metrics tables set tokens and move the styles # with them (see rederive), then its plot table and kind tables set fields as # they are: [] for style fields and [.] for paint fields, -# with "*" for every kind or state, a kind named by its registered name. a +# with "*" for every kind or state, a kind named by its registered name, and +# fill_to after every fill (see follower) wherever it is listed. a # class table is left for the context (see style.load_theme). a key that # names nothing, an unregistered kind among them, is refused. # --- @@ -2088,8 +2144,8 @@ fun read_into(t: *Theme, root: *toml.Table) err[LoadError] { if (!builtin(t, bv.data.string)) { ret err[LoadError].err{LoadError.base{bv.span.at}}; } } val before: Theme = @t; - var pass: u8 = 0; - for (pass < 2) { + var pass: u8 = PASS_TOKENS; + for (pass <= PASS_FOLLOWS) { var i: usize = 0; for (i < toml.table_len(root)) { var key: str; @@ -2101,10 +2157,10 @@ fun read_into(t: *Theme, root: *toml.Table) err[LoadError] { val g: opt[u8] = group_named(key, str_len(key)); val ko: opt[u32] = kind_named(t, key, str_len(key)); if (sel g.none && sel ko.none) { ret err[LoadError].err{LoadError.unknown{v.key.at}}; } - val e: err[LoadError] = read_table(t, key, ?v.data.table, pass == 0); + val e: err[LoadError] = read_table(t, key, ?v.data.table, pass); if (sel e.err) { ret e; } } - if (pass == 0) { rederive(t, ?before); } + if (pass == PASS_TOKENS) { rederive(t, ?before); } pass = pass + 1; } ret err[LoadError].ok{}; @@ -2206,9 +2262,12 @@ fun save_group(w: *tw.Writer, t: *Theme, group: u8, name: str) { # write the fields of one group of a kind (style fields, or one state's paint # fields) that differ from what its derive builds, under a table header when -# any do +# any do. a field that follows another (see follower) is written after it +# when it differs from it, as reading the leader alone would make them equal. fun save_diff(w: *tw.Writer, t: *Theme, d: *Style, kind: u32, state: u8, group: u8) { var header: bool = false; + var led: bool = false; + var lead: Value; var i: usize = 0; for (i < FIELD_COUNT) { val f: Field = FIELDS[i]; @@ -2222,7 +2281,14 @@ fun save_diff(w: *tw.Writer, t: *Theme, d: *Style, kind: u32, state: u8, group: was.v[j] = ((d::usize + off)::*f32)[j]; j = j + 1; } - if (!same_value(f.unit, now, was)) { + var differs: bool = !same_value(f.unit, now, was); + if (follows(r) && led) { differs = !same_value(f.unit, now, lead); } + val fo: opt[Ref] = follower(r); + if (sel fo.some) { + led = differs; + lead = now; + } + if (differs) { if (!header) { var keys: [2]str; keys[0] = t.names[kind]; From 6d6e0e8740b47c7f4c9d433096c0383509055621 Mon Sep 17 00:00:00 2001 From: Ethan Reynolds Date: Thu, 1 Oct 2026 12:34:58 -0400 Subject: [PATCH 2/2] test(#180): cover a flat fill and a fill_to gradient through every path --- src/style.mach | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/style.mach b/src/style.mach index 4fd3fdd..9ab7c8b 100644 --- a/src/style.mach +++ b/src/style.mach @@ -450,6 +450,83 @@ test registry__a_registered_kind_loads_from_toml_and_paints { ret 0; } +# whether a kind's paint in a state is a flat face of a color's red +#[testing] +fun flat(ctx: *context.Context, k: u32, state: u8, r: f32) bool { + val p: th.Paint = th.style(?ctx.theme, k).states[state]; + ret p.fill.r == r && p.fill_to.r == r && p.fill_to.g == p.fill.g && p.fill_to.b == p.fill.b; +} + +# whether a kind's paint in a state runs from one red at the top to another +#[testing] +fun graded(ctx: *context.Context, k: u32, state: u8, top: f32, bottom: f32) bool { + val p: th.Paint = th.style(?ctx.theme, k).states[state]; + ret p.fill.r == top && p.fill_to.r == bottom; +} + +test fill__alone_is_flat_and_fill_to_makes_the_gradient { + var a: allo.Allocator; + var ctx: context.Context; + if (!made(?ctx, ?a)) { ret 1; } + # the dark look shades its buttons, so every face starts as a gradient + val e: err[th.LoadError] = load_theme(?ctx, "base = \"dark\"\n"); + if (sel e.err) { ret 2; } + var was: th.Theme = snapshot(?ctx, ?a); + val top: f32 = th.style(?was, th.BUTTON).states[th.HOT].fill.r; + if (th.style(?was, th.BUTTON).states[th.HOT].fill_to.r == top) { ret 3; } + val red: draw.Color = draw.rgba(1.0::f32, 0.0::f32, 0.0::f32, 1.0::f32); + val dark: draw.Color = draw.rgba(0.5::f32, 0.0::f32, 0.0::f32, 1.0::f32); + + # a pushed fill is flat, a fill_to pushed after it grades it, and the + # pops restore the theme's gradient + push_color(?ctx, "button.*.fill", red); + if (!flat(?ctx, th.BUTTON, th.HOT, 1.0::f32)) { ret 4; } + push_color(?ctx, "button.hot.fill_to", dark); + if (!graded(?ctx, th.BUTTON, th.HOT, 1.0::f32, 0.5::f32)) { ret 5; } + if (!flat(?ctx, th.BUTTON, th.NORMAL, 1.0::f32)) { ret 6; } + pop(?ctx); + if (!flat(?ctx, th.BUTTON, th.HOT, 1.0::f32)) { ret 7; } + pop(?ctx); + if (!same_look(?ctx.theme, ?was)) { ret 8; } + + # a class grades only where it sets fill_to, wherever it lists it + val id: opt[u32] = add_class(?ctx, "graded"); + if (sel id.none) { ret 9; } + set(?ctx, id.some, "button.hot.fill_to", th.of_color(dark)); + set(?ctx, id.some, "button.*.fill", th.of_color(red)); + push_class(?ctx, id.some); + if (!graded(?ctx, th.BUTTON, th.HOT, 1.0::f32, 0.5::f32)) { ret 10; } + if (!flat(?ctx, th.BUTTON, th.ON, 1.0::f32)) { ret 11; } + pop(?ctx); + if (!same_look(?ctx.theme, ?was)) { ret 12; } + + # so does a document, and setting a path + val doc: str = "base = \"dark\"\n[button.hot]\nfill_to = \"#800000\"\nfill = \"#ff0000\"\n[button.on]\nfill = \"#ff0000\"\n"; + val d: err[th.LoadError] = load_theme(?ctx, doc); + if (sel d.err) { ret 13; } + if (!graded(?ctx, th.BUTTON, th.HOT, 1.0::f32, 128.0::f32 / 255.0::f32)) { ret 14; } + if (!flat(?ctx, th.BUTTON, th.ON, 1.0::f32)) { ret 15; } + val p: opt[th.Path] = th.path(?ctx.theme, "button.active.fill"); + if (sel p.none) { ret 16; } + th.set_path(?ctx.theme, p.some, th.of_color(red)); + if (!flat(?ctx, th.BUTTON, th.ACTIVE, 1.0::f32)) { ret 17; } + + # a fill changed on a shaded face saves its fill_to and reads back the same + th.style(?ctx.theme, th.BUTTON).states[th.NORMAL].fill.r = 1.0::f32; + val saved: res[str, allo.Error] = save_theme(?ctx); + if (sel saved.err) { ret 18; } + var b: allo.Allocator; + var back: context.Context; + if (!made(?back, ?b)) { ret 19; } + val e2: err[th.LoadError] = load_theme(?back, saved.ok); + if (sel e2.err) { ret 20; } + if (!same_look(?ctx.theme, ?back.theme)) { ret 21; } + val f: err[allo.Error] = context.free(?ctx); + val f2: err[allo.Error] = context.free(?back); + val f3: err[allo.Error] = th.free(?was); + ret 0; +} + # whether a vertex of the frame's draw list has a premultiplied color's red # and alpha, and how far down the lowest one lies #[testing]