diff --git a/LANGUAGE.md b/LANGUAGE.md index 8da9eae1..5b9cec36 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -444,6 +444,9 @@ applies to both: it is let go. - A scroll box (`overflow: auto` or ``) takes the wheel, has a scrollbar that can be dragged (`scrollbar-color`), clips what it holds and pulls the focused control into view. + - The scrollbar is `.ui-scrollbar` with a `.ui-thumb`, styled like a control's parts. A press on + the thumb holds it where it was taken and the pointer moves it in proportion; a press on the + track jumps the thumb's middle there and holds it. A press on the bar presses nothing under it. - `:focus`, `:focus-visible` and `:focus-within` style the focus, and `outline` (which follows `border-radius`) draws the ring. - A test drives all of it with `ui_input(i)`. @@ -474,6 +477,8 @@ applies to both: - **Popovers, tooltips and bars.** - `
` sits absolutely in its parent and draws on the top layer. While it is up, the pointer and Tab stay inside it, and a press outside it or Esc closes it. + A press on it goes to its own controls, never to what lies under it at the same pixels, and a + press outside it only closes it. - `anchor="cell"` puts a popover beside the element with that id (the nearest, looking out from the popover), and a bare `anchor` beside the element before it. `placement` is `right` (the default), `left`, `bottom` or `top`, its margin on that side is the gap, and where it would @@ -511,7 +516,8 @@ applies to both: `color` around it, as text does. An `` with a `color` of its own is tinted by it. - Motion: `opacity` fades the element and everything inside it. `@keyframes` with `animation: name 0.25s [infinite] [alternate]` animate numeric properties from when the element first - appeared, and `transition: opacity 0.2s` eases a changed opacity. + appeared, and `transition: opacity 0.2s` eases a changed opacity. Both run on the clock, in + float, and land exactly on their end values whatever the frame rate. - **Mixed content.** Text beside elements keeps its place: ``, `

Hi there

`. A boolean attribute present with no value is true, as in HTML (` +
+ + +

opened {opened} dropped {dropped} closed {closed} {open}

+ diff --git a/examples/library/ui_scrollbar.ludic b/examples/library/ui_scrollbar.ludic new file mode 100644 index 00000000..b411078e --- /dev/null +++ b/examples/library/ui_scrollbar.ludic @@ -0,0 +1,52 @@ +# ui_scrollbar.ludic — a scroll box's bar held and dragged: a press on the thumb holds it where it +# was taken, the pointer held moves it in proportion, letting go leaves it; a press on the track +# jumps the thumb's middle there; the wheel still scrolls; and the rows under the bar are never pressed. +import "ludic.ui" +import "ui_scrollbar_parts/Long.ludic" +program UiScrollbar { + numbers float + var bx: float = 0.0 + var by: float = 0.0 + function frame(i: UiInput) -> UiNode { + ui_input(i) + ui_show("Long", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes("Long", null) + ui_place(root, 0.0, 0.0, 400.0, 400.0) + return root.children[0] + } + # the pointer at (bx + x, by + y), held or not; how far the box is scrolled then + function at(x: float, y: float, down: bool) -> int { + let i = new UiInput + i.x = bx + x + i.y = by + y + i.down = down + return int(frame(i).children[0].scroll_y) + } + function wheel(x: float, y: float) -> int { + let i = new UiInput + i.x = bx + x + i.y = by + y + i.wheel = 1.0 + frame(i) + return int(frame(new UiInput).children[0].scroll_y) + } + entry { + ui_backend(new UiBackend) + let list = frame(new UiInput).children[0] + bx = list.x + by = list.y + let grab = at(195.0, 10.0, true) + let moved = at(195.0, 40.0, true) + let far = at(195.0, 300.0, true) + let back = at(195.0, 25.0, true) + let kept = at(195.0, 25.0, false) + at(0.0 - bx - 1.0, 0.0 - by - 1.0, false) + let jump = at(195.0, 80.0, true) + at(195.0, 80.0, false) + let wheeled = wheel(50.0, 50.0) + at(50.0, 20.0, true) + at(50.0, 20.0, false) + let page = frame(new UiInput) + print(`grab {grab} moved {moved} far {far} back {back} kept {kept} | jump {jump} | wheel {wheeled} | {page.children[1].text}`) + } +} diff --git a/examples/library/ui_scrollbar_parts/Long.lss b/examples/library/ui_scrollbar_parts/Long.lss new file mode 100644 index 00000000..5cee44f2 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.lss @@ -0,0 +1,3 @@ +.page { width: 300px; height: 300px } +.list { overflow: auto; width: 200px; height: 100px } +.row { width: fill; height: 40px; padding: 0; border-radius: 0 } diff --git a/examples/library/ui_scrollbar_parts/Long.ludic b/examples/library/ui_scrollbar_parts/Long.ludic new file mode 100644 index 00000000..bbdf2132 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.ludic @@ -0,0 +1,5 @@ +# Long.ludic - a box of ten rows, four times taller than it shows; each row is a button as wide as +# the box, so the scrollbar sits over them +component Long { + state pressed: int = 0 +} diff --git a/examples/library/ui_scrollbar_parts/Long.xml b/examples/library/ui_scrollbar_parts/Long.xml new file mode 100644 index 00000000..08daf0ea --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.xml @@ -0,0 +1,4 @@ +
+
+

pressed {pressed}

+
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index 67088e9c..38ff3596 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -23,6 +23,7 @@ import "controls_number.ludic" import "input.ludic" import "focus.ludic" import "scroll.ludic" +import "scroll_bar.ludic" import "popover.ludic" import "popover_anchor.ludic" import "tooltip.ludic" diff --git a/packages/ludic.ui/pointer.ludic b/packages/ludic.ui/pointer.ludic index 18530630..509ba011 100644 --- a/packages/ludic.ui/pointer.ludic +++ b/packages/ludic.ui/pointer.ludic @@ -1,6 +1,6 @@ -# pointer.ludic - the pointer: what it is over is :hover (the element and all around it, as in CSS), -# a press on a control focuses it and makes it :active, and letting go over it is a click. The -# control being dragged (a range's thumb, a scrollbar) keeps the pointer until it is let go. +# pointer.ludic - the pointer: what it is over is :hover (and all around it, as in CSS), a press on a +# control focuses it and makes it :active, letting go over it is a click. A dragged thumb or scrollbar +# keeps the pointer until let go; a press that takes a scrollbar or closes a popover presses nothing else. var fr_hover: []string = new []string var fr_press_key: string = "" var fr_down_now: bool = false @@ -16,6 +16,7 @@ function fr_pointer(root: UiNode) -> void { fr_hover = keys tt_track(scope, i.x, i.y) if top != null and pp_pointer(top) { return } + if sc_drag != "" { return } let hit = fr_hit(scope, i.x, i.y) if i.down and not in_was_down { ct_key_pressed_away(hit) diff --git a/packages/ludic.ui/popover.ludic b/packages/ludic.ui/popover.ludic index 0ba0a3f4..b605a76d 100644 --- a/packages/ludic.ui/popover.ludic +++ b/packages/ludic.ui/popover.ludic @@ -22,6 +22,7 @@ function pp_pointer(top: UiNode) -> bool { let i = in_now if not (i.down and not in_was_down) { return false } if i.x >= top.x and i.y >= top.y and i.x < top.x + top.cw and i.y < top.y + top.ch { return false } + fr_press_key = "" ui_fire(top, "close", Value.null()) return true } diff --git a/packages/ludic.ui/scroll.ludic b/packages/ludic.ui/scroll.ludic index acc60d4e..22829934 100644 --- a/packages/ludic.ui/scroll.ludic +++ b/packages/ludic.ui/scroll.ludic @@ -1,9 +1,10 @@ -# scroll.ludic - a box with overflow (or ) scrolls itself: the wheel over it, a scrollbar -# thumb dragged, and the focused control pulled into view. Its content is shifted by how far it is +# scroll.ludic - a box with overflow (or ) scrolls itself: the wheel over it, its scrollbar +# held and dragged, and the focused control pulled into view. Its content is shifted by how far it is # scrolled and clipped to it, and the offset is kept by its key from frame to frame. var sc_keys: []string = new []string var sc_offs: []float = new []float var sc_drag: string = "" +var sc_scope: UiNode = null # the popover that has the pointer this frame, if one is up function sc_get(key: string) -> float { for i in 0 .. len(sc_keys) { if sc_keys[i] == key { return sc_offs[i] } @@ -21,7 +22,11 @@ function sc_put(key: string, v: float) -> void { push(sc_offs, v) } # every scroll box, outermost first: its offset (from input, when `live`), then its content moved -function sc_update(n: UiNode) -> void { sc_walk(n, true) } +function sc_update(n: UiNode) -> void { + sc_scope = pp_top(n) + if not in_now.down { sc_drag = "" } + sc_walk(n, true) +} function sc_apply(n: UiNode) -> void { sc_walk(n, false) } function sc_walk(n: UiNode, live: bool) -> void { if n.kind == UI_SCROLL { @@ -38,17 +43,25 @@ function sc_walk(n: UiNode, live: bool) -> void { } for k in 0 .. len(n.children) { sc_walk(n.children[k], live) } } -# the wheel over it, its thumb dragged, the keyboard's focus pulled into view +# the wheel over it, its bar held and dragged (scroll_bar.ludic), the keyboard's focus pulled into +# view; while a popover is up, only a box inside it answers the pointer function sc_input(n: UiNode, off0: float, room: float, most: float) -> float { var off = off0 + if sc_scope != null and not sc_within(n, sc_scope) { return sc_into_view(n, off, room) } let i = in_now let over = i.x >= n.x and i.y >= n.y and i.x < n.x + n.cw and i.y < n.y + n.ch if over and i.wheel != 0.0 { off = off - i.wheel * ui_px(40.0) } - if i.down and not in_was_down and over and i.x >= n.x + n.cw - ui_px(14.0) and most > 0.0 { sc_drag = n.key } - if not i.down { sc_drag = "" } - if sc_drag == n.key and room > 0.0 { off = (i.y - n.y) / room * (most + room) - room / 2.0 } + off = sb_input(n, off, room, most) return sc_into_view(n, off, room) } +function sc_within(n: UiNode, top: UiNode) -> bool { + var at = n + while at != null { + if at == top { return true } + at = at.parent + } + return false +} # the focused control, moved by the keyboard, is brought into the box's view function sc_into_view(n: UiNode, off: float, room: float) -> float { if not fc_visible or fc_key == "" or not lc_mine(fc_key, n.key) { return off } @@ -59,22 +72,6 @@ function sc_into_view(n: UiNode, off: float, room: float) -> float { if top + f.ch > off + room { return top + f.ch - room + ui_px(8.0) } return off } -# the scrollbar: a thumb on a track down the box's right edge, when there is more than it shows -function sc_bar(n: UiNode) -> void { - let room = n.ch - ly_vin(n) - let total = n.content_h - ly_vin(n) - if total <= room or ui_be.rect == null { return } - let w = ui_px(6.0) - let x = n.x + n.cw - w - ui_px(2.0) - var track = n.bar_track - if track == -2 { track = 3355443 } - var thumb = n.bar_c - if thumb < 0 { thumb = 10066329 } - if track >= 0 { ui_be.rect(x, n.y, w, n.ch, track, 0.4 * fr_ga) } - let th = Math.max(n.ch * room / total, ui_px(24.0)) - let ty = n.y + (n.ch - th) * n.scroll_y / (total - room) - ui_be.rect(x, ty, w, th, thumb, 0.85 * fr_ga) -} # ui_place moved every box by its stored offset; the frame takes that back before moving it again function sc_undo(n: UiNode) -> void { if n.kind == UI_SCROLL and n.scroll_y != 0.0 { diff --git a/packages/ludic.ui/scroll_bar.ludic b/packages/ludic.ui/scroll_bar.ludic new file mode 100644 index 00000000..31199322 --- /dev/null +++ b/packages/ludic.ui/scroll_bar.ludic @@ -0,0 +1,63 @@ +# scroll_bar.ludic - a scroll box's bar: .ui-scrollbar, the track down its right edge, and its +# .ui-thumb, styled by the default sheet or a theme as the box's own parts. A press on the thumb holds +# it where it was taken; a press on the track jumps the thumb's middle to the pointer and holds it there. +var sb_x: float = 0.0 +var sb_w: float = 0.0 +var sb_ty: float = 0.0 +var sb_th: float = 0.0 +var sb_bar: UiNode = null +var sc_grab: float = 0.0 # how far below the thumb's top the pointer holds it +# the bar's parts, styled as descendants of the box, and where they are at this offset +function sb_geo(n: UiNode, off: float, room: float, most: float) -> void { + let bar = ct_part(n, "ui-scrollbar", "") + bar.tag = "div" + bar.parent = n + push(bar.children, ct_part(n, "ui-thumb", "")) + cs_tree(bar) + sb_bar = bar + sb_w = ui_px(6.0) + if bar.w >= 0.0 { sb_w = bar.w } + sb_x = n.x + n.cw - sb_w - bar.mr + var least = ui_px(24.0) + if len(bar.children) > 0 and bar.children[0].min_h > 0.0 { least = bar.children[0].min_h } + sb_th = Math.min(Math.max(n.ch * room / (room + most), least), n.ch) + sb_ty = n.y + if most > 0.0 { sb_ty = n.y + (n.ch - sb_th) * Math.clamp(off, 0.0, most) / most } +} +# the pointer on the bar: taken on a press over it, moved while held, let go on release +function sb_input(n: UiNode, off: float, room: float, most: float) -> float { + let i = in_now + if most <= 0.0 or room <= 0.0 { return off } + if i.down and not in_was_down and sc_drag == "" { + sb_geo(n, off, room, most) + let reach = Math.min(sb_x, n.x + n.cw - ui_px(14.0)) + let over = i.x >= reach and i.y >= n.y and i.x < n.x + n.cw and i.y < n.y + n.ch + if over and not sb_bar.hidden { + sc_drag = n.key + sc_grab = sb_th / 2.0 + if i.y >= sb_ty and i.y < sb_ty + sb_th { sc_grab = i.y - sb_ty } + } + } + if sc_drag != n.key { return off } + sb_geo(n, off, room, most) + let span = n.ch - sb_th + if span <= 0.0 { return off } + return (i.y - sc_grab - n.y) / span * most +} +# the track, then the thumb over it; scrollbar-color on the box wins over the parts' backgrounds +function sc_bar(n: UiNode) -> void { + let room = n.ch - ly_vin(n) + let most = n.content_h - ly_vin(n) - room + if most <= 0.0 or ui_be.rect == null { return } + sb_geo(n, n.scroll_y, room, most) + let bar = sb_bar + if bar.hidden { return } + var track = bar.bg + if n.bar_track != -2 { track = n.bar_track } + if track >= 0 { fr_box(sb_x, n.y, sb_w, n.ch, bar.radius, track, bar.bg_a * fr_ga) } + if len(bar.children) == 0 { return } + let t = bar.children[0] + var thumb = t.bg + if n.bar_c >= 0 { thumb = n.bar_c } + if thumb >= 0 { fr_box(sb_x, sb_ty, sb_w, sb_th, t.radius, thumb, t.bg_a * fr_ga) } +} diff --git a/packages/ludic.ui/transition.ludic b/packages/ludic.ui/transition.ludic index 7a034092..83861716 100644 --- a/packages/ludic.ui/transition.ludic +++ b/packages/ludic.ui/transition.ludic @@ -27,5 +27,6 @@ function tr_apply(n: UiNode) -> void { } function tr_value(i: int, now: float, dur: float) -> float { let t = Math.clamp((now - tr_at[i]) / dur, 0.0, 1.0) + if t >= 1.0 { return tr_to[i] } return tr_from[i] + (tr_to[i] - tr_from[i]) * t } diff --git a/packages/ludic.ui/ua.ludic b/packages/ludic.ui/ua.ludic index 5b0d0611..3798637b 100644 --- a/packages/ludic.ui/ua.ludic +++ b/packages/ludic.ui/ua.ludic @@ -1,4 +1,4 @@ # ua.ludic - what every element looks like before a program styles it, as a browser's own sheet # does: headings sized, buttons and controls usable and plain, a focus ring. A theme overrides any # of it, with the same selectors or with the variables it reads. -const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 } button { align-items: center; justify-content: center; gap: 8; padding: 6 14; background: #3a3f46; border-radius: 4 } button:hover { background: #4a5058 } button:disabled { opacity: 0.4 } :focus-visible { outline: 2px solid #ffcc55; outline-offset: 2 } input, select { align-items: center; gap: 10; padding: 6 10 } .ui-label { flex-grow: 1 } .ui-labels { flex-grow: 1; gap: 2 } .ui-labels .ui-label { flex-grow: 0 } .ui-note { font-size: 13; color: #9aa0a6 } .ui-track { flex-direction: row; align-items: center; width: 44; height: 22; padding: 3; background: #555a60; border-radius: 11 } .ui-knob { width: 16; height: 16; background: #eeeeee; border-radius: 8 } input:checked .ui-track { justify-content: end; background: #d9a54a } input[type=range] .ui-track { width: 200; height: 8; padding: 0; border-radius: 4 } .ui-fill { height: fill; background: #d9a54a; justify-content: end; align-items: center; border-radius: 4 } .ui-thumb { width: 14; height: 14; background: #eeeeee; border-radius: 7; margin: 0 -7 } .ui-value { min-width: 40 } .ui-field { width: 200; padding: 4 8; background: #202428; border: 1px solid #555a60 } .ui-caret { width: 2; height: 1.2em; background: #eeeeee } .ui-prev, .ui-next { color: #aaaaaa } progress, meter { width: 160; height: 10; background: #202428; border-radius: 5 } progress .ui-fill, meter .ui-fill { justify-content: start; border-radius: 5 } [popover] { position: absolute; z-index: 900 } .ui-tooltip { position: fixed; padding: 6 10; background: #202428; border: 1px solid #555a60; font-size: 15; z-index: 1000 }" +const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 } button { align-items: center; justify-content: center; gap: 8; padding: 6 14; background: #3a3f46; border-radius: 4 } button:hover { background: #4a5058 } button:disabled { opacity: 0.4 } :focus-visible { outline: 2px solid #ffcc55; outline-offset: 2 } input, select { align-items: center; gap: 10; padding: 6 10 } .ui-label { flex-grow: 1 } .ui-labels { flex-grow: 1; gap: 2 } .ui-labels .ui-label { flex-grow: 0 } .ui-note { font-size: 13; color: #9aa0a6 } .ui-track { flex-direction: row; align-items: center; width: 44; height: 22; padding: 3; background: #555a60; border-radius: 11 } .ui-knob { width: 16; height: 16; background: #eeeeee; border-radius: 8 } input:checked .ui-track { justify-content: end; background: #d9a54a } input[type=range] .ui-track { width: 200; height: 8; padding: 0; border-radius: 4 } .ui-fill { height: fill; background: #d9a54a; justify-content: end; align-items: center; border-radius: 4 } .ui-thumb { width: 14; height: 14; background: #eeeeee; border-radius: 7; margin: 0 -7 } .ui-value { min-width: 40 } .ui-field { width: 200; padding: 4 8; background: #202428; border: 1px solid #555a60 } .ui-caret { width: 2; height: 1.2em; background: #eeeeee } .ui-prev, .ui-next { color: #aaaaaa } progress, meter { width: 160; height: 10; background: #202428; border-radius: 5 } progress .ui-fill, meter .ui-fill { justify-content: start; border-radius: 5 } .ui-scrollbar { width: 6; margin: 0 2 0 0; background: rgba(51, 51, 51, 0.4); border-radius: 3 } .ui-scrollbar .ui-thumb { min-height: 24; margin: 0; background: rgba(153, 153, 153, 0.85); border-radius: 3 } [popover] { position: absolute; z-index: 900 } .ui-tooltip { position: fixed; padding: 6 10; background: #202428; border: 1px solid #555a60; font-size: 15; z-index: 1000 }" diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index d634f0fc..bcbf9a49 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -760,6 +760,9 @@ function cmd_dev_test() -> int { controller_case("library/ui_controls", "", "1 false 7 2 Adalove 70 1 false 8 2 Adalove 70; scrolled 40", "ui_controls.ludic (L11: focus and keys, the built-in controls, a range dragged, a box scrolled)") controller_case("library/ui_anim", "", "0 100 100 | 49 50 100 | 100 100 61", "ui_anim.ludic (L11: @keyframes and animation, looping and alternating, and a transition)") controller_case("library/ui_popover", "", "drop false | drop true | drop false | drop false | fill 60 of 200", "ui_popover.ludic (L11: a popover keeps the keys and closes on a press outside or Esc; progress fills)") + controller_case("library/ui_popover_mouse", "", "over the cell 1 | opened 1 dropped 0 closed 0 true | opened 1 dropped 1 closed 0 false | opened 2 dropped 1 closed 1 false | opened 3 dropped 1 closed 2 false", "ui_popover_mouse.ludic (L11: a popover takes the mouse - its own button wins over the cell under it, and a press outside closes it and presses nothing)") + controller_case("library/ui_scrollbar", "", "grab 0 moved 120 far 300 back 60 kept 60 | jump 270 | wheel 230 | pressed 1", "ui_scrollbar.ludic (L11: a scrollbar's thumb held where it was taken and dragged, a press on the track jumps there, the wheel, nothing under the bar pressed)") + controller_case("library/ui_ease", "", "60: 1480 680 slid 1 landed 1 left 0; 120: 1480 680 slid 1 landed 1 left 0; 180: 1480 680 slid 1 landed 1 left 0; 240: 1480 680 slid 1 landed 1 left 0;", "ui_ease.ludic (L11: a transition and an animation at 60, 120, 180 and 240 Hz are the same half way and land exactly on their targets)") 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)")