tuile 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/sig/tuile.rbs CHANGED
@@ -2227,7 +2227,13 @@ module Tuile
2227
2227
  end
2228
2228
 
2229
2229
  # A layout doesn't paint anything by itself: its job is to position child
2230
- # components.
2230
+ # components. Two families, both top-down (see book ch3):
2231
+ #
2232
+ # - {Absolute} — you override {Component#rect=} and compute every child's
2233
+ # rectangle yourself. Total control, and the base for anything unusual.
2234
+ # - {Box} / {Vertical} / {Horizontal} — you declare each child's extent as
2235
+ # a {Fixed}, {Percent} or {Expand} constraint and the layout does the
2236
+ # arithmetic. Sugar over the same `rect=` assignment, for the common case.
2231
2237
  #
2232
2238
  # Children that fully tile the layout's rect repaint themselves and
2233
2239
  # cover everything; children that leave gaps (e.g. a form with widgets
@@ -2260,10 +2266,563 @@ module Tuile
2260
2266
 
2261
2267
  def on_focus: () -> void
2262
2268
 
2269
+ # How much space a child gets along one axis of a {Box}: exactly {#cells},
2270
+ # clamped to whatever is still unassigned.
2271
+ #
2272
+ # add(prompt, Fixed[4]) # 4 rows in a Vertical
2273
+ # add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
2274
+ #
2275
+ # `Fixed[0]` hides the child — it gets an empty rect and paints nothing.
2276
+ #
2277
+ # @!attribute [r] cells
2278
+ # @return [Integer] cell count along the axis.
2279
+ class Fixed
2280
+ # _@param_ `cells` — cell count along the axis; `>= 0`.
2281
+ def initialize: (cells: Integer) -> void
2282
+
2283
+ # _@return_ — cell count along the axis.
2284
+ attr_reader cells: Integer
2285
+ end
2286
+
2287
+ # A percentage of the space *available* along a {Box}'s axis — measured
2288
+ # after {Box#padding} and {Box#spacing} have come off, so two `Percent[50]`
2289
+ # children fit exactly rather than overflowing by the gap between them.
2290
+ #
2291
+ # add(left, Percent[60])
2292
+ # add(right, Percent[40])
2293
+ #
2294
+ # @!attribute [r] percent
2295
+ # @return [Numeric] percentage of the available extent, `0..100`.
2296
+ class Percent
2297
+ # _@param_ `percent` — percentage of the available extent, `0..100`.
2298
+ def initialize: (percent: Numeric) -> void
2299
+
2300
+ # _@return_ — percentage of the available extent, `0..100`.
2301
+ attr_reader percent: Numeric
2302
+ end
2303
+
2304
+ # A share of whatever a {Box} has left once its {Fixed} and {Percent}
2305
+ # children have taken theirs, split between the `Expand` children in
2306
+ # proportion to their weights:
2307
+ #
2308
+ # add(header, Fixed[1])
2309
+ # add(body, Expand[2]) # gets twice…
2310
+ # add(side, Expand[1]) # …what this one gets
2311
+ #
2312
+ # Main axis only — {Box#add} rejects one passed as `cross:`, where a child
2313
+ # has no siblings to compete with and so nothing for a weight to mean.
2314
+ #
2315
+ # @!attribute [r] weight
2316
+ # @return [Integer] relative share of the leftover space.
2317
+ class Expand
2318
+ # _@param_ `weight` — relative share; `>= 1`.
2319
+ def initialize: (weight: Integer) -> void
2320
+
2321
+ # _@return_ — relative share of the leftover space.
2322
+ attr_reader weight: Integer
2323
+ end
2324
+
2325
+ # Per-edge padding for a {Box}, in cells:
2326
+ #
2327
+ # Insets[top: 1] # one blank row above the children
2328
+ # Insets[top: 1, left: 2, right: 2] # unnamed edges default to 0
2329
+ # Insets.coerce(1) # uniform on all four edges
2330
+ #
2331
+ # Keyword-only: AWT and JavaFX order these same four numbers differently,
2332
+ # so a positional form would be a coin flip.
2333
+ #
2334
+ # @!attribute [r] top
2335
+ # @return [Integer] cells inset from the top edge.
2336
+ # @!attribute [r] right
2337
+ # @return [Integer] cells inset from the right edge.
2338
+ # @!attribute [r] bottom
2339
+ # @return [Integer] cells inset from the bottom edge.
2340
+ # @!attribute [r] left
2341
+ # @return [Integer] cells inset from the left edge.
2342
+ class Insets
2343
+ ZERO: Insets
2344
+
2345
+ # _@param_ `positional` — must be empty — see the class doc.
2346
+ #
2347
+ # _@param_ `kwargs` — any of `top:`/`right:`/`bottom:`/`left:`.
2348
+ def self.new: (*::Array[untyped] positional, **::Hash[Symbol, Integer] kwargs) -> Insets
2349
+
2350
+ # Needed because `Data`'s inherited `[]` never dispatches through a `new`
2351
+ # override, so the guard above alone would miss `Insets[1, 2, 3, 4]`.
2352
+ #
2353
+ # _@param_ `positional` — must be empty.
2354
+ #
2355
+ # _@param_ `kwargs` — any of `top:`/`right:`/`bottom:`/`left:`.
2356
+ def self.[]: (*::Array[untyped] positional, **::Hash[Symbol, Integer] kwargs) -> Insets
2357
+
2358
+ # _@param_ `value` — an Integer becomes a uniform inset.
2359
+ def self.coerce: ((Insets | Integer) value) -> Insets
2360
+
2361
+ # _@param_ `top` — cells inset from the top edge; `>= 0`.
2362
+ #
2363
+ # _@param_ `right` — cells inset from the right edge; `>= 0`.
2364
+ #
2365
+ # _@param_ `bottom` — cells inset from the bottom edge; `>= 0`.
2366
+ #
2367
+ # _@param_ `left` — cells inset from the left edge; `>= 0`.
2368
+ def initialize: (
2369
+ ?_top: Integer,
2370
+ ?right: Integer,
2371
+ ?bottom: Integer,
2372
+ ?left: Integer
2373
+ ) -> void
2374
+
2375
+ # _@return_ — `left` + `right`.
2376
+ def horizontal: () -> Integer
2377
+
2378
+ # _@return_ — `top` + `bottom`.
2379
+ def vertical: () -> Integer
2380
+
2381
+ # _@return_ — cells inset from the top edge.
2382
+ attr_reader top: Integer
2383
+
2384
+ # _@return_ — cells inset from the right edge.
2385
+ attr_reader right: Integer
2386
+
2387
+ # _@return_ — cells inset from the bottom edge.
2388
+ attr_reader bottom: Integer
2389
+
2390
+ # _@return_ — cells inset from the left edge.
2391
+ attr_reader left: Integer
2392
+ end
2393
+
2263
2394
  # Absolute layout. Extend this class, register any children, and
