tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
data/book/08-testing.md CHANGED
@@ -28,10 +28,13 @@ after { Screen.close }
28
28
  `Screen.fake` installs a {Tuile::FakeScreen} as the process singleton — a
29
29
  `Screen` subclass with the terminal amputated. It has a fixed 160×50
30
30
  viewport (so geometry is deterministic, independent of whoever's terminal
31
- runs the suite), it writes nothing to any TTY, its `check_locked` is a
32
- no-op so you can mutate the UI freely from the test thread without holding
33
- the UI lock, and its event queue is the synchronous {Tuile::FakeEventQueue}
34
- (more on that below). It also pins the color scheme to `:dark`, skipping
31
+ runs the suite), it writes nothing to any TTY, and its event queue is the
32
+ synchronous {Tuile::FakeEventQueue} (more on that below). You can mutate the
33
+ UI directly from your example — and note there is no lock *bypass* doing
34
+ that for you: the fake runs no loop, so `running?` is false and chapter 4's
35
+ rule falls back to "the thread that created the screen," which is yours. A
36
+ spec that mutates the UI from a **spawned** thread therefore raises, exactly
37
+ as an app would. It also pins the color scheme to `:dark`, skipping
35
38
  the OSC 11 probe from chapter 6 — a probe would otherwise write an escape
36
39
  query to the test runner's terminal and swallow its input.
37
40
 
@@ -44,6 +47,17 @@ from nothing. Skip the `after` and you get the classic singleton test
44
47
  smell: passes in isolation, fails in suite, order-dependent. The pair is
45
48
  not boilerplate you can trim.
46
49
 
50
+ One more line of setup earns its place if your app has a theme of its own. A
51
+ fresh `Screen.fake` starts from the built-in {Tuile::ThemeDef}, so a
52
+ component reading `theme[:my_token]` would `KeyError` in every example.
53
+ Rather than assigning `Screen.instance.theme_def` in every `before` block,
54
+ point the construction-time default at your definition once, in
55
+ `spec_helper`:
56
+
57
+ ```ruby
58
+ Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
59
+ ```
60
+
47
61
  ## Asserting what got painted
48
62
 
49
63
  Here's where the back buffer earns its keep. Recall from chapter 2 that
@@ -94,9 +108,25 @@ key, so you assert on that too:
94
108
 
