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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -64
- data/DECISIONS.md +619 -14
- data/README.md +6 -0
- data/book/03-layout.md +153 -8
- data/book/05-focus.md +2 -0
- data/book/07-components.md +105 -10
- data/book/README.md +3 -1
- data/examples/sampler.rb +282 -132
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +17 -8
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/checkbox.rb +10 -9
- data/lib/tuile/component/combo_box.rb +8 -26
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -1
- data/lib/tuile/component/list_dropdown.rb +69 -18
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/styled_string.rb +13 -3
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +882 -29
- metadata +8 -1
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
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
2388
|
-
#
|
|
2389
|
-
# {
|
|
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
|
|
2453
|
-
#
|
|
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
|
-
#
|
|
2622
|
-
#
|
|
2623
|
-
#
|
|
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
|
|
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
|
-
#
|
|
4029
|
-
#
|
|
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,
|
|
4034
|
-
# drop.lines = matches.map { |m| render(m) }
|
|
4035
|
-
# drop.rect
|
|
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
|
|
4041
|
-
# with the driver:
|
|
4042
|
-
#
|
|
4043
|
-
#
|
|
4044
|
-
#
|
|
4045
|
-
# {#
|
|
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
|
|
4094
|
-
#
|
|
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
|
#
|