2264
2395
  # override {Component#rect=} to reposition the children.
2265
2396
  class Absolute < Layout
2266
2397
  end
2398
+
2399
+ # Abstract base of the one-dimensional box layouts. Children are stacked
2400
+ # along a *main* axis in the order they were added, each getting the extent
2401
+ # its constraint asks for; across the *cross* axis they are sized one at a
2402
+ # time, since nothing competes with them there. {Vertical} and {Horizontal}
2403
+ # pick which axis is which.
2404
+ #
2405
+ # class LoginForm < Tuile::Component::Layout::Vertical
2406
+ # def initialize
2407
+ # super(spacing: 1, padding: Insets[top: 1])
2408
+ # add(@prompt = Tuile::Component::Label.new, Fixed[4])
2409
+ # add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
2410
+ # add(@log = Tuile::Component::TextView.new, Expand[1])
2411
+ # end
2412
+ # end
2413
+ #
2414
+ # The constraint names need no prefix inside a subclass — Ruby finds them on
2415
+ # `Layout`, an ancestor. Component classes are not on that chain and still do.
2416
+ #
2417
+ # Children pack from the start edge, so with no {Expand} among them the
2418
+ # slack is simply left at the end: there is no filler component to add.
2419
+ # Nest boxes to vary the gap — a `Vertical.new(spacing: 0)` inside a
2420
+ # `Vertical.new(spacing: 1)` groups two rows tightly within a looser stack.
2421
+ #
2422
+ # == Implementation details
2423
+ #
2424
+ # Every child-list mutation re-runs the whole pass, because in a box the
2425
+ # children move: removing one shifts everything after it, and adding one
2426
+ # shrinks every {Expand} share. ({Absolute} can skip this — there, siblings
2427
+ # are independent.)
2428
+ #
2429
+ # Main-axis resolution order, against
2430
+ # `available = extent - padding - spacing * (children - 1)`:
2431
+ #
2432
+ # 1. {Fixed} takes its cells, clamped to what is still unassigned.
2433
+ # 2. {Percent} takes its share *of `available`*, likewise clamped.
2434
+ # 3. {Expand} children split the residue by weight; the integer remainder
2435
+ # goes to the earliest of them, one cell each.
2436
+ #
2437
+ # So over-subscription starves in declaration order rather than raising:
2438
+ # a child with nothing left gets an empty rect and paints nothing. Padding
2439
+ # wider than the layout does the same to every child.
2440
+ class Box < Layout
2441
+ DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
2442
+ ALIGNMENTS: ::Array[Symbol]
2443
+
2444
+ # _@param_ `spacing` — blank cells between adjacent children; `>= 0`.
2445
+ #
2446
+ # _@param_ `padding` — inset from this layout's own rect; an Integer is coerced to a uniform {Insets}.
2447
+ def initialize: (?spacing: Integer, ?padding: (Insets | Integer)) -> void
2448
+
2449
+ # Adds a child — or every element of an Enumerable, all with the same
2450
+ # constraints — and re-runs the layout.
2451
+ #
2452
+ # add(field, Fixed[1], cross: Fixed[30], align: :center)
2453
+ # add([ok, cancel], Fixed[1])
2454
+ #
2455
+ # _@param_ `child`
2456
+ #
2457
+ # _@param_ `main` — extent along the main axis.
2458
+ #
2459
+ # _@param_ `cross` — extent across it.
2460
+ #
2461
+ # _@param_ `align` — one of {ALIGNMENTS} — where a child narrower than the cross extent sits. {Vertical} / {Horizontal} say which edge `:start` is.
2462
+ def add: (
2463
+ (Component | ::Enumerable[Component]) child,
2464
+ ?(Fixed | Percent | Expand) main,
2465
+ ?cross: (Fixed | Percent),
2466
+ ?align: Symbol
2467
+ ) -> void
2468
+
2469
+ # Removes the child, forgets its constraints, and closes the gap it left
2470
+ # by re-running the layout.
2471
+ #
2472
+ # _@param_ `child`
2473
+ def remove: (Component child) -> void
2474
+
2475
+ # _@param_ `new_rect`
2476
+ def rect=: (Rect new_rect) -> void
2477
+
2478
+ # Recomputes and assigns every child's rect. Silent until this layout has
2479
+ # a rect of its own — {#add} runs during construction, long before a
2480
+ # parent assigns one.
2481
+ def relayout: () -> void
2482
+
2483
+ # _@return_ — {#rect} with {#padding} taken off each edge; may be
2484
+ # {Rect#empty? empty}.
2485
+ def inner_rect: () -> Rect
2486
+
2487
+ # _@param_ `inner` — {#inner_rect}, known non-empty.
2488
+ def place_children: (Rect inner) -> void
2489
+
2490
+ # _@param_ `inner` — {#inner_rect}.
2491
+ #
2492
+ # _@return_ — main-axis extent per child, in child order.
2493
+ def main_sizes: (Rect inner) -> ::Array[Integer]
2494
+
2495
+ # Splits `slack` between the {Expand} children by weight, writing the
2496
+ # results into `sizes`.
2497
+ #
2498
+ # _@param_ `sizes` — mutated in place.
2499
+ #
2500
+ # _@param_ `indices` — child indices carrying an {Expand}.
2501
+ #
2502
+ # _@param_ `slack` — cells left over; a negative value yields zeroes.
2503
+ def distribute_expand: (::Array[Integer] sizes, ::Array[Integer] indices, Integer slack) -> void
2504
+
2505
+ # _@param_ `child`
2506
+ #
2507
+ # _@param_ `available` — cross extent of {#inner_rect}.
2508
+ #
2509
+ # _@return_ — offset from `inner`'s start edge, and
2510
+ # extent, along the cross axis.
2511
+ def cross_placement: (Component child, Integer available) -> [Integer, Integer]
2512
+
2513
+ # _@param_ `align` — one of {ALIGNMENTS}.
2514
+ #
2515
+ # _@param_ `slack` — unused cells across the axis.
2516
+ def align_offset: (Symbol align, Integer slack) -> Integer
2517
+
2518
+ # _@param_ `extent`
2519
+ #
2520
+ # _@param_ `constraint`
2521
+ def percent_of: (Integer extent, Percent constraint) -> Integer
2522
+
2523
+ # _@param_ `child`
2524
+ #
2525
+ # _@return_ — the child's `main`/`cross`/`align`.
2526
+ def placement: (Component child) -> ::Hash[Symbol, Object]
2527
+
2528
+ # _@param_ `rect`
2529
+ #
2530
+ # _@return_ — the extent along the main axis.
2531
+ def main_extent: (Rect rect) -> Integer
2532
+
2533
+ # _@param_ `rect`
2534
+ #
2535
+ # _@return_ — the extent along the cross axis.
2536
+ def cross_extent: (Rect rect) -> Integer
2537
+
2538
+ # _@param_ `inner` — {#inner_rect}, the origin both offsets are relative to.
2539
+ #
2540
+ # _@param_ `main_offset` — cells along the main axis.
2541
+ #
2542
+ # _@param_ `main_size` — extent along the main axis.
2543
+ #
2544
+ # _@param_ `cross_offset` — cells along the cross axis.
2545
+ #
2546
+ # _@param_ `cross_size` — extent along the cross axis.
2547
+ #
2548
+ # _@return_ — absolute screen rect for one child.
2549
+ def build_rect: (
2550
+ Rect inner,
2551
+ Integer main_offset,
2552
+ Integer main_size,
2553
+ Integer cross_offset,
2554
+ Integer cross_size
2555
+ ) -> Rect
2556
+
2557
+ # _@param_ `cells`
2558
+ #
2559
+ # _@return_ — `cells`.
2560
+ def validate_spacing: (Integer cells) -> Integer
2561
+
2562
+ # _@param_ `constraint`
2563
+ def validate_main: (Object constraint) -> void
2564
+
2565
+ # _@param_ `constraint`
2566
+ def validate_cross: (Object constraint) -> void
2567
+
2568
+ # _@param_ `align`
2569
+ def validate_align: (Object align) -> void
2570
+
2571
+ # _@return_ — blank cells between adjacent children.
2572
+ attr_accessor spacing: Integer
2573
+
2574
+ # _@return_ — inset from this layout's own rect.
2575
+ attr_accessor padding: (Insets | Integer)
2576
+ end
2577
+
2578
+ # Stacks children top to bottom. The main axis is vertical, so a child's
2579
+ # positional constraint is its **height** and `cross:` is its **width**;
2580
+ # `align: :start` is the left edge, `:end` the right.
2581
+ #
2582
+ # form = Component::Layout::Vertical.new(spacing: 1)
2583
+ # form.add(caption, Component::Layout::Fixed[1])
2584
+ # form.add(field, Component::Layout::Fixed[1], cross: Component::Layout::Fixed[30])
2585
+ # form.add(log, Component::Layout::Expand[1]) # takes whatever is left below
2586
+ #
2587
+ # Inside a subclass the constraints need no prefix at all — see {Box}.
2588
+ #
2589
+ # See {Box} for the constraint vocabulary and how the space is divided.
2590
+ class Vertical < Tuile::Component::Layout::Box
2591
+ DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
2592
+ ALIGNMENTS: ::Array[Symbol]
2593
+
2594
+ # _@param_ `rect`
2595
+ def main_extent: (Rect rect) -> Integer
2596
+
2597
+ # _@param_ `rect`
2598
+ def cross_extent: (Rect rect) -> Integer
2599
+
2600
+ # _@param_ `inner`
2601
+ #
2602
+ # _@param_ `main_offset` — rows down from `inner`'s top.
2603
+ #
2604
+ # _@param_ `main_size` — height.
2605
+ #
2606
+ # _@param_ `cross_offset` — columns right of `inner`'s left.
2607
+ #
2608
+ # _@param_ `cross_size` — width.
2609
+ def build_rect: (
2610
+ Rect inner,
2611
+ Integer main_offset,
2612
+ Integer main_size,
2613
+ Integer cross_offset,
2614
+ Integer cross_size
2615
+ ) -> Rect
2616
+ end
2617
+
2618
+ # Lays children out left to right. The main axis is horizontal, so a
2619
+ # child's positional constraint is its **width** and `cross:` is its
2620
+ # **height**; `align: :start` is the top edge, `:end` the bottom.
2621
+ #
2622
+ # split = Component::Layout::Horizontal.new
2623
+ # split.add(sidebar, Component::Layout::Fixed[30])
2624
+ # split.add(main, Component::Layout::Expand[1]) # takes the rest of the row
2625
+ #
2626
+ # Inside a subclass the constraints need no prefix at all — see {Box}.
2627
+ #
2628
+ # See {Box} for the constraint vocabulary and how the space is divided.
2629
+ class Horizontal < Tuile::Component::Layout::Box
2630
+ DEFAULT_PLACEMENT: ::Hash[Symbol, Object]
2631
+ ALIGNMENTS: ::Array[Symbol]
2632
+
2633
+ # _@param_ `rect`
2634
+ def main_extent: (Rect rect) -> Integer
2635
+
2636
+ # _@param_ `rect`
2637
+ def cross_extent: (Rect rect) -> Integer
2638
+
2639
+ # _@param_ `inner`
2640
+ #
2641
+ # _@param_ `main_offset` — columns right of `inner`'s left.
2642
+ #
2643
+ # _@param_ `main_size` — width.
2644
+ #
2645
+ # _@param_ `cross_offset` — rows down from `inner`'s top.
2646
+ #
2647
+ # _@param_ `cross_size` — height.
2648
+ def build_rect: (
2649
+ Rect inner,
2650
+ Integer main_offset,
2651
+ Integer main_size,
2652
+ Integer cross_offset,
2653
+ Integer cross_size
2654
+ ) -> Rect
2655
+ end
2656
+ end
2657
+
2658
+ # A closed-choice field on one row: the selected item's label plus a `▾`
2659
+ # affordance, dropping open a {ListDropdown} of the options. Enter, Space or
2660
+ # Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or
2661
+ # Space commits; ESC dismisses without committing.
2662
+ #
2663
+ # warn ▾ <- the face: one row, on a field well
2664
+ # debug <- the dropdown, measured to the widest label
2665
+ # info (the one-column gutters are {List}'s)
2666
+ # warn <- highlighted: the value's row, on open
2667
+ # error
2668
+ #
2669
+ # sel = Component::Select.new(items: LogLevel.all)
2670
+ # sel.item_label = ->(l) { l.name } # item -> shown label; default :to_s
2671
+ # sel.on_value_change = ->(l) { relog(l) } # fires on commit, with the item
2672
+ # sel.value = LogLevel::WARN # selects it; the face shows its label
2673
+ #
2674
+ # Use it for an **enum** — labels the developer authored, a closed set known
2675
+ # when the code is written: log level, sort order, line endings, Yes/No/Ask.
2676
+ # For items the app supplies at runtime with labels you don't control
2677
+ # (countries, users, branches) reach for {ComboBox} instead, where filtering
2678
+ # is the navigation. Item count is a symptom, not the criterion; book ch7 has
2679
+ # the widget-choice table.
2680
+ #
2681
+ # {#value} is the selected *item*, of whatever type {#items} holds, never its
2682
+ # label; `nil` — a blank face — is the initial state and stays legal, so an
2683
+ # optional enum field needs no placeholder. As on {ComboBox}, {#items=} is
2684
+ # chrome: it never touches {#value}, never fires {HasValue#on_value_change},
2685
+ # and a value absent from {#items} survives intact while rendering nothing
2686
+ # selected. Keeping the two in sync is the app's job.
2687
+ #
2688
+ # == It claims no printable key but Space
2689
+ # Enter, Space, ESC, {ListDropdown::MOVE_KEYS} and the mouse. *Every other*
2690
+ # printable key bubbles past it (key-dispatch rung 3), so a form's `s`-to-save
2691
+ # and a layout's `1`/`2`/`3` pane jumps keep working while a Select has focus
2692
+ # — the one capability no {ComboBox} configuration can offer, since a text
2693
+ # field eats printables unconditionally. Space is the single exception, and it
2694
+ # forecloses nothing: every activatable widget in the gem already claims it.
2695
+ # Home/End are declined too, so they stay available app-wide.
2696
+ #
2697
+ # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
2698
+ # the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
2699
+ # need no prefix-disambiguation.
2700
+ #
2701
+ # == Implementation details
2702
+ # A leaf widget: it paints its own row (the face is *derived* from {#value}
2703
+ # each paint, never a synced copy) and owns the dropdown as an overlay, which
2704
+ # is not a child — like {ComboBox}'s. The well is read from
2705
+ # {Screen#theme} at paint time, so it tracks a theme flip with no hook.
2706
+ #
2707
+ # The dropdown is at least as wide as the face and grows to fit the widest
2708
+ # label, so the labels are never the thing that ellipsizes. It is not opened
2709
+ # at all when {#items} is empty: an item-less Select is a programming bug, and
2710
+ # an empty tinted panel reads as a broken list rather than as "nothing to
2711
+ # pick". Enter/Space/Down are claimed either way.
2712
+ #
2713
+ # UI-thread-confined, like every component (see {Screen}).
2714
+ class Select < Component
2715
+ include Tuile::Component::HasValue
2716
+
2717
+ # _@param_ `items` — the options (any type); also settable via {#items=}.
2718
+ #
2719
+ # _@param_ `value` — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
2720
+ def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
2721
+
2722
+ def tab_stop?: () -> bool
2723
+
2724
+ def keyboard_hint: () -> String
2725
+
2726
+ # Re-anchors the (open) dropdown after a move or resize.
2727
+ #
2728
+ # _@param_ `new_rect`
2729
+ def rect=: (Rect new_rect) -> void
2730
+
2731
+ # Closes the dropdown when the Select leaves the focus chain, so tabbing
2732
+ # away doesn't strand an open menu. Safe against re-entrancy: focus never
2733
+ # sits inside the (non-focusable) {ListDropdown}, so closing it repairs no
2734
+ # focus.
2735
+ #
2736
+ # _@param_ `flag`
2737
+ def active=: (bool flag) -> void
2738
+
2739
+ # Opens the dropdown on Enter, Space or Down; while it is open, forwards
2740
+ # {ListDropdown::MOVE_KEYS} to it, commits the highlight on Enter or Space,
2741
+ # and dismisses on ESC. Everything else — every other printable included —
2742
+ # is left unhandled so it bubbles to an ancestor.
2743
+ #
2744
+ # _@param_ `key`
2745
+ def handle_key: (String key) -> bool
2746
+
2747
+ # Toggles the dropdown on a left click anywhere in {#rect} — a field's
2748
+ # affordance is its whole row, as the well advertises; `super` runs first,
2749
+ # so the click also focuses.
2750
+ #
2751
+ # _@param_ `event`
2752
+ def handle_mouse: (MouseEvent event) -> void
2753
+
2754
+ def repaint: () -> void
2755
+
2756
+ # The painted row: the value's label padded across all but the last column,
2757
+ # then the `▾`, all on the field well — {Theme#active_bg_color} while on the
2758
+ # focus chain, {Theme#input_bg_color} otherwise.
2759
+ def face_row: () -> StyledString
2760
+
2761
+ # Rebuilds the dropdown's rows, highlight and geometry, opening it if
2762
+ # needed; closes it instead when there is nothing to show.
2763
+ def refill: () -> void
2764
+
2765
+ def open_menu: () -> void
2766
+
2767
+ def close_menu: () -> void
2768
+
2769
+ # Adopts the item on row `index` as {#value} and closes the dropdown.
2770
+ #
2771
+ # _@param_ `index`
2772
+ def commit: (Integer index) -> void
2773
+
2774
+ def anchor: () -> void
2775
+
2776
+ # The dropdown's width: the widest label plus {List}'s two row gutters, plus
2777
+ # the scrollbar column when the rows can't all be shown at once — but never
2778
+ # narrower than the Select itself, so both edges line up with the face and
2779
+ # the panel reads as belonging to it. Only a label that needs more pushes it
2780
+ # wider.
2781
+ #
2782
+ # A dropdown the screen clamps shorter than
2783
+ # {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having bought that
2784
+ # column, ellipsizing its labels one early — the {ComboBox} trade, in the
2785
+ # one case measuring can't predict the height.
2786
+ def menu_width: () -> Integer
2787
+
2788
+ # _@param_ `item`
2789
+ #
2790
+ # _@return_ — `item`'s label, or empty for `nil` — so {#value}
2791
+ # being unset never reaches an {#item_label} that assumes an item.
2792
+ def label_for: (Object item) -> StyledString
2793
+
2794
+ # _@return_ — the current value; `nil` until first set.
2795
+ def value: () -> Object
2796
+
2797
+ # No-op (no repaint, no listener) when equal to the current value.
2798
+ #
2799
+ # _@param_ `new_value`
2800
+ def value=: (Object new_value) -> void
2801
+
2802
+ # _@return_ — true iff {#value} equals {#empty_value}.
2803
+ def empty?: () -> bool
2804
+
2805
+ # Resets {#value} to {#empty_value}.
2806
+ def clear: () -> void
2807
+
2808
+ # _@return_ — the value {#empty?}/{#clear} treat as empty; `nil`
2809
+ # unless an includer overrides it.
2810
+ def empty_value: () -> Object
2811
+
2812
+ # Input fields are focusable by default (overrides {Component#focusable?});
2813
+ # a read-only display field could override back to `false`. Only
2814
+ # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
2815
+ # composing wrappers, so it stays per-class (`DECISIONS.md`
2816
+ # `D-integer-field`).
2817
+ def focusable?: () -> bool
2818
+
2819
+ # _@return_ — the options.
2820
+ attr_accessor items: ::Array[untyped]
2821
+
2822
+ # _@return_ — item -> shown label (a `String` or
2823
+ # {StyledString}); `:to_s` by default. Never called with `nil` — an
2824
+ # unselected Select renders a blank face.
2825
+ attr_accessor item_label: (Proc | Method)
2267
2826
  end
