tui_tui 0.1.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. metadata +98 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e7ad8088986ea6e9bc201be5c89597019f4a256de1531f73184e489233752175
4
+ data.tar.gz: b08cec47d0355748b9fdf732527f29d2ad2cb104ae1e88c0af5c17a9e488f552
5
+ SHA512:
6
+ metadata.gz: 4058461db872786a4db951d4b1cd4b789ca7cc5cdfc07eeec8be7c4f2d1290bca30916f2405fe021f870465d74acab0f886128705567aca961346f389a048684
7
+ data.tar.gz: 3634ec3d3c0da6967adf48af668790559087690e543352a9bb3690d61b435553689e46647cbb13b8619deec409abd19fa95f9bb286d8140baaa1d328237ad114
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+ - name: Set up Ruby ${{ matrix.ruby }}
15
+ uses: ruby/setup-ruby@v1
16
+ with:
17
+ ruby-version: ${{ matrix.ruby }}
18
+ bundler-cache: true # bundle install + cache gems
19
+ - name: Run the test suite
20
+ run: bundle exec rake
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Masayoshi Takahashi (@takahashim)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # TuiTui
2
+
3
+ TuiTui is a small terminal UI toolkit for Ruby, with no external dependencies.
4
+
5
+ It uses a lightweight, TEA-inspired (MVU) architecture: the app object is the model,
6
+ `update(event)` returns the next app, and `view(size)` renders a `Canvas` that the runtime paints.
7
+
8
+ ## Usage
9
+
10
+ An app is any object with two methods: `view(size)` returns a `Canvas`, and
11
+ `update(event)` returns the next app (or `:quit`). `Runtime#run` drives the loop.
12
+
13
+ ```ruby
14
+ require "tui_tui"
15
+
16
+ class Counter
17
+ def initialize(count = 0) = @count = count
18
+
19
+ def view(size)
20
+ TuiTui::Canvas.blank(size).text(1, 1, "count: #{@count} (+/- to change, q to quit)")
21
+ end
22
+
23
+ def update(event)
24
+ return self unless event.is_a?(TuiTui::KeyEvent)
25
+
26
+ case event.key
27
+ when "+", "=" then Counter.new(@count + 1)
28
+ when "-", "_" then Counter.new(@count - 1)
29
+ when "q", TuiTui::KeyCode::CTRL_C then :quit
30
+ else self
31
+ end
32
+ end
33
+ end
34
+
35
+ TuiTui::Runtime.new(Counter.new).run
36
+ ```
37
+
38
+ See [`examples/`](examples) for larger apps.
39
+ Each is runnable with `ruby examples/<name>.rb`.
40
+
41
+ - `examples/counter.rb` — the smallest possible app
42
+ - `examples/widgets.rb` — built-in modal widgets
43
+ - `examples/file_browser.rb` — two-pane file browser
44
+ - `examples/todo.rb` — todo list with prompts and filtering
45
+ - `examples/csv_viewer.rb` — fixed-header CSV table viewer
46
+
47
+ ## Non-functional requirements
48
+
49
+ TuiTui is built around a small set of non-functional requirements (NFRs).
50
+
51
+ #### N1: Minimal dependencies.
52
+
53
+ Depends only on `io/console`, which is a default gem.
54
+
55
+ #### N2: Testable without a terminal.
56
+
57
+ State transitions (`update`) and drawing (`view`) are pure functions.
58
+ Only the driver (`Screen`) touches the terminal.
59
+
60
+ This makes apps and widgets unit-testable and snapshot-testable in headless environments.
61
+
62
+ #### N3: Terminal safety.
63
+
64
+ Raw mode, the alternate screen, and cursor visibility are always restored.
65
+ This applies to normal exits, exceptions, and signals.
66
+
67
+ This prevents the terminal from being left in a broken state.
68
+ The `Screen.run` block form guarantees this behavior through `TerminalSession`.
69
+
70
+ #### N4: No flicker.
71
+
72
+ Only the frame diff is written.
73
+ Each frame is flushed with a single `write`.
74
+
75
+ #### N5: Full-width aware.
76
+
77
+ Columns never misalign.
78
+ Display width is measured using a small built-in table based on East Asian Width.
79
+
80
+ Glyphs are clipped at region edges, not split across them.
81
+
82
+ #### N6: Performance.
83
+
84
+ Movement and redraw stay responsive, even with large content.
85
+ Only changed rows are repainted, so cost scales with the change, not the screen size.
86
+
87
+ #### N7: Width-safe UI chrome (ASCII).
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`.
98
+
99
+ Content text, such as Japanese data, is measured with `Width`.
100
+ It is clipped or padded to fit the available space.
101
+
102
+ ## Configuration
103
+
104
+ Environment variables (all optional):
105
+
106
+ - `TUITUI_MOUSE` — set to `0`/`off`/`false` to disable mouse reporting (on by default).
107
+ - `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).
108
+
109
+ ## Installation
110
+
111
+ ```bash
112
+ bundle add tui_tui
113
+ ```
114
+
115
+ If bundler is not being used to manage dependencies, install the gem by executing:
116
+
117
+ ```bash
118
+ gem install tui_tui
119
+ ```
120
+
121
+ ## Development
122
+
123
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
124
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
125
+
126
+ To install this gem onto your local machine, run `bundle exec rake install`.
127
+ To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`,
128
+ which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
129
+
130
+ ## Contributing
131
+
132
+ Bug reports and pull requests are welcome on GitHub at https://github.com/takahashim/tui_tui.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/examples/clock.rb ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Shows the tick seam: a big, resizable, always-animating clock. The app opts in
5
+ # to timer ticks with `wants_tick?`; the Runtime delivers a TickEvent on its poll
6
+ # interval and re-renders, so it animates without input. The time is drawn as
7
+ # large digits — each font pixel is an N-cell block of background color, so the
8
+ # banner is ASCII-only (N7), no block-drawing glyphs. The size auto-fits the
9
+ # terminal and is adjustable with +/-.
10
+ #
11
+ # ruby examples/clock.rb
12
+ #
13
+ # Keys: + / - bigger / smaller, q (or Ctrl-C) quit.
14
+
15
+ require_relative "../lib/tui_tui"
16
+
17
+ module ClockSample
18
+ TIME = TuiTui::Style.new(bg: :cyan) # a lit "pixel" of the big digits
19
+ HINT = TuiTui::Style.new(attrs: [:dim])
20
+ SPINNER = %w[| / - \\].freeze
21
+
22
+ GLYPH_W = 3 # font cells wide
23
+ GLYPH_H = 5 # font cells tall
24
+
25
+ # 3x5 ASCII fonts; "#" is a lit pixel, " " is blank.
26
+ FONT = {
27
+ "0" => ["###", "# #", "# #", "# #", "###"],
28
+ "1" => [" #", " #", " #", " #", " #"],
29
+ "2" => ["###", " #", "###", "# ", "###"],
30
+ "3" => ["###", " #", "###", " #", "###"],
31
+ "4" => ["# #", "# #", "###", " #", " #"],
32
+ "5" => ["###", "# ", "###", " #", "###"],
33
+ "6" => ["###", "# ", "###", "# #", "###"],
34
+ "7" => ["###", " #", " #", " #", " #"],
35
+ "8" => ["###", "# #", "###", "# #", "###"],
36
+ "9" => ["###", "# #", "###", " #", "###"],
37
+ ":" => [" ", " # ", " ", " # ", " "],
38
+ }.freeze
39
+
40
+ class Clock
41
+ def initialize
42
+ @ticks = 0
43
+ @scale = nil # set to the auto-fit size on first view; adjusted with +/-
44
+ end
45
+
46
+ def wants_tick? = true
47
+
48
+ def update(event)
49
+ case event
50
+ when TuiTui::KeyEvent then handle_key(event.key)
51
+ when TuiTui::TickEvent then (@ticks += 1) && self
52
+ else self
53
+ end
54
+ end
55
+
56
+ def view(size)
57
+ canvas = TuiTui::Canvas.blank(size)
58
+ time = Time.now.strftime("%H:%M:%S")
59
+ @scale = (@scale || max_scale(size, time)).clamp(1, max_scale(size, time))
60
+ box = TuiTui::Rect.centered(size, cols: banner_width(time, @scale), rows: banner_height(@scale))
61
+ draw_big(canvas, box.row, box.col, time, @scale)
62
+ canvas.text(size.rows, 1, " #{SPINNER[@ticks % SPINNER.length]} +/- size q quit", HINT)
63
+ canvas
64
+ end
65
+
66
+ private
67
+
68
+ def handle_key(key)
69
+ case key
70
+ when "q", TuiTui::KeyCode::CTRL_C then return :quit
71
+ when "+", "=" then @scale += 1 # clamped to fit in view
72
+ when "-", "_" then @scale -= 1
73
+ end
74
+ self
75
+ end
76
+
77
+ # A font pixel is `2*scale` columns by `scale` rows (≈ square on screen, since
78
+ # terminal cells are about twice as tall as wide); glyphs sit `2*scale` apart.
79
+ def banner_width(text, scale) = (text.length * GLYPH_W + (text.length - 1)) * 2 * scale
80
+ def banner_height(scale) = GLYPH_H * scale
81
+
82
+ # The largest scale whose banner still fits the terminal (at least 1).
83
+ def max_scale(size, text)
84
+ by_width = size.cols / banner_width(text, 1)
85
+ by_height = (size.rows - 1) / GLYPH_H
86
+ [[by_width, by_height].min, 1].max
87
+ end
88
+
89
+ def draw_big(canvas, top, left, text, scale)
90
+ pixel_w = 2 * scale
91
+ col = left
92
+ text.each_char do |ch|
93
+ glyph = FONT[ch]
94
+ next unless glyph
95
+
96
+ glyph.each_with_index do |line, r|
97
+ line.each_char.with_index do |pixel, c|
98
+ next unless pixel == "#"
99
+
100
+ cell = TuiTui::Rect.new(row: top + (r * scale), col: col + (c * pixel_w), rows: scale, cols: pixel_w)
101
+ canvas.fill(cell, TIME)
102
+ end
103
+ end
104
+ col += (GLYPH_W * pixel_w) + (2 * scale)
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ if $PROGRAM_NAME == __FILE__
111
+ TuiTui::Runtime.new(ClockSample::Clock.new).run(tick: 0.1)
112
+ end
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The smallest possible TuiTui app: a counter. It shows the whole contract and
5
+ # nothing else — `view(size) -> Canvas` and `update(event) -> self | :quit` —
6
+ # with no widgets and no layout.
7
+ #
8
+ # ruby examples/counter.rb
9
+ #
10
+ # Keys: j / + / ↑ increment, k / - / ↓ decrement, r reset, q (or Ctrl-C) quit.
11
+
12
+ require_relative "../lib/tui_tui"
13
+
14
+ module CounterSample
15
+ BIG = TuiTui::Style.new(fg: :green, attrs: [:bold])
16
+ HINT = TuiTui::Style.new(attrs: [:dim])
17
+
18
+ class Counter
19
+ def initialize
20
+ @count = 0
21
+ end
22
+
23
+ def update(event)
24
+ return self unless event.is_a?(TuiTui::KeyEvent)
25
+
26
+ case event.key
27
+ when "q", TuiTui::KeyCode::CTRL_C then return :quit
28
+ when "j", "+", :up then @count += 1
29
+ when "k", "-", :down then @count -= 1
30
+ when "r" then @count = 0
31
+ end
32
+ self
33
+ end
34
+
35
+ def view(size)
36
+ canvas = TuiTui::Canvas.blank(size)
37
+ label = "count: #{@count}"
38
+ box = TuiTui::Rect.centered(size, cols: TuiTui::DisplayText.new(label).width, rows: 1)
39
+ canvas.text(box.row, box.col, label, BIG)
40
+ canvas.text(size.rows, 1, " j/+ up k/- down r reset q quit", HINT)
41
+ canvas
42
+ end
43
+ end
44
+ end
45
+
46
+ if $PROGRAM_NAME == __FILE__
47
+ TuiTui::Runtime.new(CounterSample::Counter.new).run
48
+ end
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A small CSV viewer. It demonstrates a table-shaped app with a fixed header,
5
+ # vertical row selection, horizontal column navigation, width-aware cell
6
+ # clipping, and a status bar. Pass a CSV path, or run it without arguments to
7
+ # browse the built-in sample data.
8
+ #
9
+ # ruby examples/csv_viewer.rb [CSV]
10
+ #
11
+ # Keys: j/k (or ↑/↓) move rows, h/l (or ←/→) move columns, Space page down,
12
+ # b page up, g/G top/bottom, 0/$ first/last column, q (or Ctrl-C) quit.
13
+
14
+ require "csv"
15
+ require_relative "../lib/tui_tui"
16
+
17
+ module CsvViewerSample
18
+ SAMPLE = <<~CSV
19
+ id,name,role,country,notes
20
+ 1,Ada Lovelace,Mathematician,United Kingdom,First programmer
21
+ 2,Grace Hopper,Computer Scientist,United States,Popularized machine-independent languages
22
+ 3,高橋,Engineer,日本,日本語のセルも幅安全に表示
23
+ 4,Margaret Hamilton,Software Engineer,United States,Apollo guidance software
24
+ 5,Katherine Johnson,Mathematician,United States,Orbital mechanics calculations
25
+ 6,Edsger Dijkstra,Computer Scientist,Netherlands,Shortest paths and structured programming
26
+ CSV
27
+
28
+ S = TuiTui::Style
29
+ STYLE = {
30
+ title: S.new(attrs: [:bold]),
31
+ dim: S.new(attrs: [:dim]),
32
+ header: S.new(fg: :bright_white, bg: 238, attrs: [:bold]),
33
+ row_number: S.new(fg: :bright_black),
34
+ selected_row: S.new(bg: 236),
35
+ selected_cell: S.new(attrs: [:reverse, :bold]),
36
+ status: S.new(attrs: [:reverse]),
37
+ error: S.new(fg: :bright_red, attrs: [:bold]),
38
+ accent: S.new(fg: :cyan, attrs: [:bold]),
39
+ }.freeze
40
+
41
+ ROW_NUMBER_WIDTH = 6
42
+ MIN_COLUMN_WIDTH = 6
43
+ MAX_COLUMN_WIDTH = 28
44
+
45
+ class CsvViewer
46
+ def initialize(path)
47
+ @path = path
48
+ @header, @rows, @error = load_csv(path)
49
+ @list = TuiTui::ScrollList.new(@rows.size)
50
+ @col = 0
51
+ @left_col = 0
52
+ @page = 1
53
+ @widths = column_widths
54
+ end
55
+
56
+ def update(event)
57
+ return self unless event.is_a?(TuiTui::KeyEvent)
58
+
59
+ case event.key
60
+ when "q", TuiTui::KeyCode::CTRL_C then return :quit
61
+ when "j", :down then @list.move(1)
62
+ when "k", :up then @list.move(-1)
63
+ when " ", :pgdn then @list.move(@page)
64
+ when "b", :pgup then @list.move(-@page)
65
+ when "g", :home then @list.to_top
66
+ when "G", :end then @list.to_end
67
+ when "h", :left then move_col(-1)
68
+ when "l", :right then move_col(1)
69
+ when "0" then @col = 0
70
+ when "$" then @col = last_col
71
+ end
72
+ self
73
+ end
74
+
75
+ def view(size)
76
+ canvas = TuiTui::Canvas.blank(size)
77
+ body, footer = split_footer(size)
78
+ detail, status = footer.split_h(1)
79
+ table = body
80
+
81
+ @page = [table.rows - 1, 1].max
82
+ @list.ensure_visible(@page)
83
+ ensure_column_visible(table.cols - ROW_NUMBER_WIDTH)
84
+
85
+ draw_table(canvas, table)
86
+ draw_detail(canvas, detail)
87
+ draw_status(canvas, status)
88
+ canvas
89
+ end
90
+
91
+ private
92
+
93
+ def load_csv(path)
94
+ if path
95
+ rows = CSV.read(path, headers: false)
96
+ source = File.expand_path(path)
97
+ else
98
+ rows = CSV.parse(SAMPLE, headers: false)
99
+ source = "(sample data)"
100
+ end
101
+
102
+ header = normalize_row(rows.shift || [])
103
+ data = rows.map { |row| normalize_row(row) }
104
+ width = [header.size, data.map(&:size).max || 0].max
105
+ header = default_header(width) if header.empty?
106
+ header = pad_row(header, width)
107
+ data = data.map { |row| pad_row(row, width) }
108
+ [header, data, nil]
109
+ rescue CSV::MalformedCSVError, SystemCallError => e
110
+ [["error"], [["#{e.class}: #{e.message}"]], "#{source || path}: #{e.message}"]
111
+ end
112
+
113
+ def normalize_row(row) = row.map { |cell| cell.to_s }
114
+ def pad_row(row, width) = row + Array.new(width - row.size, "")
115
+ def default_header(width) = Array.new(width) { |i| "column_#{i + 1}" }
116
+
117
+ def split_footer(size)
118
+ whole = TuiTui::Rect.new(row: 1, col: 1, rows: size.rows, cols: size.cols)
119
+ return [whole, TuiTui::Rect.new(row: size.rows, col: 1, rows: 0, cols: size.cols)] if size.rows < 3
120
+
121
+ whole.split_h(size.rows - 2)
122
+ end
123
+
124
+ def draw_table(canvas, rect)
125
+ return if rect.rows <= 0 || rect.cols <= 0
126
+
127
+ canvas.fill(TuiTui::Rect.new(row: rect.row, col: rect.col, rows: 1, cols: rect.cols), STYLE[:header])
128
+ canvas.text(rect.row, rect.col, fit("#", ROW_NUMBER_WIDTH), STYLE[:header])
129
+ each_visible_column(rect.cols - ROW_NUMBER_WIDTH) do |index, col, width|
130
+ style = index == @col ? STYLE[:selected_cell] : STYLE[:header]
131
+ canvas.text(rect.row, col, fit(@header[index], width), style)
132
+ end
133
+
134
+ if @rows.empty?
135
+ canvas.text(rect.row + 1, rect.col + 1, "No rows", STYLE[:dim]) if rect.rows > 1
136
+ return
137
+ end
138
+
139
+ @list.each_visible([rect.rows - 1, 0].max) do |row_index, offset|
140
+ row = rect.row + 1 + offset
141
+ selected = row_index == @list.cursor
142
+ canvas.fill(TuiTui::Rect.new(row: row, col: rect.col, rows: 1, cols: rect.cols), STYLE[:selected_row]) if selected
143
+ canvas.text(row, rect.col, fit((row_index + 1).to_s, ROW_NUMBER_WIDTH), selected ? STYLE[:selected_row] : STYLE[:row_number])
144
+ each_visible_column(rect.cols - ROW_NUMBER_WIDTH) do |index, col, width|
145
+ style = selected && index == @col ? STYLE[:selected_cell] : (selected ? STYLE[:selected_row] : nil)
146
+ canvas.text(row, col, fit(@rows[row_index][index], width), style)
147
+ end
148
+ end
149
+ end
150
+
151
+ def draw_detail(canvas, rect)
152
+ return if rect.rows <= 0
153
+
154
+ canvas.hline(rect.row, rect.col, rect.cols, "-", STYLE[:dim])
155
+ return if @rows.empty?
156
+
157
+ label = @header[@col] || "column_#{@col + 1}"
158
+ value = @rows[@list.cursor][@col].to_s
159
+ text = "#{label}: #{value}"
160
+ canvas.text(rect.row, rect.col + 1, TuiTui::DisplayText.new(text).truncate(rect.cols - 1), STYLE[:accent])
161
+ end
162
+
163
+ def draw_status(canvas, rect)
164
+ return if rect.rows <= 0
165
+
166
+ canvas.fill(rect, STYLE[:status])
167
+ left = @error || " #{@path ? File.basename(@path) : "sample.csv"}"
168
+ canvas.text(rect.row, rect.col, TuiTui::DisplayText.new(left).truncate(rect.cols), @error ? STYLE[:error] : STYLE[:status])
169
+
170
+ right = " row #{@list.cursor + 1}/#{[@rows.size, 1].max} col #{@col + 1}/#{@header.size} j/k rows h/l cols q quit "
171
+ width = TuiTui::DisplayText.new(right).width
172
+ return if width >= rect.cols
173
+
174
+ canvas.text(rect.row, rect.col + rect.cols - width, right, STYLE[:status])
175
+ end
176
+
177
+ def each_visible_column(available)
178
+ return if available <= 0
179
+
180
+ col = ROW_NUMBER_WIDTH + 1
181
+ @left_col.upto(last_col) do |index|
182
+ width = @widths[index]
183
+ break if col + width - 1 > available + ROW_NUMBER_WIDTH
184
+
185
+ yield index, col, width
186
+ col += width
187
+ end
188
+ end
189
+
190
+ def ensure_column_visible(available)
191
+ return if available <= 0
192
+
193
+ @left_col = @col if @col < @left_col
194
+ while @col >= first_hidden_column(available)
195
+ @left_col += 1
196
+ end
197
+ @left_col = @left_col.clamp(0, last_col)
198
+ end
199
+
200
+ def first_hidden_column(available)
201
+ used = 0
202
+ @left_col.upto(last_col) do |index|
203
+ used += @widths[index]
204
+ return index if used > available
205
+ end
206
+ last_col + 1
207
+ end
208
+
209
+ def move_col(delta)
210
+ @col = (@col + delta).clamp(0, last_col)
211
+ end
212
+
213
+ def last_col = [@header.size - 1, 0].max
214
+
215
+ def column_widths
216
+ @header.each_index.map do |index|
217
+ values = [@header[index], *@rows.first(200).map { |row| row[index] }]
218
+ width = values.map { |value| TuiTui::DisplayText.new(value.to_s).width }.max || MIN_COLUMN_WIDTH
219
+ (width + 2).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
220
+ end
221
+ end
222
+
223
+ def fit(value, width)
224
+ text = TuiTui::DisplayText.new(" #{value}")
225
+ text = text.truncate(width - 1) if text.width >= width
226
+ text.to_s + (" " * [width - text.width, 0].max)
227
+ end
228
+ end
229
+ end
230
+
231
+ if $PROGRAM_NAME == __FILE__
232
+ TuiTui::Runtime.new(CsvViewerSample::CsvViewer.new(ARGV[0])).run
233
+ end