tui_tui 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +6 -10
- data/examples/file_browser.rb +6 -4
- data/examples/paint.rb +3 -2
- data/examples/widgets.rb +3 -2
- data/lib/tui_tui/box_chrome.rb +54 -0
- data/lib/tui_tui/box_prober.rb +61 -0
- data/lib/tui_tui/canvas.rb +12 -9
- data/lib/tui_tui/list.rb +16 -2
- data/lib/tui_tui/modal_host.rb +61 -0
- data/lib/tui_tui/rect.rb +8 -0
- data/lib/tui_tui/render_context.rb +14 -0
- data/lib/tui_tui/runtime.rb +11 -4
- data/lib/tui_tui/screen.rb +22 -6
- data/lib/tui_tui/scrollbar.rb +2 -1
- data/lib/tui_tui/span.rb +5 -0
- data/lib/tui_tui/text_view.rb +6 -2
- data/lib/tui_tui/theme.rb +32 -4
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui.rb +4 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 499d4c344b19e2fca357f58d50471d80033b99b5fb94f32943f70383dd4eceaa
|
|
4
|
+
data.tar.gz: 31b39eb3d5d3de1361a9f3679277f4ea859b0fc5b7a5bd9a78bb6d69e250adf7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74c01a1dc4fc3d95568bb9c4fde27bb43b6047f2ea17547342df2ec051e159a6b8aabaa7380039d87aa763dea0d61c24e0e7736ce9d9b29091dabe78cec041ab
|
|
7
|
+
data.tar.gz: 5241bf9a256248c5da1430f67068d74639dfd44fce1be034b20736f4f55d0334ab4ad296a232a9433b2d53ba9c2054a60c509b9ee765d667e74d67fe9915c6cf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-06-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Optional Unicode box-drawing chrome: probed once at startup and used only when
|
|
7
|
+
the terminal renders it at width 1, else ASCII. Override with `TUITUI_BOX`.
|
|
8
|
+
- `RenderContext` passed to `view` (a `Size`-compatible value with a `canvas`
|
|
9
|
+
factory); legacy `view(size)` apps keep working.
|
|
10
|
+
- `Rect#include?(row, col)` and `Rect#hit?(mouse_event)` for mouse hit-testing.
|
|
11
|
+
- `List#index_at(rect, event, scrollbar:)` to map a click to a list index,
|
|
12
|
+
accounting for the scroll offset and the scrollbar gutter.
|
|
13
|
+
- `Theme` semantic status roles — `success` / `warning` / `danger` / `info`
|
|
14
|
+
(background-aware, hue-independent) — plus `Theme#status(kind)` to map
|
|
15
|
+
symbolic kinds (`:ok`, `:warn`, `:error`, `:info`, with aliases) to a role.
|
|
16
|
+
- `ModalHost`: a host-side helper that owns the current modal widget, routing
|
|
17
|
+
`MouseEvent`s to `#handle_mouse` and other events to `#handle`, and running an
|
|
18
|
+
`on_result` callback when the widget resolves.
|
|
19
|
+
- `auto:` option for `List.draw` / `TextView.draw`: reserve the scrollbar gutter
|
|
20
|
+
only when the content overflows the rect.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Silence the "method redefined; discarding old []" warning from `Span` under
|
|
24
|
+
`-w` by removing the `Data`-generated `.[]` before redefining the convenience
|
|
25
|
+
constructor.
|
|
26
|
+
|
|
27
|
+
## [0.1.0] - 2026-06-16
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Initial release: a lightweight, dependency-free (io/console only) TEA-inspired
|
|
31
|
+
(MVU) TUI toolkit — Canvas with per-cell diffing, Theme, layout `Rect`s,
|
|
32
|
+
widgets (List, TextView, Scrollbar, StatusBar, Toast, Modal, Confirm, Select,
|
|
33
|
+
Help, Prompt, Pager, Fuzzy), East-Asian-width-aware text, and a `Runtime`
|
|
34
|
+
event loop.
|
data/README.md
CHANGED
|
@@ -84,17 +84,12 @@ Glyphs are clipped at region edges, not split across them.
|
|
|
84
84
|
Movement and redraw stay responsive, even with large content.
|
|
85
85
|
Only changed rows are repainted, so cost scales with the change, not the screen size.
|
|
86
86
|
|
|
87
|
-
#### N7: Width-safe UI chrome
|
|
87
|
+
#### N7: Width-safe UI chrome.
|
|
88
88
|
|
|
89
|
-
Self-drawn chrome
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Their width can vary under CJK terminal settings and break layouts.
|
|
94
|
-
|
|
95
|
-
Vertical splits are drawn as a colored one-column gutter.
|
|
96
|
-
Rules are drawn with ASCII `-` or a background fill.
|
|
97
|
-
Selection is drawn with `:reverse`.
|
|
89
|
+
Self-drawn chrome defaults to ASCII, color, and spacing, which have a guaranteed
|
|
90
|
+
width of 1. Unicode box-drawing has an ambiguous width that can break layouts under
|
|
91
|
+
CJK terminal settings, so it is only used when the terminal is confirmed to render
|
|
92
|
+
it at width 1; otherwise the chrome falls back to ASCII.
|
|
98
93
|
|
|
99
94
|
Content text, such as Japanese data, is measured with `Width`.
|
|
100
95
|
It is clipped or padded to fit the available space.
|
|
@@ -105,6 +100,7 @@ Environment variables (all optional):
|
|
|
105
100
|
|
|
106
101
|
- `TUITUI_MOUSE` — set to `0`/`off`/`false` to disable mouse reporting (on by default).
|
|
107
102
|
- `TUITUI_BACKGROUND` — `light` or `dark` to pick the theme for your terminal background. Without it, `COLORFGBG` is read if present, otherwise `dark` is assumed (reliable auto-detection isn't possible on all terminals).
|
|
103
|
+
- `TUITUI_BOX` — `ascii` / `unicode` / `auto` to force or auto-detect Unicode box-drawing chrome (default `auto`: used only when the terminal renders it at width 1, else ASCII).
|
|
108
104
|
|
|
109
105
|
## Installation
|
|
110
106
|
|
data/examples/file_browser.rb
CHANGED
|
@@ -288,8 +288,9 @@ module FileBrowserSample
|
|
|
288
288
|
|
|
289
289
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
290
290
|
|
|
291
|
-
def view(
|
|
292
|
-
|
|
291
|
+
def view(ctx)
|
|
292
|
+
size = ctx.size
|
|
293
|
+
canvas = ctx.canvas
|
|
293
294
|
body, status = split_status(size)
|
|
294
295
|
list_rect, preview_rect = split_panes(body)
|
|
295
296
|
@list_rect = list_rect # remembered so a click can hit-test the list
|
|
@@ -552,10 +553,11 @@ module FileBrowserSample
|
|
|
552
553
|
# --- drawing ---
|
|
553
554
|
|
|
554
555
|
# A dim vertical rule in the 1-column gutter between the panes (the column
|
|
555
|
-
# split_ratio left between list and preview).
|
|
556
|
+
# split_ratio left between list and preview). Follows the canvas chrome:
|
|
557
|
+
# ASCII "|" by default, "│" when the terminal probed as Unicode-capable.
|
|
556
558
|
def draw_divider(canvas, list_rect)
|
|
557
559
|
col = list_rect.col + list_rect.cols
|
|
558
|
-
canvas.fill(TuiTui::Rect.new(row: list_rect.row, col: col, rows: list_rect.rows, cols: 1), @styles[:divider],
|
|
560
|
+
canvas.fill(TuiTui::Rect.new(row: list_rect.row, col: col, rows: list_rect.rows, cols: 1), @styles[:divider], canvas.chrome.v)
|
|
559
561
|
end
|
|
560
562
|
|
|
561
563
|
def draw_list(canvas, rect)
|
data/examples/paint.rb
CHANGED
|
@@ -160,9 +160,10 @@ module PaintSample
|
|
|
160
160
|
end
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
-
def view(
|
|
163
|
+
def view(ctx)
|
|
164
|
+
size = ctx.size
|
|
164
165
|
@rows = size.rows
|
|
165
|
-
canvas =
|
|
166
|
+
canvas = ctx.canvas
|
|
166
167
|
@cells.each do |(row, col), color|
|
|
167
168
|
next unless row.between?(PAINT_TOP, size.rows) && col.between?(1, size.cols)
|
|
168
169
|
|
data/examples/widgets.rb
CHANGED
|
@@ -46,8 +46,9 @@ module WidgetsSample
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
def view(
|
|
50
|
-
|
|
49
|
+
def view(ctx)
|
|
50
|
+
size = ctx.size
|
|
51
|
+
canvas = ctx.canvas
|
|
51
52
|
canvas.text(2, 3, "TuiTui widget gallery", @theme.title)
|
|
52
53
|
canvas.text(4, 3, "theme: #{THEMES[@theme_i]}", @theme.accent)
|
|
53
54
|
canvas.text(5, 3, "last result: #{@last}", @theme.text)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TuiTui
|
|
4
|
+
# The glyph set used to draw chrome (frame borders, dividers, scrollbar track).
|
|
5
|
+
BoxChrome = Data.define(:tl, :tr, :bl, :br, :h, :v, :lt, :rt, :tt, :bt, :cross, :track)
|
|
6
|
+
|
|
7
|
+
class BoxChrome
|
|
8
|
+
ASCII = new(
|
|
9
|
+
tl: "+", tr: "+", bl: "+", br: "+",
|
|
10
|
+
h: "-", v: "|",
|
|
11
|
+
lt: "+", rt: "+", tt: "+", bt: "+", cross: "+",
|
|
12
|
+
track: "|"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Single-line box drawing (U+2500..U+253C).
|
|
16
|
+
UNICODE = new(
|
|
17
|
+
tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
18
|
+
h: "─", v: "│",
|
|
19
|
+
lt: "├", rt: "┤", tt: "┬", bt: "┴", cross: "┼",
|
|
20
|
+
track: "│"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# The distinct Unicode glyphs chrome can emit, probed as one string.
|
|
24
|
+
PROBE_GLYPHS = "─│┌┐└┘├┤┬┴┼"
|
|
25
|
+
|
|
26
|
+
# Narrower than this and the probe glyphs would wrap at column 1.
|
|
27
|
+
MIN_PROBE_COLS = 12
|
|
28
|
+
|
|
29
|
+
# Resolve an override string to a chrome, or :auto when a probe is needed.
|
|
30
|
+
def self.from(name)
|
|
31
|
+
case name.to_s.downcase
|
|
32
|
+
when "ascii", "0", "off", "false" then ASCII
|
|
33
|
+
when "unicode", "1", "on", "true" then UNICODE
|
|
34
|
+
else :auto
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The capability gate: every probed glyph must render at width 1, so the total
|
|
39
|
+
# advance equals the glyph count.
|
|
40
|
+
def self.supported?(total_width)
|
|
41
|
+
total_width == PROBE_GLYPHS.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Full resolution given a live console. Honors TUITUI_BOX, else probes; falls
|
|
45
|
+
# back to ASCII when forced off, the terminal is too narrow, or the probe fails.
|
|
46
|
+
def self.resolve(input:, output:, term_cols:, env: ENV, prober: BoxProber.new)
|
|
47
|
+
forced = from(env["TUITUI_BOX"].to_s)
|
|
48
|
+
return forced unless forced == :auto
|
|
49
|
+
return ASCII if term_cols < MIN_PROBE_COLS
|
|
50
|
+
|
|
51
|
+
supported?(prober.measure_all(input: input, output: output)) ? UNICODE : ASCII
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "box_chrome"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Measures how many columns a string of box-drawing glyphs actually occupies on
|
|
7
|
+
# the live terminal, by printing them and asking for the cursor position (DSR).
|
|
8
|
+
class BoxProber
|
|
9
|
+
# Cursor Position Report: ESC [ row ; col R -> capture the column.
|
|
10
|
+
CPR = /\e\[\d+;(\d+)R/
|
|
11
|
+
MAX_REPLY_BYTES = 64
|
|
12
|
+
|
|
13
|
+
def initialize(glyphs: BoxChrome::PROBE_GLYPHS, timeout: 0.2, wait: nil)
|
|
14
|
+
@glyphs = glyphs
|
|
15
|
+
@timeout = timeout
|
|
16
|
+
@wait = wait || method(:wait_readable)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def measure_all(input:, output:)
|
|
20
|
+
output.write("\r") # known baseline: column 1
|
|
21
|
+
output.write(@glyphs) # advances by the sum of glyph widths
|
|
22
|
+
output.write("\e[6n") # DSR: request cursor position
|
|
23
|
+
output.flush
|
|
24
|
+
col = read_column(input)
|
|
25
|
+
cleanup(output)
|
|
26
|
+
col.nil? ? -1 : col - 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Wipe the probe line before the first render (the alt screen stays clean).
|
|
32
|
+
def cleanup(output)
|
|
33
|
+
output.write("\r\e[K")
|
|
34
|
+
output.flush
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read_column(input)
|
|
38
|
+
deadline = monotonic + @timeout
|
|
39
|
+
buf = +""
|
|
40
|
+
loop do
|
|
41
|
+
remaining = deadline - monotonic
|
|
42
|
+
break if remaining <= 0
|
|
43
|
+
break unless @wait.call(input, remaining)
|
|
44
|
+
|
|
45
|
+
char = input.getc
|
|
46
|
+
break if char.nil?
|
|
47
|
+
|
|
48
|
+
buf << char
|
|
49
|
+
if (match = CPR.match(buf))
|
|
50
|
+
return match[1].to_i
|
|
51
|
+
end
|
|
52
|
+
break if buf.bytesize > MAX_REPLY_BYTES
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wait_readable(io, timeout) = io.wait_readable(timeout)
|
|
58
|
+
|
|
59
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/tui_tui/canvas.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "text_sanitizer"
|
|
|
5
5
|
require_relative "display_text"
|
|
6
6
|
require_relative "style"
|
|
7
7
|
require_relative "cell"
|
|
8
|
+
require_relative "box_chrome"
|
|
8
9
|
|
|
9
10
|
module TuiTui
|
|
10
11
|
# Pure drawing surface. Coordinates are 1-origin to match terminal cursor
|
|
@@ -14,18 +15,20 @@ module TuiTui
|
|
|
14
15
|
CONTROL_GLYPH = "?"
|
|
15
16
|
FRAME = Style.new(fg: :bright_black)
|
|
16
17
|
|
|
17
|
-
def self.blank(size)
|
|
18
|
-
new(size.rows, size.cols)
|
|
18
|
+
def self.blank(size, chrome: BoxChrome::ASCII)
|
|
19
|
+
new(size.rows, size.cols, chrome: chrome)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
attr_reader :rows, :cols
|
|
22
23
|
attr_reader :cursor
|
|
24
|
+
attr_reader :chrome
|
|
23
25
|
|
|
24
|
-
def initialize(rows, cols)
|
|
26
|
+
def initialize(rows, cols, chrome: BoxChrome::ASCII)
|
|
25
27
|
@rows = rows
|
|
26
28
|
@cols = cols
|
|
27
29
|
@grid = Array.new(rows) { Array.new(cols, Cell::BLANK) }
|
|
28
30
|
@cursor = nil
|
|
31
|
+
@chrome = chrome
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def cursor_at(row, col)
|
|
@@ -92,14 +95,14 @@ module TuiTui
|
|
|
92
95
|
text(row, col, char * len, style)
|
|
93
96
|
end
|
|
94
97
|
|
|
95
|
-
def frame(rect, style: FRAME)
|
|
98
|
+
def frame(rect, style: FRAME, chrome: @chrome)
|
|
96
99
|
fill(rect, nil)
|
|
97
|
-
|
|
98
|
-
text(rect.row, rect.col,
|
|
99
|
-
text(rect.row + rect.rows - 1, rect.col,
|
|
100
|
+
mid = chrome.h * (rect.cols - 2)
|
|
101
|
+
text(rect.row, rect.col, chrome.tl + mid + chrome.tr, style)
|
|
102
|
+
text(rect.row + rect.rows - 1, rect.col, chrome.bl + mid + chrome.br, style)
|
|
100
103
|
(1...(rect.rows - 1)).each do |dy|
|
|
101
|
-
text(rect.row + dy, rect.col,
|
|
102
|
-
text(rect.row + dy, rect.col + rect.cols - 1,
|
|
104
|
+
text(rect.row + dy, rect.col, chrome.v, style)
|
|
105
|
+
text(rect.row + dy, rect.col + rect.cols - 1, chrome.v, style)
|
|
103
106
|
end
|
|
104
107
|
|
|
105
108
|
self
|
data/lib/tui_tui/list.rb
CHANGED
|
@@ -12,8 +12,10 @@ module TuiTui
|
|
|
12
12
|
@scroll = scroll
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def draw(canvas, rect, highlight: nil, scrollbar: nil)
|
|
16
|
-
|
|
15
|
+
def draw(canvas, rect, highlight: nil, scrollbar: nil, auto: false)
|
|
16
|
+
# With auto:, reserve the gutter only when the content overflows the rect.
|
|
17
|
+
show_bar = scrollbar && !(auto && @scroll.count <= rect.rows)
|
|
18
|
+
body, gutter = show_bar ? rect.split_gutter : [rect, nil]
|
|
17
19
|
@scroll.ensure_visible(body.rows)
|
|
18
20
|
@scroll.each_visible(body.rows) do |index, offset|
|
|
19
21
|
row = body.row + offset
|
|
@@ -26,6 +28,18 @@ module TuiTui
|
|
|
26
28
|
canvas
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Map a MouseEvent to the list index under it, or nil. Pass the same `rect`
|
|
32
|
+
# and `scrollbar:` used for `draw` so the gutter column is excluded and the
|
|
33
|
+
# scroll offset matches what was rendered. Returns nil for clicks outside the
|
|
34
|
+
# body or below the last item.
|
|
35
|
+
def index_at(rect, event, scrollbar: nil)
|
|
36
|
+
body = scrollbar ? rect.split_gutter.first : rect
|
|
37
|
+
return nil unless body.hit?(event)
|
|
38
|
+
|
|
39
|
+
index = @scroll.top + (event.row - body.row)
|
|
40
|
+
index < @scroll.count ? index : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
private
|
|
30
44
|
|
|
31
45
|
def draw_scrollbar(canvas, gutter, theme)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# Host-side helper for the app that *owns* the current modal widget.
|
|
7
|
+
#
|
|
8
|
+
# Centralizes the open + dispatch loop every app with modals otherwise hand
|
|
9
|
+
# writes: it routes MouseEvents to #handle_mouse and other events to #handle,
|
|
10
|
+
# honors the "resolved value, or nil to stay open" widget contract, and runs
|
|
11
|
+
# the caller's on_result callback when the modal resolves.
|
|
12
|
+
#
|
|
13
|
+
# host = TuiTui::ModalHost.new
|
|
14
|
+
# host.open(TuiTui::Confirm.new("Quit?")) { |r| :quit if r == :ok }
|
|
15
|
+
# # in update(event):
|
|
16
|
+
# if host.open?
|
|
17
|
+
# outcome = host.handle(event) # nil while open; on_result value once resolved
|
|
18
|
+
# return outcome == :quit ? :quit : self
|
|
19
|
+
# end
|
|
20
|
+
# # in view(size): host.draw(canvas, size) if host.open?
|
|
21
|
+
class ModalHost
|
|
22
|
+
def open(widget, &on_result)
|
|
23
|
+
@widget = widget
|
|
24
|
+
@on_result = on_result || ->(result) { result }
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def open? = !@widget.nil?
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
@widget = nil
|
|
32
|
+
@on_result = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def draw(canvas, size) = @widget&.draw(canvas, size)
|
|
36
|
+
|
|
37
|
+
# Route one event to the modal. Returns nil while the modal stays open
|
|
38
|
+
# (the event was consumed), or the on_result callback's value once the
|
|
39
|
+
# widget resolves (e.g. :quit, or a new app model).
|
|
40
|
+
def handle(event)
|
|
41
|
+
return nil unless open?
|
|
42
|
+
|
|
43
|
+
result = dispatch(event)
|
|
44
|
+
return nil if result.nil?
|
|
45
|
+
|
|
46
|
+
callback = @on_result
|
|
47
|
+
close
|
|
48
|
+
callback.call(result)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def dispatch(event)
|
|
54
|
+
case event
|
|
55
|
+
when MouseEvent then @widget.handle_mouse(event)
|
|
56
|
+
when KeyEvent then @widget.handle(event.key)
|
|
57
|
+
else @widget.handle(event)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/tui_tui/rect.rb
CHANGED
|
@@ -37,6 +37,14 @@ module TuiTui
|
|
|
37
37
|
Rect.new(row: row, col: col + by, rows: rows, cols: cols - by)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Whether a 1-origin cell (row, col) falls inside this rectangle.
|
|
41
|
+
def include?(r, c)
|
|
42
|
+
r.between?(row, row + rows - 1) && c.between?(col, col + cols - 1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether a MouseEvent's cell falls inside this rectangle.
|
|
46
|
+
def hit?(mouse) = include?(mouse.row, mouse.col)
|
|
47
|
+
|
|
40
48
|
# Carve `width` columns off the right edge for a scrollbar gutter. Returns
|
|
41
49
|
# [body, gutter]; gutter is nil when the rect is too narrow to spare them.
|
|
42
50
|
def split_gutter(width = 1)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "canvas"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
# What an app's `view` receives: the terminal size plus the resolved chrome
|
|
7
|
+
RenderContext = Data.define(:size, :chrome) do
|
|
8
|
+
def rows = size.rows
|
|
9
|
+
def cols = size.cols
|
|
10
|
+
|
|
11
|
+
# A blank canvas already carrying the resolved chrome.
|
|
12
|
+
def canvas = Canvas.blank(size, chrome: chrome)
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/tui_tui/runtime.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "screen"
|
|
4
|
+
require_relative "render_context"
|
|
4
5
|
|
|
5
6
|
module TuiTui
|
|
6
7
|
# Small Elm-style loop: render, read one event, fold it through the app, repeat.
|
|
@@ -9,11 +10,11 @@ module TuiTui
|
|
|
9
10
|
@app = app
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default)
|
|
13
|
-
Screen.run(input: input, output: output, depth: depth, mouse: mouse) do |screen|
|
|
13
|
+
def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default, box: ENV["TUITUI_BOX"])
|
|
14
|
+
Screen.run(input: input, output: output, depth: depth, mouse: mouse, box: box) do |screen|
|
|
14
15
|
raise "tui_tui: not a terminal" if screen.nil?
|
|
15
16
|
|
|
16
|
-
screen.render(
|
|
17
|
+
screen.render(view(screen))
|
|
17
18
|
loop do
|
|
18
19
|
event = screen.events.next_event(tick: tick)
|
|
19
20
|
break if event.is_a?(EofEvent)
|
|
@@ -25,13 +26,19 @@ module TuiTui
|
|
|
25
26
|
|
|
26
27
|
@app = result
|
|
27
28
|
flush_clipboard(screen)
|
|
28
|
-
screen.render(
|
|
29
|
+
screen.render(view(screen))
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
# Pass a RenderContext (size + resolved chrome + canvas factory). It is
|
|
37
|
+
# Size-compatible, so legacy `view(size)` apps keep working (ASCII).
|
|
38
|
+
def view(screen)
|
|
39
|
+
@app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def flush_clipboard(screen)
|
|
36
43
|
# Clipboard writes stay an effect of the loop, requested by the app.
|
|
37
44
|
return unless @app.respond_to?(:take_clipboard)
|
data/lib/tui_tui/screen.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "io/console"
|
|
|
5
5
|
require_relative "ansi"
|
|
6
6
|
require_relative "size"
|
|
7
7
|
require_relative "color_depth"
|
|
8
|
+
require_relative "box_chrome"
|
|
9
|
+
require_relative "box_prober"
|
|
8
10
|
require_relative "canvas_compositor"
|
|
9
11
|
require_relative "terminal_size"
|
|
10
12
|
require_relative "event_stream"
|
|
@@ -15,12 +17,12 @@ module TuiTui
|
|
|
15
17
|
class Screen
|
|
16
18
|
DEFAULT_SIZE = Size.new(rows: 24, cols: 80)
|
|
17
19
|
|
|
18
|
-
def self.run(input: $stdin, output: $stdout, depth: ColorDepth.detect, mouse: mouse_default)
|
|
20
|
+
def self.run(input: $stdin, output: $stdout, depth: ColorDepth.detect, mouse: mouse_default, box: ENV["TUITUI_BOX"])
|
|
19
21
|
console = IO.console
|
|
20
22
|
# Let callers provide a non-interactive fallback for piped output.
|
|
21
23
|
return yield(nil) if console.nil? || !output.tty?
|
|
22
24
|
|
|
23
|
-
screen = new(console, input, output, depth, mouse: mouse)
|
|
25
|
+
screen = new(console, input, output, depth, mouse: mouse, box: box)
|
|
24
26
|
screen.start
|
|
25
27
|
begin
|
|
26
28
|
yield screen
|
|
@@ -33,8 +35,12 @@ module TuiTui
|
|
|
33
35
|
!%w[0 off false].include?(ENV["TUITUI_MOUSE"])
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def initialize(console, input, output, depth, mouse: true)
|
|
38
|
+
def initialize(console, input, output, depth, mouse: true, box: nil)
|
|
39
|
+
@input = input
|
|
37
40
|
@output = output
|
|
41
|
+
@box_override = box
|
|
42
|
+
# ASCII until start probes a real TTY; safe for non-TTY/StringIO callers.
|
|
43
|
+
@chrome = BoxChrome::ASCII
|
|
38
44
|
@compositor = CanvasCompositor.new(depth: depth)
|
|
39
45
|
@term_size = TerminalSize.new(console, default: DEFAULT_SIZE)
|
|
40
46
|
@events = EventStream.new(input: input, size: @term_size)
|
|
@@ -44,9 +50,19 @@ module TuiTui
|
|
|
44
50
|
@cursor = nil
|
|
45
51
|
end
|
|
46
52
|
|
|
47
|
-
attr_reader :events
|
|
48
|
-
|
|
49
|
-
def start
|
|
53
|
+
attr_reader :events, :chrome
|
|
54
|
+
|
|
55
|
+
def start
|
|
56
|
+
@session.start
|
|
57
|
+
# Probe box-drawing support once, after raw mode + alt screen, before the
|
|
58
|
+
# first render/next_event so the DSR reply never reaches the key reader.
|
|
59
|
+
@chrome = BoxChrome.resolve(
|
|
60
|
+
input: @input,
|
|
61
|
+
output: @output,
|
|
62
|
+
term_cols: size.cols,
|
|
63
|
+
env: {"TUITUI_BOX" => @box_override}
|
|
64
|
+
)
|
|
65
|
+
end
|
|
50
66
|
|
|
51
67
|
def size = @term_size.size
|
|
52
68
|
|
data/lib/tui_tui/scrollbar.rb
CHANGED
|
@@ -13,9 +13,10 @@ module TuiTui
|
|
|
13
13
|
TRACK = Theme::DEFAULT.scroll_track
|
|
14
14
|
THUMB = Theme::DEFAULT.scroll_thumb
|
|
15
15
|
|
|
16
|
-
def draw(canvas, rect, top:, visible:, total:, track:
|
|
16
|
+
def draw(canvas, rect, top:, visible:, total:, track: nil, thumb: " ", track_style: TRACK, thumb_style: THUMB)
|
|
17
17
|
return canvas if rect.rows <= 0
|
|
18
18
|
|
|
19
|
+
track ||= canvas.chrome.track
|
|
19
20
|
length, offset = geometry(rect.rows, top, visible, total)
|
|
20
21
|
rect.rows.times do |i|
|
|
21
22
|
in_thumb = i >= offset && i < offset + length
|
data/lib/tui_tui/span.rb
CHANGED
|
@@ -6,6 +6,11 @@ module TuiTui
|
|
|
6
6
|
# A run of text sharing one Style.
|
|
7
7
|
# Width is terminal-column aware via DisplayText.
|
|
8
8
|
Span = Data.define(:text, :style) do
|
|
9
|
+
# Data.define auto-generates Span.[] as an alias of .new. Remove it before
|
|
10
|
+
# redefining so our convenience form (style optional, text coerced) does not
|
|
11
|
+
# print a "method redefined" warning under -w.
|
|
12
|
+
singleton_class.send(:remove_method, :[])
|
|
13
|
+
|
|
9
14
|
# Convenience constructor: Span["hi", style] (style optional).
|
|
10
15
|
def self.[](text, style = nil) = new(text: text.to_s, style: style)
|
|
11
16
|
|
data/lib/tui_tui/text_view.rb
CHANGED
|
@@ -10,8 +10,12 @@ module TuiTui
|
|
|
10
10
|
module TextView
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil)
|
|
14
|
-
|
|
13
|
+
def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil, auto: false)
|
|
14
|
+
# With auto:, reserve the gutter only when the content overflows the rect.
|
|
15
|
+
# When the total is unknown (lazy block, no total:), the bar is kept.
|
|
16
|
+
count = total || lines&.length
|
|
17
|
+
show_bar = scrollbar && !(auto && count && count <= rect.rows)
|
|
18
|
+
body, gutter = show_bar ? rect.split_gutter : [rect, nil]
|
|
15
19
|
body.rows.times do |offset|
|
|
16
20
|
index = top + offset
|
|
17
21
|
content = lines ? lines[index] : yield(index)
|
data/lib/tui_tui/theme.rb
CHANGED
|
@@ -22,23 +22,47 @@ module TuiTui
|
|
|
22
22
|
:bar,
|
|
23
23
|
:cursor,
|
|
24
24
|
:scroll_track,
|
|
25
|
-
:scroll_thumb
|
|
25
|
+
:scroll_thumb,
|
|
26
|
+
# semantic status roles (hue-independent, background-aware)
|
|
27
|
+
:success,
|
|
28
|
+
:warning,
|
|
29
|
+
:danger,
|
|
30
|
+
:info
|
|
26
31
|
)
|
|
27
32
|
|
|
28
33
|
class Theme
|
|
34
|
+
# Map a symbolic status to its semantic role Style (with common aliases).
|
|
35
|
+
def status(kind)
|
|
36
|
+
case kind
|
|
37
|
+
when :ok, :success then success
|
|
38
|
+
when :warn, :warning then warning
|
|
39
|
+
when :error, :danger then danger
|
|
40
|
+
when :info then info
|
|
41
|
+
else text
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
29
45
|
# Background-dependent neutral roles.
|
|
30
46
|
SURFACES = {
|
|
31
47
|
dark: {
|
|
32
48
|
text: Style.new,
|
|
33
49
|
muted: Style.new(fg: 245),
|
|
34
50
|
bar: Style.new(fg: 252, bg: 238),
|
|
35
|
-
selection_dim: Style.new(fg: 247, bg: 238)
|
|
51
|
+
selection_dim: Style.new(fg: 247, bg: 238),
|
|
52
|
+
success: Style.new(fg: 71),
|
|
53
|
+
warning: Style.new(fg: 179),
|
|
54
|
+
danger: Style.new(fg: 167),
|
|
55
|
+
info: Style.new(fg: 110)
|
|
36
56
|
},
|
|
37
57
|
light: {
|
|
38
58
|
text: Style.new,
|
|
39
59
|
muted: Style.new(fg: 240),
|
|
40
60
|
bar: Style.new(fg: 16, bg: 252),
|
|
41
|
-
selection_dim: Style.new(fg: 240, bg: 252)
|
|
61
|
+
selection_dim: Style.new(fg: 240, bg: 252),
|
|
62
|
+
success: Style.new(fg: 28),
|
|
63
|
+
warning: Style.new(fg: 130),
|
|
64
|
+
danger: Style.new(fg: 124),
|
|
65
|
+
info: Style.new(fg: 25)
|
|
42
66
|
}
|
|
43
67
|
}.freeze
|
|
44
68
|
|
|
@@ -79,7 +103,11 @@ module TuiTui
|
|
|
79
103
|
bar: surface[:bar],
|
|
80
104
|
cursor: selection,
|
|
81
105
|
scroll_track: Style.new(fg: a[:line]),
|
|
82
|
-
scroll_thumb: Style.new(bg: a[:sel][1])
|
|
106
|
+
scroll_thumb: Style.new(bg: a[:sel][1]),
|
|
107
|
+
success: surface[:success],
|
|
108
|
+
warning: surface[:warning],
|
|
109
|
+
danger: surface[:danger],
|
|
110
|
+
info: surface[:info]
|
|
83
111
|
)
|
|
84
112
|
end
|
|
85
113
|
|
data/lib/tui_tui/version.rb
CHANGED
data/lib/tui_tui.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative "tui_tui/span"
|
|
|
9
9
|
require_relative "tui_tui/line"
|
|
10
10
|
require_relative "tui_tui/ansi"
|
|
11
11
|
require_relative "tui_tui/color_depth"
|
|
12
|
+
require_relative "tui_tui/box_chrome"
|
|
13
|
+
require_relative "tui_tui/box_prober"
|
|
12
14
|
require_relative "tui_tui/palette"
|
|
13
15
|
require_relative "tui_tui/style"
|
|
14
16
|
require_relative "tui_tui/theme"
|
|
@@ -16,6 +18,7 @@ require_relative "tui_tui/size"
|
|
|
16
18
|
require_relative "tui_tui/rect"
|
|
17
19
|
require_relative "tui_tui/cell"
|
|
18
20
|
require_relative "tui_tui/canvas"
|
|
21
|
+
require_relative "tui_tui/render_context"
|
|
19
22
|
require_relative "tui_tui/canvas_compositor"
|
|
20
23
|
require_relative "tui_tui/event"
|
|
21
24
|
require_relative "tui_tui/key_code"
|
|
@@ -32,6 +35,7 @@ require_relative "tui_tui/toast"
|
|
|
32
35
|
require_relative "tui_tui/focus_ring"
|
|
33
36
|
require_relative "tui_tui/fuzzy"
|
|
34
37
|
require_relative "tui_tui/modal"
|
|
38
|
+
require_relative "tui_tui/modal_host"
|
|
35
39
|
require_relative "tui_tui/confirm"
|
|
36
40
|
require_relative "tui_tui/select"
|
|
37
41
|
require_relative "tui_tui/help"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tui_tui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
@@ -19,6 +19,7 @@ extensions: []
|
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
21
|
- ".github/workflows/ci.yml"
|
|
22
|
+
- CHANGELOG.md
|
|
22
23
|
- LICENSE.txt
|
|
23
24
|
- README.md
|
|
24
25
|
- Rakefile
|
|
@@ -33,6 +34,8 @@ files:
|
|
|
33
34
|
- examples/widgets.rb
|
|
34
35
|
- lib/tui_tui.rb
|
|
35
36
|
- lib/tui_tui/ansi.rb
|
|
37
|
+
- lib/tui_tui/box_chrome.rb
|
|
38
|
+
- lib/tui_tui/box_prober.rb
|
|
36
39
|
- lib/tui_tui/canvas.rb
|
|
37
40
|
- lib/tui_tui/canvas_compositor.rb
|
|
38
41
|
- lib/tui_tui/cell.rb
|
|
@@ -50,10 +53,12 @@ files:
|
|
|
50
53
|
- lib/tui_tui/line.rb
|
|
51
54
|
- lib/tui_tui/list.rb
|
|
52
55
|
- lib/tui_tui/modal.rb
|
|
56
|
+
- lib/tui_tui/modal_host.rb
|
|
53
57
|
- lib/tui_tui/pager.rb
|
|
54
58
|
- lib/tui_tui/palette.rb
|
|
55
59
|
- lib/tui_tui/prompt.rb
|
|
56
60
|
- lib/tui_tui/rect.rb
|
|
61
|
+
- lib/tui_tui/render_context.rb
|
|
57
62
|
- lib/tui_tui/runtime.rb
|
|
58
63
|
- lib/tui_tui/screen.rb
|
|
59
64
|
- lib/tui_tui/scroll_list.rb
|
|
@@ -78,6 +83,7 @@ metadata:
|
|
|
78
83
|
allowed_push_host: https://rubygems.org
|
|
79
84
|
homepage_uri: https://github.com/takahashim/tui_tui
|
|
80
85
|
source_code_uri: https://github.com/takahashim/tui_tui
|
|
86
|
+
changelog_uri: https://github.com/takahashim/tui_tui/blob/main/CHANGELOG.md
|
|
81
87
|
rdoc_options: []
|
|
82
88
|
require_paths:
|
|
83
89
|
- lib
|