2268
2827
 
2269
2828
  # A window with a frame, a {#caption} and a content {Component}. Doesn't
@@ -2366,7 +2925,7 @@ module Tuile
2366
2925
  attr_accessor footer_text: (StyledString | String)?
2367
2926
  end
2368
2927
 
2369
- # A boolean input on one row. Space or a left click toggles it:
2928
+ # A boolean input on one row. Space, Enter or a left click toggles it:
2370
2929
  #
2371
2930
  # [x] Enable syslog forwarding
2372
2931
  # [ ] Enable syslog forwarding
@@ -2382,11 +2941,12 @@ module Tuile
2382
2941
  # {#empty_value}, so a fresh checkbox is {HasValue#empty? empty} and
2383
2942
  # {HasValue#clear} unchecks.
2384
2943
  #
2385
- # Space toggles. Enter is unhandled — unlike {Button} — simply because a
2386
- # checkbox has no default action to confirm, so it bubbles to an ancestor;
2387
- # treat that as this widget declining a key, not as a guarantee the framework
2388
- # makes (a {TextArea} claims Enter for newline, and a checkable row in a
2389
- # {Component::List} toggles on it).
2944
+ # Space and Enter both toggle — same as a checkable row in a
2945
+ # {Component::List} ({CheckboxGroup}, {RadioGroup}), so the gesture reads the
2946
+ # same standalone and grouped. A focused checkbox therefore *consumes* Enter:
2947
+ # a form's Enter-to-submit on an ancestor won't see it, exactly as with a
2948
+ # focused {Button} or {TextArea}. Which widget lets Enter through is per
2949
+ # widget, never a framework guarantee — book ch5's Enter table is the list.
2390
2950
  #
