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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7ad8088986ea6e9bc201be5c89597019f4a256de1531f73184e489233752175
4
- data.tar.gz: b08cec47d0355748b9fdf732527f29d2ad2cb104ae1e88c0af5c17a9e488f552
3
+ metadata.gz: 499d4c344b19e2fca357f58d50471d80033b99b5fb94f32943f70383dd4eceaa
4
+ data.tar.gz: 31b39eb3d5d3de1361a9f3679277f4ea859b0fc5b7a5bd9a78bb6d69e250adf7
5
5
  SHA512:
6
- metadata.gz: 4058461db872786a4db951d4b1cd4b789ca7cc5cdfc07eeec8be7c4f2d1290bca30916f2405fe021f870465d74acab0f886128705567aca961346f389a048684
7
- data.tar.gz: 3634ec3d3c0da6967adf48af668790559087690e543352a9bb3690d61b435553689e46647cbb13b8619deec409abd19fa95f9bb286d8140baaa1d328237ad114
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 (ASCII).
87
+ #### N7: Width-safe UI chrome.
88
88
 
89
- Self-drawn chrome uses only ASCII, color, and spacing.
90
- ASCII characters have a guaranteed width of 1.
91
-
92
- Unicode box-drawing characters are never used.
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
 
@@ -288,8 +288,9 @@ module FileBrowserSample
288
288
 
289
289
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
290
290
 
291
- def view(size)
292
- canvas = TuiTui::Canvas.blank(size)
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). ASCII "|" (N7), dim like frames.
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(size)
163
+ def view(ctx)
164
+ size = ctx.size
164
165
  @rows = size.rows
165
- canvas = TuiTui::Canvas.blank(size)
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(size)
50
- canvas = TuiTui::Canvas.blank(size)
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
@@ -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
- bar = "+#{"-" * (rect.cols - 2)}+"
98
- text(rect.row, rect.col, bar, style)
99
- text(rect.row + rect.rows - 1, rect.col, bar, style)
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, "|", style)
102
- text(rect.row + dy, rect.col + rect.cols - 1, "|", style)
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
- body, gutter = scrollbar ? rect.split_gutter : [rect, nil]
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
@@ -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(@app.view(screen.size))
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(@app.view(screen.size))
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)
@@ -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 = @session.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
 
@@ -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: "|", thumb: " ", track_style: TRACK, thumb_style: THUMB)
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
 
@@ -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
- body, gutter = scrollbar ? rect.split_gutter : [rect, nil]
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TuiTui
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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