From cf8b9e425e5d98e4ff2cc0d2837dceb5afb35b7a Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 21:59:26 +0300 Subject: [PATCH] feat(ui): nine-slice corners clamped to half the box in each direction, cut on whole pixels Co-Authored-By: Claude Opus 5.5 --- LANGUAGE.md | 4 +++- changes/defaults-views-templates.md | 2 ++ examples/library/ui_nine.ludic | 19 +++++++++++++++++++ packages/ludic.ui/backend.ludic | 2 +- packages/ludic.ui/draw.ludic | 2 +- packages/ludic.ui/draw_nine.ludic | 19 +++++++++++++++++++ packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/render3d_nine.ludic | 10 ++++++---- tools/ludic-cli/test.ludic | 1 + 9 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 examples/library/ui_nine.ludic create mode 100644 packages/ludic.ui/draw_nine.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index 14a29a2c..d6b69b0b 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -532,7 +532,9 @@ applies to both: - Boxes: `border-radius`, `box-shadow` (sharp, offset), `background-image: url(...)`, and `border-image: url(...) slice / width` as a nine-slice. With no background colour a nine-slice is drawn as painted, as CSS draws one; with one it is tinted by it, so one rounded texture serves - every colour, and `transparent` (or any colour with no alpha) leaves nothing to see. + every colour, and `transparent` (or any colour with no alpha) leaves nothing to see. Its + corners are at most half the box across and half of it down, each way on its own, and cut on + whole pixels (`ui_nine_cuts(x, y, w, h, width)` for a renderer of its own). - Text: `text-shadow: x y blur colour` draws the text again under itself, offset (the blur is drawn sharp), and is inherited. `text-fit: shrink 12px` draws a line too long for its box smaller, down to that size, then cuts what still does not fit with an ellipsis (for a chip diff --git a/changes/defaults-views-templates.md b/changes/defaults-views-templates.md index 9aa26562..f58cb2b3 100644 --- a/changes/defaults-views-templates.md +++ b/changes/defaults-views-templates.md @@ -94,6 +94,8 @@ What a game's screens found missing, now in `ludic.ui`: rule), not the one it had so far; - `min()`, `max()` and `clamp()`, in `calc()` or on their own; `top` / `right` / `bottom` / `left` as a percentage or a `calc()` of one, of the containing block; +- a nine-slice's corners are clamped to half the box in each direction on its own and cut on whole + pixels (`ui_nine_cuts`), so a small key cap has no seam; - a transition lands exactly on its end value (it had stopped a rounding error short of it, at every frame rate). diff --git a/examples/library/ui_nine.ludic b/examples/library/ui_nine.ludic new file mode 100644 index 00000000..ca2efdbd --- /dev/null +++ b/examples/library/ui_nine.ludic @@ -0,0 +1,19 @@ +# ui_nine.ludic — a nine-slice's cuts: its corners are at most half the box across and half of it +# down, each direction on its own, so a key cap shorter than two corners keeps them across; the cuts +# land on whole pixels so the pieces meet. +import "ludic.ui" +program UiNine { + numbers float + function cuts(x: float, y: float, w: float, h: float, dst: float) -> string { + let c = ui_nine_cuts(x, y, w, h, dst) + var s = "" + for i in 0 .. 8 { + if i == 4 { s = s + "/" } + s = s + ` {int(c[i])}` + } + return s + } + entry { + print(`cap{cuts(0.0, 0.0, 30.0, 16.0, 12.0)} | wide{cuts(0.0, 0.0, 100.0, 40.0, 12.0)} | odd{cuts(10.4, 3.6, 20.3, 9.0, 14.0)}`) + } +} diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic index 9e011d8e..e617033a 100644 --- a/packages/ludic.ui/backend.ludic +++ b/packages/ludic.ui/backend.ludic @@ -8,7 +8,7 @@ export property UiBackend { text: fn(float, float, string, float, int, float) -> void = null # x y text size rgb alpha measure: fn(string, float) -> float = null image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha - nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha + nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha (ui_nine_cuts) clip: fn(float, float, float, float) -> void = null unclip: fn() -> void = null scale: fn() -> float = null # screen pixels per design pixel diff --git a/packages/ludic.ui/draw.ludic b/packages/ludic.ui/draw.ludic index 24307e87..09cdcb27 100644 --- a/packages/ludic.ui/draw.ludic +++ b/packages/ludic.ui/draw.ludic @@ -42,7 +42,7 @@ function fr_background(n: UiNode) -> void { } function fr_fill(n: UiNode, x: float, y: float, c: int, a: float) -> void { if n.bi_src != "" and ui_be.nine != null { - ui_be.nine(n.bi_src, n.bi_slice, x, y, n.cw, n.ch, Math.min(n.bi_width, Math.min(n.cw, n.ch) / 2.0), c, a) + ui_be.nine(n.bi_src, n.bi_slice, x, y, n.cw, n.ch, n.bi_width, c, a) return } fr_box(x, y, n.cw, n.ch, n.radius, c, a) diff --git a/packages/ludic.ui/draw_nine.ludic b/packages/ludic.ui/draw_nine.ludic new file mode 100644 index 00000000..88b6425b --- /dev/null +++ b/packages/ludic.ui/draw_nine.ludic @@ -0,0 +1,19 @@ +# draw_nine.ludic - where a nine-slice is cut: its corners as wide as border-image says, but never +# more than half the box across or half of it down (each direction on its own, so a small key cap +# keeps its round ends and has no seam), on whole pixels so the nine pieces meet exactly +# x0 x1 x2 x3 then y0 y1 y2 y3: the columns' and the rows' edges +export function ui_nine_cuts(x: float, y: float, w: float, h: float, dst: float) -> []float { + let dx = Math.max(Math.min(dst, w / 2.0), 0.0) + let dy = Math.max(Math.min(dst, h / 2.0), 0.0) + let out = new []float + push(out, nn_px(x)) + push(out, nn_px(x + dx)) + push(out, nn_px(x + w - dx)) + push(out, nn_px(x + w)) + push(out, nn_px(y)) + push(out, nn_px(y + dy)) + push(out, nn_px(y + h - dy)) + push(out, nn_px(y + h)) + return out +} +function nn_px(v: float) -> float { return Math.floor(v + 0.5) } diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index 5adf31c1..184f3b8a 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -83,4 +83,5 @@ import "screen_class.ludic" import "draw.ludic" import "draw_content.ludic" import "draw_order.ludic" +import "draw_nine.ludic" import "dump.ludic" diff --git a/packages/ludic.ui/render3d_nine.ludic b/packages/ludic.ui/render3d_nine.ludic index b87f913f..f5982044 100644 --- a/packages/ludic.ui/render3d_nine.ludic +++ b/packages/ludic.ui/render3d_nine.ludic @@ -1,14 +1,16 @@ # render3d_nine.ludic - border-image for the render3d backend: a nine-slice of a texture module ludic_ui -# border-image: the texture's `slice`-pixel corners kept, the rest stretched, drawn `dst` wide; the -# slice is a share of the texture's own width across and its own height down, so it need not be square +# border-image: the texture's `slice`-pixel corners kept, the rest stretched, drawn `dst` wide (at +# most half the box each way, ui_nine_cuts); the slice is a share of the texture's own width across +# and its own height down, so it need not be square function r3u_nine(src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void { let t = r3u_tex(src) if t == 0 { return } let su = slice / float(Math.max(tex_width(t), 1)) let sv = slice / float(Math.max(tex_height(t), 1)) - let xs = r3u_four(x, x + dst, x + w - dst, x + w) - let ys = r3u_four(y, y + dst, y + h - dst, y + h) + let cut = ui_nine_cuts(x, y, w, h, dst) + let xs = r3u_four(cut[0], cut[1], cut[2], cut[3]) + let ys = r3u_four(cut[4], cut[5], cut[6], cut[7]) let us = r3u_four(0.0, su, 1.0 - su, 1.0) let vs = r3u_four(0.0, sv, 1.0 - sv, 1.0) for j in 0 .. 3 { diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 441cdc7e..63b4ec94 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -768,6 +768,7 @@ function cmd_dev_test() -> int { controller_case("library/ui_popover_align", "", "centred, flipped in the panel 90,40 | end below 110,70 | above b in the list 0 inside 1", "ui_popover_align.ludic (L11: anchored popovers aligned along their side, flipped and kept within a named container or their scroll box)") controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)") controller_case("library/ui_calc", "", "min 200 max 82 clamp 50 21 | p 150,60 q 255,80 r 30", "ui_calc.ludic (L11: min(), max() and clamp(), and insets as percentages and calc() of the containing block)") + controller_case("library/ui_nine", "", "cap 0 12 18 30/ 0 8 8 16 | wide 0 12 88 100/ 0 12 28 40 | odd 10 21 21 31/ 4 8 8 13", "ui_nine.ludic (L11: a nine-slice's corners clamped to half the box each way, on whole pixels)") controller_case("library/ui_inputs", "", "count 12 12 32 0.5 0 | 20 32 0.5 0 | 19 32 0.5 0 | How many to carry | Space bar listening 1 19 9 0.5 0 | 19 9 0.5 0 | 19 0 0.5 0 | 60% 0.50 s | 19 0 0.6 9 | 7!", "ui_inputs.ludic (L11: number fields, key capture with Esc and Backspace, notes, range formats, an event named set, a string prop given a number)") controller_case("library/ui_boxes", "", "list 120 of 200 | text at 32 152 wide, 3 lines, row 60 | zero 0x0 | half 140x36 pad 4 2 | order abc at 16 | fill 98x8 of 200x12", "ui_boxes.ludic (L11: flex-shrink to the leftover room, text wrapping beside its siblings, zero sizes, calc(), order, the range track as a row)") controller_case("library/ui_look", "", "root div.in.i.m pad 5 margin 9 color #ff0000 text Hi 2,53 #102030 | text Hi 0,50 #eeeeee | opacity 0.25 | nine 40 #ffffff nine 40 #ff0000 of 2 image photo.png #ffffff | image icon:tent #00ff00 | text first line 29,205 #eeeeee | text second line 29,223 #eeeeee by id at 84,50 | flipped at 246,268 100x32", "ui_look.ludic (L11: nested components styled by specificity, text-shadow, ui_opacity, border-image tints, pictures, tooltip lines, anchored popovers)")