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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +8 -0
- data/examples/clock.rb +112 -0
- data/examples/counter.rb +48 -0
- data/examples/csv_viewer.rb +233 -0
- data/examples/file_browser.rb +665 -0
- data/examples/form.rb +633 -0
- data/examples/life.rb +144 -0
- data/examples/paint.rb +246 -0
- data/examples/todo.rb +250 -0
- data/examples/widgets.rb +101 -0
- data/lib/tui_tui/ansi.rb +34 -0
- data/lib/tui_tui/canvas.rb +187 -0
- data/lib/tui_tui/canvas_compositor.rb +45 -0
- data/lib/tui_tui/cell.rb +11 -0
- data/lib/tui_tui/color_depth.rb +39 -0
- data/lib/tui_tui/confirm.rb +74 -0
- data/lib/tui_tui/display_text.rb +73 -0
- data/lib/tui_tui/event.rb +10 -0
- data/lib/tui_tui/event_stream.rb +39 -0
- data/lib/tui_tui/focus_ring.rb +25 -0
- data/lib/tui_tui/fuzzy.rb +56 -0
- data/lib/tui_tui/help.rb +44 -0
- data/lib/tui_tui/key_code.rb +9 -0
- data/lib/tui_tui/key_intent.rb +29 -0
- data/lib/tui_tui/key_reader.rb +175 -0
- data/lib/tui_tui/line.rb +59 -0
- data/lib/tui_tui/list.rb +45 -0
- data/lib/tui_tui/modal.rb +30 -0
- data/lib/tui_tui/pager.rb +94 -0
- data/lib/tui_tui/palette.rb +49 -0
- data/lib/tui_tui/prompt.rb +111 -0
- data/lib/tui_tui/rect.rb +48 -0
- data/lib/tui_tui/runtime.rb +53 -0
- data/lib/tui_tui/screen.rb +85 -0
- data/lib/tui_tui/scroll_list.rb +57 -0
- data/lib/tui_tui/scrollbar.rb +40 -0
- data/lib/tui_tui/select.rb +104 -0
- data/lib/tui_tui/size.rb +5 -0
- data/lib/tui_tui/span.rb +14 -0
- data/lib/tui_tui/status_bar.rb +23 -0
- data/lib/tui_tui/style.rb +101 -0
- data/lib/tui_tui/terminal_session.rb +65 -0
- data/lib/tui_tui/terminal_size.rb +24 -0
- data/lib/tui_tui/text_sanitizer.rb +13 -0
- data/lib/tui_tui/text_view.rb +52 -0
- data/lib/tui_tui/theme.rb +127 -0
- data/lib/tui_tui/toast.rb +82 -0
- data/lib/tui_tui/version.rb +5 -0
- data/lib/tui_tui/width.rb +101 -0
- data/lib/tui_tui.rb +51 -0
- 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
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
|
data/examples/counter.rb
ADDED
|
@@ -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
|