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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -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. That is the one setting never wrong in the
414
- # dangerous direction: under-measuring lets a glyph overrun its cell, which
415
- # shifts the rest of the row, desyncs the cursor and escapes the component's
416
- # rect, while over-measuring leaves a blank column. `D_cluster_width` has the
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, appending an ellipsis when
504
- # characters were dropped. The ellipsis counts toward the target — the
505
- # returned {StyledString}'s `display_width` never exceeds
506
- # `display_width`. When `self` already fits, `self` is returned. When
507
- # `display_width` is smaller than the ellipsis's own width, the ellipsis
508
- # is sliced down to fit and no original content is included.
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] appended when truncation
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
- slice(0, display_width - ellipsis.display_width) + ellipsis
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