95
109
  ```ruby
96
110
  list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
111
+ ```
112
+
113
+ **A mouse test needs the component mounted, where a key test doesn't.** A
114
+ click doesn't only *do* something, it also *focuses* — and
115
+ {Tuile::Screen#focused=} refuses a component that isn't on the pane, so
116
+ `handle_mouse` on a component you never attached raises "is not attached to
117
+ this screen". Give it a tree first:
118
+
119
+ ```ruby
120
+ screen.content = list # a click focuses; focus needs a tree
121
+ list.rect = Rect.new(0, 0, 10, 5)
97
122
  list.handle_mouse(MouseEvent.new(:left, 5, 2))
98
123
  ```
99
124
 
125
+ That applies to containers too, and to more of them than you might expect:
126
+ a click descends to every child whose rect contains the point, so testing a
127
+ window's footer by clicking it exercises the window, the footer's slot and
128
+ the footer, all of which want to be attached.
129
+
100
130
  **High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
101
131
  dispatch rung from chapter 5 that routing is actually about: delivery to
102
132
  {Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
@@ -29,7 +29,7 @@ tangle has to be re-untangled on every edit.
29
29
  {Tuile::StyledString} untangles it once, structurally. A styled string is
30
30
  a sequence of **spans**, each a maximal run of characters that share one
31
31
  complete {Tuile::StyledString::Style} — foreground, background, bold,
32
- italic, underline, strikethrough. The spans are non-overlapping and tile
32
+ italic, underline, strikethrough, inverse. The spans are non-overlapping and tile
33
33
  the whole string: every character belongs to exactly one span, and that
34
34
  span's `style` *is* the character's style. There are no overlay layers to
35
35
  merge, no running state to reconstruct. "What's the style at column 5?"
@@ -100,8 +100,8 @@ by default.**
100
100
 
101
101
  Strict means it recognizes exactly the SGR codes that map to a
102
102
  {Tuile::StyledString::Style}'s attributes — the foreground and background
103
- colors, bold, italic, underline, strikethrough — and *raises* on anything
104
- else. An unmodeled attribute like blink or reverse video, an unknown SGR
103
+ colors, bold, italic, underline, strikethrough, inverse — and *raises* on
104
+ anything else. An unmodeled attribute like blink or conceal, an unknown SGR
105
105
  code, a non-SGR escape like a cursor move or an OSC sequence: all of them
106
106
  are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
107
107
  contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
data/book/README.md CHANGED
@@ -66,17 +66,19 @@ one, not to fill an outline.
66
66
  `focusable?`, and the three-rung order in which a keystroke is offered
67
67
  to the tree — Tab, global shortcuts, then `handle_key` delivered to
68
68
  focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
69
- form's default button) belong on an ancestor, and how `keyboard_hint`
70
- drives the status bar.
69
+ form's default button) belong on an ancestor, why a paste rides its own
70
+ path rather than the ladder, and how to write a status line over
71
+ `on_focus_changed` — Tuile draws none for you.
71
72
  6. **[Theming](06-theming.md).** Semantic color tokens read at paint
72
73
  time, opt-in component backgrounds that inherit down the tree
73
74
  (`bg_color`), light/dark auto-detection at startup and live OS
74
75
  appearance flips, pairing variants in a `ThemeDef`, app-specific custom
75
76
  tokens, and rebuilding theme-derived content in `on_theme_changed`.
76
77
  7. **[The component library](07-components.md).** A narrative tour of
77
- the shipped toolbox — Window, List, the text inputs and views,
78
- ProgressBar, Popup, and the window conveniences — framed around
79
- *when and why* you reach for each. Signatures stay in the rdoc.
78
+ the shipped toolbox — the text inputs and views, the value fields, the
79
+ selectors, Button, ProgressBar, Window, TabSheet, MenuBar, Popup and the
80
+ window conveniences — framed around *when and why* you reach for each.
81
+ Signatures stay in the rdoc.
80
82
  8. **[Testing a Tuile app](08-testing.md).** The testing approach:
81
83
  `FakeScreen`, asserting against the painted buffer, driving
82
84
  invalidation, and PTY-based end-to-end tests of runnable scripts.
@@ -4,7 +4,9 @@
4
4
  # Tuile two-pane file commander. Two windows side by side, each showing a
5
5
  # directory listing. Tab switches active pane; arrows / jk move the cursor;
6
6
  # Enter descends into a directory (no-op on a regular file); Backspace
7
- # ascends to the parent. The header label shows the active pane's cwd.
7
+ # ascends to the parent. The header label shows the active pane's cwd, and a
8
+ # static status line spells out the keys — Tuile draws no status bar and
9
+ # reserves no row, so both are ordinary children of the layout.
8
10
  # Unreadable directories surface an InfoWindow. Layout follows the
9
11
  # terminal on resize (WINCH) — the framework dispatches a TTYSizeEvent and
10
12
  # the layout's `rect=` rebuilds the geometry.
@@ -80,7 +82,7 @@ module FileCommanderExample
80
82
  @on_cwd_changed&.call
81
83
  rescue SystemCallError => e
82
84
  @cwd = previous
83
- Tuile::Component::InfoWindow.open("Cannot open", [path, e.message])
85
+ Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
84
86
  end
85
87
 
86
88
  def load_entries
@@ -107,18 +109,6 @@ module FileCommanderExample
107
109
  end
108
110
  end
109
111
 
110
- # A pane window that advertises navigation shortcuts in the status bar.
111
- # The active window's `keyboard_hint` is rendered by {Tuile::Screen}
112
- # alongside the global `q` quit hint, so all the user-facing controls
113
- # land in one place.
114
- class PaneWindow < Tuile::Component::Window
115
- def keyboard_hint
116
- "Tab #{screen.theme.hint("Switch")} " \
117
- "Enter #{screen.theme.hint("Open")} " \
118
- "Bksp #{screen.theme.hint("Up")}"
119
- end
120
- end
121
-
122
112
  # Top-level layout. Header label on the first row, two side-by-side
123
113
  # windows below. `rect=` re-runs on the initial mount and on every WINCH,
124
114
  # so the split tracks the terminal size automatically.
@@ -128,19 +118,34 @@ module FileCommanderExample
128
118
  @header = Tuile::Component::Label.new
129
119
  add(@header)
130
120
 
131
- @left_window = PaneWindow.new
121
+ @left_window = Tuile::Component::Window.new
132
122
  @left_list = DirList.new(left_dir)
133
123
  @left_list.on_cwd_changed = method(:refresh_header)
134
124
  @left_window.content = @left_list
135
125
  @left_window.scrollbar = true
136
126
  add(@left_window)
137
127
 
138
- @right_window = PaneWindow.new
128
+ @right_window = Tuile::Component::Window.new
139
129
  @right_list = DirList.new(right_dir)
140
130
  @right_list.on_cwd_changed = method(:refresh_header)
141
131
  @right_window.content = @right_list
142
132
  @right_window.scrollbar = true
143
133
  add(@right_window)
134
+
135
+ # The status line. Every key here works in both panes, so the row never
136
+ # changes and nothing needs to watch focus — a status line is only worth
137
+ # wiring to Tuile::Screen#on_focus_changed= when its text actually varies
138
+ # with the focused component. `theme.hint` bakes its colors in, so the
139
+ # one thing this label does watch is a light/dark flip.
140
+ @status = Tuile::Component::Label.new
141
+ render_status = lambda do
142
+ t = screen.theme
143
+ @status.text = "q #{t.hint("quit")} Tab #{t.hint("Switch")} " \
144
+ "Enter #{t.hint("Open")} Bksp #{t.hint("Up")}"
145
+ end
146
+ render_status.call
147
+ @status.on_theme_changed = render_status
148
+ add(@status)
144
149
  end
145
150
 
146
151
  attr_reader :left_window
@@ -150,8 +155,9 @@ module FileCommanderExample
150
155
  return if rect.empty?
151
156
 
152
157
  @header.rect = Tuile::Rect.new(rect.left, rect.top, rect.width, 1)
158
+ @status.rect = Tuile::Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
153
159
  body_top = rect.top + 1
154
- body_height = [rect.height - 1, 0].max
160
+ body_height = [rect.height - 2, 0].max
155
161
  half = rect.width / 2
156
162
  @left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
157
163
  @right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Tuile hello-world. A Window wrapping a Label.
4
+ # Tuile hello-world. A Window wrapping a Label, over a status line the app
5
+ # owns — Tuile draws no chrome of its own and reserves no row.
5
6
  #
6
7
  # Run from the gem root:
7
8
  # bundle exec ruby -Ilib examples/hello_world.rb
@@ -14,12 +15,23 @@ require "tuile"
14
15
  # Tuile::Screen.instance during invalidate/repaint hooks.
15
16
  screen = Tuile::Screen.new
16
17
 
17
- label = Tuile::Component::Label.new("Hello, world!")
18
-
19
18
  window = Tuile::Component::Window.new("Tuile")
20
- window.content = label
19
+ window.content = Tuile::Component::Label.new("Hello, world!")
20
+
21
+ # The status line. `theme.hint` styles the *description* half of a "key what"
22
+ # pair, and bakes the color in — so the label rebuilds itself from
23
+ # `on_theme_changed` to follow a light/dark flip.
24
+ status = Tuile::Component::Label.new
25
+ render_status = -> { status.text = "q #{screen.theme.hint("quit")}" }
26
+ render_status.call
27
+ status.on_theme_changed = render_status
28
+
29
+ # One row for the status line, everything else to the window.
30
+ root = Tuile::Component::Layout::Vertical.new
31
+ root.add(window, Tuile::Component::Layout::Expand[1])
32
+ root.add(status, Tuile::Component::Layout::Fixed[1])
21
33
 
22
- screen.content = window
34
+ screen.content = root
23
35
  window.focus
24
36
  begin
25
37
  screen.run_event_loop