tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -403,6 +403,37 @@ module Tuile
|
|
|
403
403
|
raise TypeError, "cannot parse #{input.class}"
|
|
404
404
|
end
|
|
405
405
|
end
|
|
406
|
+
|
|
407
|
+
# Checks a glyph knob's value at assignment: exactly one grapheme
|
|
408
|
+
# cluster, one column wide. A component painting one glyph per cell
|
|
409
|
+
# spills a wider one onto its neighbour — silently, with nothing in the
|
|
410
|
+
# frame to point at — so every glyph knob asks here rather than at paint.
|
|
411
|
+
#
|
|
412
|
+
# def track_char=(char)
|
|
413
|
+
# @track_char = StyledString.validate_glyph(char, :track_char)
|
|
414
|
+
# end
|
|
415
|
+
#
|
|
416
|
+
# East-Asian-Ambiguous glyphs pass: Tuile measures them one column by
|
|
417
|
+
# construction (`D_ambiguous_width`), so no check can catch a terminal
|
|
418
|
+
# drawing them two.
|
|
419
|
+
# @param char [String]
|
|
420
|
+
# @param name [Symbol, String] the knob, for the message.
|
|
421
|
+
# @return [String] `char`, frozen.
|
|
422
|
+
# @raise [TypeError] when `char` is not a String.
|
|
423
|
+
# @raise [ArgumentError] when `char` is not exactly one grapheme cluster
|
|
424
|
+
# one column wide.
|
|
425
|
+
def validate_glyph(char, name)
|
|
426
|
+
raise TypeError, "#{name} must be a String, got #{char.inspect}" unless char.is_a?(String)
|
|
427
|
+
|
|
428
|
+
unless char.each_grapheme_cluster.take(2).size == 1
|
|
429
|
+
raise ArgumentError, "#{name} must be exactly one grapheme cluster, got #{char.inspect}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
width = plain(char).display_width
|
|
433
|
+
raise ArgumentError, "#{name} must be one column wide, got #{char.inspect} (#{width})" unless width == 1
|
|
434
|
+
|
|
435
|
+
-char
|
|
436
|
+
end
|
|
406
437
|
end
|
|
407
438
|
|
|
408
439
|
# The framework's single emoji-width policy, passed to every
|
|
@@ -410,11 +441,11 @@ module Tuile
|
|
|
410
441
|
#
|
|
411
442
|
# `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
|
|
412
443
|
# sequences — the ones vendors actually ship a single glyph for — and *sums*
|
|
413
|
-
# the parts of anything else
|
|
414
|
-
#
|
|
415
|
-
# shifts the rest of
|
|
416
|
-
#
|
|
417
|
-
# per-setting reasoning.
|
|
444
|
+
# the parts of anything else, erring only in the direction that cannot escape
|
|
445
|
+
# a component: {Buffer#flush} emits a dirty run contiguously, so a mis-measure
|
|
446
|
+
# of either sign shifts the rest of that run, but only an under-measure shifts
|
|
447
|
+
# it right, into a neighbour whose cells are clean and so never repainted.
|
|
448
|
+
# `D_cluster_width` has the per-setting reasoning.
|
|
418
449
|
# @return [Symbol]
|
|
419
450
|
EMOJI_WIDTH = :rgi
|
|
420
451
|
|
|
@@ -500,27 +531,88 @@ module Tuile
|
|
|
500
531
|
slice_spans(start, len)
|
|
501
532
|
end
|
|
502
533
|
|
|
503
|
-
# Truncates to a target column width,
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
#
|
|
508
|
-
# is
|
|
534
|
+
# Truncates to a target column width, marking the cut end with an ellipsis.
|
|
535
|
+
# The ellipsis counts toward the target — the returned {StyledString}'s
|
|
536
|
+
# `display_width` never exceeds `display_width`, and comes out a column
|
|
537
|
+
# short when the cut lands mid-cluster, since a wide glyph straddling the
|
|
538
|
+
# boundary is dropped rather than halved. When `self` already fits, `self`
|
|
539
|
+
# is returned. When `display_width` is smaller than the ellipsis's own
|
|
540
|
+
# width, the ellipsis is sliced down to fit and no original content is
|
|
541
|
+
# included.
|
|
542
|
+
#
|
|
543
|
+
# `at: :start` keeps the *tail* instead, for text whose end identifies it
|
|
544
|
+
# and whose head is context — a path (`…/shared/markdown/`), a log line's
|
|
545
|
+
# message after its prefix.
|
|
509
546
|
#
|
|
510
547
|
# @param display_width [Integer] target column width.
|
|
511
|
-
# @param ellipsis [String, StyledString]
|
|
548
|
+
# @param ellipsis [String, StyledString] added when truncation
|
|
512
549
|
# occurs. Defaults to the Unicode horizontal-ellipsis `…` (one
|
|
513
550
|
# column). A `String` is parsed via {.parse}, so ANSI in it is
|
|
514
551
|
# preserved.
|
|
552
|
+
# @param at [Symbol] which end is cut: `:end` (the default) keeps the head
|
|
553
|
+
# and appends, `:start` keeps the tail and prepends.
|
|
554
|
+
# @raise [ArgumentError] unless `at` is `:start` or `:end`.
|
|
515
555
|
# @return [StyledString]
|
|
516
|
-
def ellipsize(display_width, ellipsis = "…")
|
|
556
|
+
def ellipsize(display_width, ellipsis = "…", at: :end)
|
|
557
|
+
raise ArgumentError, "expected :start or :end, got #{at.inspect}" unless %i[start end].include?(at)
|
|
517
558
|
return self.class.new if display_width <= 0
|
|
518
559
|
return self if self.display_width <= display_width
|
|
519
560
|
|
|
520
561
|
ellipsis = self.class.parse(ellipsis)
|
|
521
562
|
return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width
|
|
522
563
|
|
|
523
|
-
|
|
564
|
+
keep = display_width - ellipsis.display_width
|
|
565
|
+
return ellipsis + slice(self.display_width - keep, keep) if at == :start
|
|
566
|
+
|
|
567
|
+
slice(0, keep) + ellipsis
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Pads on the right out to `width` display columns — {String#ljust}
|
|
571
|
+
# counted in columns, so a CJK glyph or an emoji measures what it paints.
|
|
572
|
+
#
|
|
573
|
+
# StyledString.plain("日本").ljust(6).to_s # => "日本 "
|
|
574
|
+
# row.ellipsize(w).ljust(w) # exactly w columns
|
|
575
|
+
#
|
|
576
|
+
# Never truncates: `self` comes back when already at least `width` wide,
|
|
577
|
+
# a negative gap included. The fill is unstyled, so it shows whatever
|
|
578
|
+
# background is painted under it.
|
|
579
|
+
# @param width [Integer] target display width.
|
|
580
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
581
|
+
# @return [StyledString]
|
|
582
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
583
|
+
def ljust(width, pad = " ")
|
|
584
|
+
gap = gap_to(width, pad)
|
|
585
|
+
gap.zero? ? self : self + self.class.plain(pad * gap)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Pads on the left out to `width` display columns; see {#ljust}.
|
|
589
|
+
#
|
|
590
|
+
# StyledString.plain("42").rjust(5).to_s # => " 42"
|
|
591
|
+
#
|
|
592
|
+
# @param width [Integer] target display width.
|
|
593
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
594
|
+
# @return [StyledString]
|
|
595
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
596
|
+
def rjust(width, pad = " ")
|
|
597
|
+
gap = gap_to(width, pad)
|
|
598
|
+
gap.zero? ? self : self.class.plain(pad * gap) + self
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Pads both sides out to `width` display columns; see {#ljust}. An odd gap
|
|
602
|
+
# puts the extra column on the right, as {String#center} does.
|
|
603
|
+
#
|
|
604
|
+
# StyledString.plain("ab").center(5, "·").to_s # => "·ab··"
|
|
605
|
+
#
|
|
606
|
+
# @param width [Integer] target display width.
|
|
607
|
+
# @param pad [String] the fill, one grapheme cluster one column wide.
|
|
608
|
+
# @return [StyledString]
|
|
609
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
610
|
+
def center(width, pad = " ")
|
|
611
|
+
gap = gap_to(width, pad)
|
|
612
|
+
return self if gap.zero?
|
|
613
|
+
|
|
614
|
+
left = gap / 2
|
|
615
|
+
self.class.plain(pad * left) + self + self.class.plain(pad * (gap - left))
|
|
524
616
|
end
|
|
525
617
|
|
|
526
618
|
# Splits on `"\n"`, preserving spans on each side. A trailing newline
|
|
@@ -646,6 +738,34 @@ module Tuile
|
|
|
646
738
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
|
|
647
739
|
end
|
|
648
740
|
|
|
741
|
+
# Returns a copy with `fg` set **only on spans that have none**; a span with
|
|
742
|
+
# an explicit fg is left untouched. The foreground counterpart of
|
|
743
|
+
# {#under_bg} — it gives a caption a default tone while keeping the parts
|
|
744
|
+
# the app colored itself:
|
|
745
|
+
#
|
|
746
|
+
# caption = StyledString.plain("Queue ") + StyledString.styled("3", fg: theme[:notice])
|
|
747
|
+
# caption.under_fg(theme[:hint]) # "Queue " in :hint, "3" stays :notice
|
|
748
|
+
#
|
|
749
|
+
# An `inverse` span is skipped even when its `fg` member is nil, as
|
|
750
|
+
# {#under_bg} skips it: SGR 7 swaps the pair, so a filled fg would become
|
|
751
|
+
# the span's *background*.
|
|
752
|
+
#
|
|
753
|
+
# @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground color,
|
|
754
|
+
# coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
755
|
+
# @return [StyledString]
|
|
756
|
+
def under_fg(fg)
|
|
757
|
+
return self if fg.nil?
|
|
758
|
+
|
|
759
|
+
fg = Color.coerce(fg)
|
|
760
|
+
self.class.new(@spans.map do |span|
|
|
761
|
+
if span.style.fg.nil? && !span.style.inverse
|
|
762
|
+
Span.new(text: span.text, style: span.style.merge(fg: fg))
|
|
763
|
+
else
|
|
764
|
+
span
|
|
765
|
+
end
|
|
766
|
+
end)
|
|
767
|
+
end
|
|
768
|
+
|
|
649
769
|
# Returns a new {StyledString} with `bold` applied to every span, preserving
|
|
650
770
|
# each span's text and other style attributes (`fg`, `bg`, `italic`,
|
|
651
771
|
# `underline`, `strikethrough`). The bold-attribute counterpart of
|
|
@@ -740,6 +860,16 @@ module Tuile
|
|
|
740
860
|
result
|
|
741
861
|
end
|
|
742
862
|
|
|
863
|
+
# @param width [Integer]
|
|
864
|
+
# @param pad [String]
|
|
865
|
+
# @return [Integer] columns {#ljust} and friends must fill, never negative.
|
|
866
|
+
# @raise [ArgumentError] when `pad` is not one cluster one column wide.
|
|
867
|
+
def gap_to(width, pad)
|
|
868
|
+
# The default skips validation: the fill runs once per painted row.
|
|
869
|
+
self.class.validate_glyph(pad, :pad) unless pad == " "
|
|
870
|
+
(width - display_width).clamp(0, nil)
|
|
871
|
+
end
|
|
872
|
+
|
|
743
873
|
# @param start_or_range [Integer, Range]
|
|
744
874
|
# @param len [Integer, nil]
|
|
745
875
|
# @param total [Integer] receiver's full display width.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
module Testing
|
|
5
|
+
# Receiver syntax for {Tuile::Testing}'s gestures, so a located component
|
|
6
|
+
# is driven in one line instead of passed back as an argument:
|
|
7
|
+
#
|
|
8
|
+
# using Tuile::Testing::Gestures
|
|
9
|
+
#
|
|
10
|
+
# Testing.get(Component::TextField, id: :name)._value = "Zaphod"
|
|
11
|
+
# Testing.get(Component::Button) { _1.caption.to_s == "Save" }._click
|
|
12
|
+
#
|
|
13
|
+
# A **refinement**, so it exists only where `using` says so — per file or
|
|
14
|
+
# per `describe` block, never leaking to a sibling — and nothing lands on
|
|
15
|
+
# {Component} itself. That is `D_component_lookup`'s standing answer for
|
|
16
|
+
# wanting receiver syntax.
|
|
17
|
+
#
|
|
18
|
+
# **The leading underscore is the mark**, borrowed from Karibu-Testing: it
|
|
19
|
+
# says *testing API, not the component's own*, which matters most for
|
|
20
|
+
# `_value=` sitting one character from a real `value=`. The unrefined
|
|
21
|
+
# spellings keep plain names, since `Testing.` already marks them.
|
|
22
|
+
module Gestures
|
|
23
|
+
refine Component do
|
|
24
|
+
# @return [void] see {Testing.click}.
|
|
25
|
+
def _click = Testing.click(self)
|
|
26
|
+
|
|
27
|
+
# @param value [Object] see {Testing.set_value}.
|
|
28
|
+
# @return [void]
|
|
29
|
+
def _value=(value)
|
|
30
|
+
Testing.set_value(self, value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|