2391
2951
  # A tab stop, so Tab lands on it, and the widget highlights while on the focus
2392
2952
  # chain. Assign a {#rect} (typically from the surrounding {Layout}) at least
@@ -2449,8 +3009,8 @@ module Tuile
2449
3009
  # mode switch invisible in the code and untestable by inspection.
2450
3010
  def extent: () -> Rect
2451
3011
 
2452
- # Toggles on Space. Every other key — Enter included — is left unhandled so
2453
- # it bubbles to an ancestor.
3012
+ # Toggles on Space or Enter. Every other key is left unhandled so it bubbles
3013
+ # to an ancestor.
2454
3014
  #
2455
3015
  # _@param_ `key`
2456
3016
  def handle_key: (String key) -> bool
@@ -2525,7 +3085,6 @@ module Tuile
2525
3085
  class ComboBox < Component
2526
3086
  include Tuile::Component::HasContent
2527
3087
  include Tuile::Component::HasValue
2528
- MAX_VISIBLE_ROWS: Integer
2529
3088
 
2530
3089
  # _@param_ `items` — the candidate items (any type); also settable via {#items=}.
2531
3090
  def initialize: (?items: ::Array[untyped]) -> void
@@ -2564,7 +3123,8 @@ module Tuile
2564
3123
  def repaint: () -> void
