thaum 0.1.0 → 0.2.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -14
  3. data/examples/checkbox.rb +89 -0
  4. data/examples/counter.rb +50 -0
  5. data/examples/hello_world.rb +28 -0
  6. data/examples/layout_demo.rb +138 -0
  7. data/examples/modal.rb +76 -0
  8. data/examples/mouse.rb +60 -0
  9. data/examples/octagram_picker.rb +224 -0
  10. data/examples/picker.rb +150 -0
  11. data/examples/progress_bar.rb +90 -0
  12. data/examples/scroll_view.rb +64 -0
  13. data/examples/select.rb +64 -0
  14. data/examples/spinner.rb +66 -0
  15. data/examples/status_bar.rb +65 -0
  16. data/examples/stopwatch.rb +84 -0
  17. data/examples/table.rb +196 -0
  18. data/examples/tabs.rb +112 -0
  19. data/examples/text.rb +101 -0
  20. data/examples/theme_picker.rb +95 -0
  21. data/examples/todo.rb +242 -0
  22. data/lib/thaum/action.rb +30 -0
  23. data/lib/thaum/app.rb +87 -0
  24. data/lib/thaum/color.rb +97 -0
  25. data/lib/thaum/concerns/context_update.rb +40 -0
  26. data/lib/thaum/concerns/focus.rb +53 -0
  27. data/lib/thaum/concerns/layout.rb +349 -0
  28. data/lib/thaum/concerns/modal.rb +102 -0
  29. data/lib/thaum/concerns/tab_navigation.rb +97 -0
  30. data/lib/thaum/dispatch.rb +149 -0
  31. data/lib/thaum/escape_parser.rb +265 -0
  32. data/lib/thaum/event.rb +13 -0
  33. data/lib/thaum/events.rb +28 -0
  34. data/lib/thaum/hit_test.rb +28 -0
  35. data/lib/thaum/input_reader.rb +46 -0
  36. data/lib/thaum/key_event.rb +13 -0
  37. data/lib/thaum/keys.rb +55 -0
  38. data/lib/thaum/minitest.rb +64 -0
  39. data/lib/thaum/octagram.rb +76 -0
  40. data/lib/thaum/painter.rb +49 -0
  41. data/lib/thaum/rect.rb +5 -0
  42. data/lib/thaum/rendering/box_drawing.rb +186 -0
  43. data/lib/thaum/rendering/buffer.rb +84 -0
  44. data/lib/thaum/rendering/canvas.rb +219 -0
  45. data/lib/thaum/rendering/cell.rb +11 -0
  46. data/lib/thaum/rendering/renderer.rb +98 -0
  47. data/lib/thaum/rendering/style.rb +13 -0
  48. data/lib/thaum/run_loop.rb +182 -0
  49. data/lib/thaum/seq.rb +91 -0
  50. data/lib/thaum/sigil.rb +41 -0
  51. data/lib/thaum/sigils/button.rb +47 -0
  52. data/lib/thaum/sigils/checkbox.rb +57 -0
  53. data/lib/thaum/sigils/progress_bar.rb +65 -0
  54. data/lib/thaum/sigils/scroll_view.rb +115 -0
  55. data/lib/thaum/sigils/select.rb +56 -0
  56. data/lib/thaum/sigils/spinner.rb +39 -0
  57. data/lib/thaum/sigils/status_bar.rb +89 -0
  58. data/lib/thaum/sigils/table.rb +156 -0
  59. data/lib/thaum/sigils/tabs.rb +59 -0
  60. data/lib/thaum/sigils/text.rb +22 -0
  61. data/lib/thaum/sigils/text_input.rb +86 -0
  62. data/lib/thaum/terminal.rb +46 -0
  63. data/lib/thaum/themes.rb +267 -0
  64. data/lib/thaum/tree.rb +16 -0
  65. data/lib/thaum/version.rb +1 -1
  66. data/lib/thaum.rb +64 -1
  67. metadata +114 -4
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Horizontal bar of labeled segments separated by a configurable
5
+ # delimiter. Segments are either plain Strings (decorative) or Hashes
6
+ # `{ label:, on_click: }` whose click handler fires when the user
7
+ # left-presses inside the segment's column range. Non-focusable —
8
+ # status bars sit at the bottom of the focus order. Mouse events the
9
+ # bar does not act on are eaten (not propagated) so the bar acts as
10
+ # the floor of the click target.
11
+ class StatusBar
12
+ include Sigil
13
+
14
+ DEFAULT_SEPARATOR = " │ "
15
+
16
+ attr_reader :separator, :segments
17
+
18
+ def initialize(segments:, separator: DEFAULT_SEPARATOR)
19
+ @segments = segments
20
+ @separator = separator
21
+ @ranges = [] # parallel array: [start_col, end_col_exclusive] per segment
22
+ end
23
+
24
+ def focusable? = false
25
+
26
+ def segments=(value)
27
+ @segments = value
28
+ request_render
29
+ end
30
+
31
+ def on_mouse(event)
32
+ return unless event.action == :press && event.button == :left
33
+
34
+ seg = segment_at(event.x) or return
35
+ handler = on_click(seg) or return
36
+ if handler.arity.zero?
37
+ handler.call
38
+ else
39
+ handler.call(event)
40
+ end
41
+ end
42
+
43
+ def render(canvas:, theme:)
44
+ canvas.fill(bg: theme.bar_bg)
45
+ @ranges = []
46
+ x = 0
47
+ width = canvas.width
48
+ @segments.each_with_index do |seg, idx|
49
+ x += draw_separator(canvas: canvas, theme: theme, x: x, width: width) if idx.positive?
50
+ break if x >= width
51
+
52
+ label = label(seg)
53
+ label_w = label.display_width
54
+ start = x
55
+ canvas.text(content: label, x: x, fg: theme.fg, bg: theme.bar_bg)
56
+ x += label_w
57
+ finish = [x, width].min
58
+ @ranges << [start, finish]
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def draw_separator(canvas:, theme:, x:, width:)
65
+ return 0 if x >= width
66
+
67
+ canvas.text(content: @separator, x: x, fg: theme.dim, bg: theme.bar_bg)
68
+ @separator.display_width
69
+ end
70
+
71
+ def label(seg)
72
+ seg.is_a?(Hash) ? seg.fetch(:label).to_s : seg.to_s
73
+ end
74
+
75
+ def on_click(seg)
76
+ return nil unless seg.is_a?(Hash)
77
+
78
+ seg[:on_click]
79
+ end
80
+
81
+ def segment_at(col)
82
+ @segments.each_with_index do |seg, idx|
83
+ range = @ranges[idx] or next
84
+ return seg if col >= range[0] && col < range[1]
85
+ end
86
+ nil
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ class Table
5
+ include Sigil
6
+
7
+ SelectedEvent = Thaum::Event.define(:index, :row)
8
+
9
+ PAGE_STEP = 10
10
+
11
+ attr_reader :headers, :rows, :widths, :cursor, :offset
12
+
13
+ def initialize(headers:, rows:, widths: nil)
14
+ @headers = headers
15
+ @rows = rows
16
+ @widths = widths
17
+ @cursor = 0
18
+ @offset = 0
19
+ end
20
+
21
+ def on_key(event)
22
+ case event.key
23
+ when :up then @cursor = [@cursor - 1, 0].max
24
+ when :down then @cursor = [@cursor + 1, rows.length - 1].min if rows.any?
25
+ when :home then @cursor = 0
26
+ when :end then @cursor = rows.length - 1 if rows.any?
27
+ when :page_up then @cursor = [@cursor - PAGE_STEP, 0].max
28
+ when :page_down then @cursor = [@cursor + PAGE_STEP, rows.length - 1].min if rows.any?
29
+ when :enter then emit_selected
30
+ else emit event
31
+ end
32
+ end
33
+
34
+ def render(canvas:, theme:)
35
+ canvas.fill(bg: theme.bg)
36
+ widths = effective_widths(canvas.width)
37
+ visible_offset(canvas)
38
+
39
+ render_header(canvas: canvas, theme: theme, widths: widths)
40
+ render_separator(canvas: canvas, theme: theme)
41
+ render_data_rows(canvas: canvas, theme: theme, widths: widths)
42
+ end
43
+
44
+ private
45
+
46
+ def emit_selected
47
+ return if rows.empty?
48
+
49
+ emit SelectedEvent.new(index: @cursor, row: rows[@cursor])
50
+ end
51
+
52
+ def render_header(canvas:, theme:, widths:)
53
+ row = canvas.row(0) or return
54
+
55
+ row.fill(bg: theme.bar_bg)
56
+ row.text(content: format_cells(cells: @headers, widths: widths), fg: theme.accent, bg: theme.bar_bg)
57
+ end
58
+
59
+ def render_separator(canvas:, theme:)
60
+ row = canvas.row(1) or return
61
+
62
+ row.fill(bg: theme.bg)
63
+ row.text(content: "─" * canvas.width, fg: theme.border, bg: theme.bg)
64
+ end
65
+
66
+ def render_data_rows(canvas:, theme:, widths:)
67
+ visible_rows = canvas.height - 2
68
+ return if visible_rows <= 0
69
+
70
+ visible_rows.times do |i|
71
+ file_idx = @offset + i
72
+ row_data = rows[file_idx] or break
73
+ row = canvas.row(i + 2) or break
74
+
75
+ selected = file_idx == @cursor
76
+ bg = selected ? theme.selection : theme.bg
77
+ fg = selected ? theme.selection_fg : theme.fg
78
+ row.fill(bg: bg)
79
+ row.text(content: format_cells(cells: row_data, widths: widths), fg: fg, bg: bg)
80
+ end
81
+ end
82
+
83
+ def format_cells(cells:, widths:)
84
+ cells.each_with_index.map { |cell, idx| fit(str: cell.to_s, width: widths[idx] || 0) }.join(" ")
85
+ end
86
+
87
+ # Pad with spaces or truncate to fit exactly `width` display columns.
88
+ def fit(str:, width:)
89
+ return "" if width <= 0
90
+
91
+ w = str.display_width
92
+ return str + (" " * (width - w)) if w <= width
93
+
94
+ truncate_to_width(str: str, width: width)
95
+ end
96
+
97
+ def truncate_to_width(str:, width:)
98
+ out = +""
99
+ cols = 0
100
+ str.each_char do |c|
101
+ cw = c.display_width
102
+ break if cols + cw > width
103
+
104
+ out << c
105
+ cols += cw
106
+ end
107
+ out << (" " * (width - cols)) if cols < width
108
+ out
109
+ end
110
+
111
+ def visible_offset(canvas)
112
+ visible_rows = canvas.height - 2
113
+ return @offset = 0 if visible_rows <= 0
114
+
115
+ if @cursor < @offset
116
+ @offset = @cursor
117
+ elsif @cursor >= @offset + visible_rows
118
+ @offset = @cursor - visible_rows + 1
119
+ end
120
+
121
+ max_offset = [rows.length - visible_rows, 0].max
122
+ @offset = @offset.clamp(0, max_offset)
123
+ end
124
+
125
+ def effective_widths(canvas_width)
126
+ return widths if widths
127
+
128
+ col_count = headers.length
129
+ return [] if col_count.zero?
130
+
131
+ max_widths = column_max_widths(col_count)
132
+ separators = col_count - 1
133
+ total_max = max_widths.sum
134
+ budget = canvas_width - separators
135
+
136
+ return max_widths if total_max <= budget || budget <= 0
137
+
138
+ scale_widths(max_widths: max_widths, total_max: total_max, budget: budget)
139
+ end
140
+
141
+ def column_max_widths(col_count)
142
+ Array.new(col_count) do |idx|
143
+ header_w = (headers[idx] || "").to_s.display_width
144
+ row_w = rows.map { |r| (r[idx] || "").to_s.display_width }.max || 0
145
+ [header_w, row_w].max
146
+ end
147
+ end
148
+
149
+ def scale_widths(max_widths:, total_max:, budget:)
150
+ scaled = max_widths.map { |w| (budget * w / total_max).floor }
151
+ leftover = budget - scaled.sum
152
+ scaled[-1] += leftover if scaled.any?
153
+ scaled
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ # Horizontal tab strip. ←/→ cycle the active tab (with wrap). Emits
5
+ # Tabs::ActivatedEvent when the active index changes. The content under each
6
+ # tab is the App's concern — Tabs only owns the strip.
7
+ class Tabs
8
+ include Sigil
9
+
10
+ ActivatedEvent = Thaum::Event.define(:index, :label)
11
+
12
+ attr_reader :labels, :active
13
+
14
+ def initialize(labels:, active: 0)
15
+ raise ArgumentError, "Tabs needs at least one label" if labels.empty?
16
+
17
+ @labels = labels
18
+ @active = active.clamp(0, labels.length - 1)
19
+ end
20
+
21
+ def current = @labels[@active]
22
+
23
+ def on_key(event)
24
+ case event.key
25
+ when :left then move(-1)
26
+ when :right then move(1)
27
+ else emit event
28
+ end
29
+ end
30
+
31
+ def render(canvas:, theme:)
32
+ canvas.fill(bg: theme.bar_bg)
33
+ x = 0
34
+ @labels.each_with_index do |label, idx|
35
+ x += draw_tab(canvas: canvas, label: label, idx: idx, x: x, theme: theme)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def move(delta)
42
+ new_idx = (@active + delta) % @labels.length
43
+ return if new_idx == @active
44
+
45
+ @active = new_idx
46
+ emit ActivatedEvent.new(index: @active, label: current)
47
+ end
48
+
49
+ def draw_tab(canvas:, label:, idx:, x:, theme:)
50
+ cell = " #{label} "
51
+ width = cell.display_width
52
+ bg = idx == @active ? theme.selection : theme.bar_bg
53
+ fg = idx == @active ? theme.selection_fg : theme.fg
54
+ canvas.fill(bg: bg, x: x, width: width)
55
+ canvas.text(content: cell, x: x, fg: fg, bg: bg)
56
+ width
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ class Text
5
+ include Sigil
6
+
7
+ attr_accessor :content
8
+
9
+ def initialize(content:, align: :left, wrap: :none)
10
+ @content = content
11
+ @align = align
12
+ @wrap = wrap
13
+ end
14
+
15
+ def focusable? = false
16
+
17
+ def render(canvas:, theme:)
18
+ resolved = content.respond_to?(:call) ? content.call : content
19
+ canvas.text(content: resolved.to_s, fg: theme.fg, align: @align, wrap: @wrap)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thaum
4
+ class TextInput
5
+ include Sigil
6
+
7
+ SubmittedEvent = Thaum::Event.define(:value)
8
+
9
+ attr_reader :value, :cursor
10
+
11
+ def initialize(value: "")
12
+ @value = value.dup
13
+ @cursor = @value.length
14
+ end
15
+
16
+ def clear
17
+ @value = +""
18
+ @cursor = 0
19
+ end
20
+
21
+ def on_key(event)
22
+ key = event.key
23
+ case key
24
+ when String then insert(key) unless event.ctrl? || event.alt?
25
+ when :backspace then backspace
26
+ when :delete then delete_forward
27
+ when :left then @cursor = [@cursor - 1, 0].max
28
+ when :right then @cursor = [@cursor + 1, @value.length].min
29
+ when :home then @cursor = 0
30
+ when :end then @cursor = @value.length
31
+ when :enter then emit SubmittedEvent.new(value: @value)
32
+ else emit event
33
+ end
34
+ end
35
+
36
+ def render(canvas:, theme:)
37
+ offset = scroll_offset(canvas.width)
38
+ visible = @value[offset..] || ""
39
+ cursor_x = display_width(@value[offset...@cursor])
40
+ canvas.fill(bg: theme.input_bg)
41
+ canvas.text(content: visible, fg: theme.fg, bg: theme.input_bg)
42
+ canvas.cursor(x: cursor_x, y: 0) if focused?
43
+ end
44
+
45
+ private
46
+
47
+ def insert(char)
48
+ @value = @value[0...@cursor] + char + @value[@cursor..]
49
+ @cursor += char.length
50
+ end
51
+
52
+ def backspace
53
+ return if @cursor.zero?
54
+
55
+ @value = @value[0...(@cursor - 1)] + @value[@cursor..]
56
+ @cursor -= 1
57
+ end
58
+
59
+ def delete_forward
60
+ return if @cursor >= @value.length
61
+
62
+ @value = @value[0...@cursor] + @value[(@cursor + 1)..]
63
+ end
64
+
65
+ # Keep the cursor inside the visible window, measured in display columns
66
+ # (not character indices) so wide chars (CJK, emoji) scroll correctly.
67
+ # When the prefix fits, no scroll. Otherwise drop leading chars until the
68
+ # cursor sits at column width-1.
69
+ def scroll_offset(width)
70
+ prefix_cols = display_width(@value[0...@cursor])
71
+ return 0 if prefix_cols < width
72
+
73
+ target = width - 1
74
+ chars = @value.chars
75
+ offset = 0
76
+ cols = prefix_cols
77
+ while cols > target && offset < @cursor
78
+ cols -= chars[offset].display_width
79
+ offset += 1
80
+ end
81
+ offset
82
+ end
83
+
84
+ def display_width(str) = (str || "").display_width
85
+ end
86
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Thaum
6
+ # Manages terminal setup and teardown: alternate screen, cursor, mouse, and bracketed paste.
7
+ class Terminal
8
+ def initialize(input: $stdin, output: $stdout)
9
+ @input = input
10
+ @output = output
11
+ end
12
+
13
+ def setup
14
+ @original_stty = stty_save
15
+ @input.raw!
16
+ write Seq::ALT_SCREEN_ON
17
+ write Seq::CURSOR_HIDE
18
+ write Seq::BRACKETED_PASTE_ON
19
+ write Seq::SGR_MOUSE_ON
20
+ write Seq::CELL_MOTION_ON
21
+ end
22
+
23
+ def teardown
24
+ write Seq::CELL_MOTION_OFF
25
+ write Seq::SGR_MOUSE_OFF
26
+ write Seq::BRACKETED_PASTE_OFF
27
+ write Seq::CURSOR_SHOW
28
+ write Seq::ALT_SCREEN_OFF
29
+ stty_restore(@original_stty)
30
+ end
31
+
32
+ def size
33
+ rows, cols = @input.winsize
34
+ [cols, rows]
35
+ end
36
+
37
+ private
38
+
39
+ def write(str) = @output.write(str)
40
+ def stty_save = @input.respond_to?(:tty?) && @input.tty? ? `stty -g 2>/dev/null`.chomp : ""
41
+
42
+ def stty_restore(state)
43
+ system("stty", state) if state && !state.empty?
44
+ end
45
+ end
46
+ end