2565
3124
 
2566
3125
  # Field spans the row bar the last column, which the `▾` occupies
2567
- # ({HasContent} layout hook).
3126
+ # ({HasContent} layout hook). One row, or none at all when the combo itself
3127
+ # was given none — a starved parent must not hand out a rect it doesn't own.
2568
3128
  #
2569
3129
  # _@param_ `field`
2570
3130
  def layout: (Component field) -> void
@@ -2618,9 +3178,10 @@ module Tuile
2618
3178
  # _@return_ — the plain-text label for `item`, or "" for nil.
2619
3179
  def display_for: (Object item) -> String
2620
3180
 
2621
- # Sizes and positions the dropdown against the field: full combo width,
2622
- # `min(matches, 10)` rows, below the field — flipped above when it won't
2623
- # fit beneath, clamped (with the list scrolling) when it fits neither.
3181
+ # Places the dropdown at the combo's own width, so both its edges line up
3182
+ # with the field — at the cost of the scrollbar taking its column from the
3183
+ # labels, which ellipsize a column earlier once the list scrolls. That is
3184
+ # the trade a measuring driver ({Select}) makes the other way.
2624
3185
  def anchor: () -> void
2625
3186
 
2626
3187
  # _@return_ — the current value; `nil` until first set.
@@ -3555,6 +4116,126 @@ module Tuile
3555
4116
  attr_accessor on_enter: (Proc | Method)?
3556
4117
  end
3557
4118
 
4119
+ # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
4120
+ # the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
4121
+ #
4122
+ # field = Component::FloatField.new
4123
+ # field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
4124
+ # field.value = 19.99 # field shows "19.99"
4125
+ # field.clear # empties it; value => nil
4126
+ #
4127
+ # Only `0`–`9`, one leading `-` and one `.` can be typed; any other
4128
+ # printable key is dropped without moving the caret. Up/Down step by `1.0`
4129
+ # (an empty field counting as `0.0`). A `Float` is a binary double, so this
4130
+ # is the wrong field for money — hold that as `Integer` cents in an
4131
+ # {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
4132
+ # not here.
4133
+ #
4134
+ # == Implementation details
4135
+ # {#value} is a *derived parse*: the buffer is the single source of truth,
4136
+ # recomputed on read and left exactly as typed (`"007"` keeps its zeros).
4137
+ # It reads `nil` for a buffer that isn't a number (`""`, a lone `"-"`) but
4138
+ # `1.0` / `0.5` for a half-typed `"1."` / `".5"`, so reaching for the
4139
+ # decimal point doesn't blink the value to `nil` and back through
4140
+ # {#on_value_change} — which fires per keystroke, but only on a real *value*
4141
+ # change (`"7"`→`"07"` is silent). The parse also accepts the exponent
4142
+ # `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
4143
+ # through the `"1.0e-05"` it displays, though no key types an `e`.
4144
+ #
4145
+ # It *composes* a {TextField} (its single {HasContent} child) rather than
4146
+ # subclassing one, so its face carries only the typed {HasValue} seam, never
4147
+ # the widget's `String`-typed `text`.
4148
+ #
4149
+ # UI-thread-confined, like every component (see {Screen}).
4150
+ class FloatField < Component
4151
+ include Tuile::Component::HasContent
4152
+ include Tuile::Component::HasValue
4153
+ NUMERIC: Regexp
4154
+
4155
+ def initialize: () -> void
4156
+
4157
+ # _@return_ — the parsed buffer; `nil` when empty or not a
4158
+ # number (e.g. a lone `"-"`).
4159
+ def value: () -> Float?
4160
+
4161
+ # Writes `new_value` into the buffer and parks the caret at its end; fires
4162
+ # {#on_value_change} only if the value actually changed.
4163
+ #
4164
+ # _@param_ `new_value` — `nil` empties the field; anything else is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
4165
+ def value=: (Numeric? new_value) -> void
4166
+
4167
+ # `nil`, not `""`: a numeric field with no parseable number is empty.
4168
+ def empty_value: () -> void
4169
+
4170
+ # _@return_ — the field's caret (the hardware cursor is delegated
4171
+ # to the inner field).
4172
+ def cursor_position: () -> Point?
4173
+
4174
+ # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
4175
+ #
4176
+ # _@return_ — no-arg callable, or nil.
4177
+ def on_enter: () -> (Proc | Method)?
4178
+
4179
+ # _@param_ `callback`
4180
+ def on_enter=: ((Proc | Method)? callback) -> void
4181
+
4182
+ # Places the wrapped field across the whole rect ({HasContent} hook).
4183
+ #
4184
+ # _@param_ `field`
4185
+ def layout: (Component field) -> void
4186
+
4187
+ # _@param_ `new_value`
4188
+ def coerce: (Numeric new_value) -> Float
4189
+
4190
+ # The field's key interceptor, consulted *before* the field acts on the
4191
+ # key — which is what lets a rejected character be swallowed without the
4192
+ # caret ever moving.
4193
+ #
4194
+ # _@param_ `key`
4195
+ #
4196
+ # _@return_ — true to consume the key.
4197
+ def field_key: (String key) -> bool
4198
+
4199
+ # Nudges {#value} by `delta`, treating an empty/un-parseable field as
4200
+ # `0.0`.
4201
+ #
4202
+ # _@param_ `delta`
4203
+ def step: (Float delta) -> void
4204
+
4205
+ # Whether `char` may be inserted. Deliberately shallow: it keeps the
4206
+ # buffer *typeable* rather than always-valid — a transient `"-"` or
4207
+ # `"1."` has to be reachable — and {#value} decides what parses.
4208
+ #
4209
+ # _@param_ `char` — a single printable character.
4210
+ def accepts?: (String char) -> bool
4211
+
4212
+ # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
4213
+ # when it differs from the last one fired — so a buffer edit that leaves
4214
+ # the value unchanged (`"7"`→`"07"`) stays silent.
4215
+ def fire_if_changed: () -> void
4216
+
4217
+ # _@return_ — true iff {#value} equals {#empty_value}.
4218
+ def empty?: () -> bool
4219
+
4220
+ # Resets {#value} to {#empty_value}.
4221
+ def clear: () -> void
4222
+
4223
+ # Input fields are focusable by default (overrides {Component#focusable?});
4224
+ # a read-only display field could override back to `false`. Only
4225
+ # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4226
+ # composing wrappers, so it stays per-class (`DECISIONS.md`
4227
+ # `D-integer-field`).
4228
+ def focusable?: () -> bool
4229
+
4230
+ # _@param_ `event`
4231
+ def handle_mouse: (MouseEvent event) -> void
4232
+
4233
+ # _@param_ `rect`
4234
+ def rect=: (Rect rect) -> void
4235
+
4236
+ def on_focus: () -> void
4237
+ end
4238
+
3558
4239
  # The chrome text a component *wears* — a {Window}'s border title, a
3559
4240
  # {Button}'s label — as opposed to the value it *holds*.
3560
4241
  #
@@ -4023,26 +4704,27 @@ module Tuile
4023
4704
  end
4024
4705
 
4025
4706
  # A borderless, tinted, non-focusable floating selection list — the dropdown
4026
- # a text input drops open, drives by forwarding movement keys, and commits a
4707
+ # a *driver* drops open, drives by forwarding movement keys, and commits a
4027
4708
  # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
4028
- # the caret stays in the driving input while the caller refills the rows,
4029
- # moves the highlight, and reads the pick.
4709
+ # focus stays on the driver while the caller refills the rows, moves the
4710
+ # highlight, and reads the pick.
4030
4711
  #
4031
4712
  # drop = Component::ListDropdown.new
4032
4713
  # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
4033
- # # …then, per keystroke in the driving input's key handler:
4034
- # drop.lines = matches.map { |m| render(m) } # caller filters + renders
4035
- # drop.rect = Rect.new(...) # caller anchors + sizes it
4714
+ # # …then, from the driver's key handler:
4715
+ # drop.lines = matches.map { |m| render(m) } # caller filters + renders
4716
+ # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
4036
4717
  # drop.open
4037
4718
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
4038
4719
  # drop.choose if key == Keys::ENTER # commit the highlight
4039
4720
  #
4040
- # It owns only what every such dropdown shares; everything that varies stays
4041
- # with the driver: geometry/anchoring, filtering, row rendering, the commit
4042
- # action, and ESC/Enter handling. ESC and Enter carry driver-specific tails
4043
- # (ESC may revert a query; Enter may commit via {#choose} *or* via a separate
4044
- # submit path), so {#move} claims neither — the driver calls {#choose} and
4045
- # {#close} from its own branches.
4721
+ # It owns only what every such dropdown shares — *placement* included, via
4722
+ # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
4723
+ # measures nothing itself), filtering, row rendering, the commit action, and
4724
+ # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
4725
+ # revert a query; Enter may commit via {#choose} *or* via a separate submit
4726
+ # path), so {#move} claims neither — the driver calls {#choose} and {#close}
4727
+ # from its own branches.
4046
4728
  #
4047
4729
  # == Theming
4048
4730
  # Borderless, told apart from the content beneath by a background tint —
@@ -4053,6 +4735,7 @@ module Tuile
4053
4735
  # UI-thread-confined, like every component (see {Screen}).
4054
4736
  class ListDropdown < Tuile::Component::Popup
4055
4737
  MOVE_KEYS: ::Array[String]
4738
+ MAX_VISIBLE_ROWS: Integer
4056
4739
 
4057
4740
  def initialize: () -> void
4058
4741
 
@@ -4071,6 +4754,31 @@ module Tuile
4071
4754
  # _@return_ — the list's cursor (the current highlight).
4072
4755
  def cursor: () -> List::Cursor
4073
4756
 
4757
+ # Sizes and places the dropdown against `anchor`: directly beneath it,
4758
+ # flipped above when `rows` won't fit below, clamped — with the list
4759
+ # scrolling — when neither side has room. Horizontally the left edges line
4760
+ # up, sliding left only far enough to keep the panel on screen.
4761
+ #
4762
+ # drop.anchor_to(field.rect, rows: matches.size) # field width
4763
+ # drop.anchor_to(rect, rows: items.size, width: measured) # own width
4764
+ #
4765
+ # Vertical flips but horizontal slides because covering the driver would
4766
+ # hide what is being chosen, while sharing its columns is the point.
4767
+ #
4768
+ # _@param_ `anchor` — the driver's rect; the dropdown never covers it.
4769
+ #
4770
+ # _@param_ `rows` — how many rows there are to show — the content count, not the height: more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
4771
+ #
4772
+ # _@param_ `width` — the panel's width in columns, clamped to the screen. Defaults to the anchor's, which lines both edges up with a field; a driver that measured its labels passes its own. A label wider than the screen clips — {List} has no horizontal scrolling.
4773
+ #
4774
+ # _@param_ `max_rows` — rows shown before the list scrolls.
4775
+ def anchor_to: (
4776
+ Rect anchor,
4777
+ rows: Integer,
4778
+ ?width: Integer,
4779
+ ?max_rows: Integer
4780
+ ) -> void
4781
+
4074
4782
  # Forwards a cursor-movement key to the list. The driver calls this from
4075
4783
  # its own key handler; a truthy return means "consumed — stop here", falsy
4076
4784
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -4090,8 +4798,8 @@ module Tuile
4090
4798
  def choose: () -> bool
4091
4799
 
4092
4800
  # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
4093
- # while focus (and the caret) stay in its input, and a mouse click selects
4094
- # an item without stealing focus — so the input never loses the cursor
4801
+ # while focus stays on it, and a mouse click selects an item without
4802
+ # stealing focus — so a driving text input never loses its caret
4095
4803
  # mid-interaction.
4096
4804
  class Menu < Tuile::Component::List
4097
4805
  def focusable?: () -> bool
@@ -4342,6 +5050,142 @@ module Tuile
4342
5050
  attr_accessor revealed: (bool | Object)
4343
5051
  end
4344
5052
 
5053
+ # A single-line field whose {#value} is a `BigDecimal` (or `nil` when
5054
+ # empty) — the numeric field for money, where {FloatField}'s binary double
5055
+ # would round. Give it a single-row {#rect}:
5056
+ #
5057
+ # price = Component::BigDecimalField.new
5058
+ # price.on_value_change = ->(d) { total.value = d } # BigDecimal or nil
5059
+ # price.value = BigDecimal("19.99") # field shows "19.99"
5060
+ # price.value = 19.99 # ArgumentError: a Float can't be exact
5061
+ #
5062
+ # Only `0`–`9`, one leading `-` and one `.` can be typed; any other
5063
+ # printable key is dropped without moving the caret. Up/Down step by one.
5064
+ # Range checks (`min`/`max`) and a display scale (`19.9` → `19.90`) belong
5065
+ # to a forms layer, not here — nothing rounds or pads what you typed.
5066
+ #
5067
+ # Requires the `bigdecimal` gem, which Tuile does *not* depend on: it is a
5068
+ # bundled gem from Ruby 3.4 on, so a `Gemfile` naming it is what puts it on
5069
+ # the load path. Referencing this class without it raises `LoadError`.
5070
+ #
5071
+ # == Implementation details
5072
+ # {#value} is a *derived parse*: the buffer is the single source of truth,
5073
+ # recomputed on read and left exactly as typed (`"19.90"` keeps its zero,
5074
+ # which `BigDecimal#to_s` would not). It reads `nil` for a buffer that
5075
+ # isn't a number (`""`, a lone `"-"`) but `1` / `0.5` for a half-typed
5076
+ # `"1."` / `".5"`, so reaching for the decimal point doesn't blink the
5077
+ # value to `nil` and back through {#on_value_change} — which fires per
5078
+ # keystroke, but only on a real *value* change (`"1.0"`→`"1.00"` is silent,
5079
+ # since the two compare equal).
5080
+ #
5081
+ # Both ends of that round-trip are written here rather than left to the
5082
+ # library, because `bigdecimal` 3.1 (Ruby 3.3's default gem) and 4.x
5083
+ # disagree about them: 3.1 rejects `BigDecimal("1.")` and `BigDecimal(0.1)`
5084
+ # where 4.x accepts both. So the buffer is normalized before parsing, a
5085
+ # `Float` is refused on both, and display goes through `to_s("F")` — plain
5086
+ # notation, never `BigDecimal#to_s`'s `"0.1999e2"`.
5087
+ #
5088
+ # It *composes* a {TextField} (its single {HasContent} child) rather than
5089
+ # subclassing one, so its face carries only the typed {HasValue} seam,
5090
+ # never the widget's `String`-typed `text`.
5091
+ #
5092
+ # UI-thread-confined, like every component (see {Screen}).
5093
+ class BigDecimalField < Component
5094
+ include Tuile::Component::HasContent
5095
+ include Tuile::Component::HasValue
5096
+ NUMERIC: Regexp
5097
+
5098
+ def initialize: () -> void
5099
+
5100
+ # _@return_ — the parsed buffer; `nil` when empty or not a
5101
+ # number (e.g. a lone `"-"`).
5102
+ def value: () -> ::BigDecimal?
5103
+
5104
+ # Writes `new_value` into the buffer in plain notation and parks the
5105
+ # caret at its end; fires {#on_value_change} only if the value actually
5106
+ # changed.
5107
+ #
5108
+ # _@param_ `new_value` — `nil` empties the field. A `Float` is refused, not converted — see the raise.
5109
+ def value=: ((::BigDecimal | Integer | String)? new_value) -> void
5110
+
5111
+ # `nil`, not `""`: a numeric field with no parseable number is empty.
5112
+ def empty_value: () -> void
5113
+
5114
+ # _@return_ — the field's caret (the hardware cursor is delegated
5115
+ # to the inner field).
5116
+ def cursor_position: () -> Point?
5117
+
5118
+ # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
5119
+ #
5120
+ # _@return_ — no-arg callable, or nil.
5121
+ def on_enter: () -> (Proc | Method)?
5122
+
5123
+ # _@param_ `callback`
5124
+ def on_enter=: ((Proc | Method)? callback) -> void
5125
+
5126
+ # Places the wrapped field across the whole rect ({HasContent} hook).
5127
+ #
5128
+ # _@param_ `field`
5129
+ def layout: (Component field) -> void
5130
+
5131
+ # Rewrites the half-typed shapes {NUMERIC} admits into ones every
5132
+ # `bigdecimal` version parses: `".5"` → `"0.5"`, `"1."` → `"1"`.
5133
+ #
5134
+ # _@param_ `text` — a buffer matching {NUMERIC}.
5135
+ def normalize: (String text) -> String
5136
+
5137
+ # _@param_ `new_value`
5138
+ def coerce: ((::BigDecimal | Integer | String) new_value) -> ::BigDecimal
5139
+
5140
+ # The field's key interceptor, consulted *before* the field acts on the
5141
+ # key — which is what lets a rejected character be swallowed without the
5142
+ # caret ever moving.
5143
+ #
5144
+ # _@param_ `key`
5145
+ #
5146
+ # _@return_ — true to consume the key.
5147
+ def field_key: (String key) -> bool
5148
+
5149
+ # Nudges {#value} by `delta`, treating an empty/un-parseable field as
5150
+ # zero.
5151
+ #
5152
+ # _@param_ `delta`
5153
+ def step: (Integer delta) -> void
5154
+
5155
+ # Whether `char` may be inserted. Deliberately shallow: it keeps the
5156
+ # buffer *typeable* rather than always-valid — a transient `"-"` or
5157
+ # `"1."` has to be reachable — and {#value} decides what parses.
5158
+ #
5159
+ # _@param_ `char` — a single printable character.
5160
+ def accepts?: (String char) -> bool
5161
+
5162
+ # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
5163
+ # when it differs from the last one fired — so a buffer edit that leaves
5164
+ # the value unchanged (`"1.0"`→`"1.00"`) stays silent.
5165
+ def fire_if_changed: () -> void
5166
+
5167
+ # _@return_ — true iff {#value} equals {#empty_value}.
5168
+ def empty?: () -> bool
5169
+
5170
+ # Resets {#value} to {#empty_value}.
5171
+ def clear: () -> void
5172
+
5173
+ # Input fields are focusable by default (overrides {Component#focusable?});
5174
+ # a read-only display field could override back to `false`. Only
5175
+ # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5176
+ # composing wrappers, so it stays per-class (`DECISIONS.md`
5177
+ # `D-integer-field`).
5178
+ def focusable?: () -> bool
5179
+
5180
+ # _@param_ `event`
5181
+ def handle_mouse: (MouseEvent event) -> void
5182
+
5183
+ # _@param_ `rect`
5184
+ def rect=: (Rect rect) -> void
5185
+
5186
+ def on_focus: () -> void
5187
+ end
5188
+
4345
5189
  # Abstract base for the **String-valued** editable text components
4346
5190
  # ({TextField}, {TextArea}): a field whose {HasValue#value} *is* its text.
4347
5191
  # A field whose value is a different type (an `Integer`, a domain object)
@@ -5209,11 +6053,20 @@ module Tuile
5209
6053
  # wrapped continuations, hard `"\n"` breaks preserved as separate output
5210
6054
  # lines.
5211
6055
  #
6056
+ # An indent is content, so it survives onto the first row — but there is no
6057
+ # hanging indent:
6058
+ #
6059
+ # StyledString.plain(" read config").wrap(20).map(&:to_s)
6060
+ # # => [" read config"] indent kept; the line never wrapped
6061
+ # StyledString.plain(" read config").wrap(6).map(&:to_s)
6062
+ # # => [" read", "config"] ...but a continuation starts at column 0
6063
+ #
5212
6064
  # Whitespace runs are space or tab; other characters are treated as word
5213
6065
  # content. When a single character is wider than `width` (e.g. a 2-column
5214
6066
  # CJK character with `width = 1`), it is still emitted on its own line at
5215
6067
  # its natural width. The "no line exceeds `width`" guarantee therefore
5216
- # holds whenever every character is at most `width` columns wide.
6068
+ # holds whenever every character is at most `width` columns wide. An indent
6069
+ # that alone exceeds `width` is dropped rather than given a row of its own.
5217
6070
  #
5218
6071
  # _@param_ `width` — target column width. `nil` or `<= 0` skips wrapping and returns each hard-line as-is, so callers can pass a stale viewport width without crashing.
5219
6072
  #