hiiro 0.1.352 → 0.1.353
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 +7 -1
- data/README.md +5 -0
- data/lib/hiiro/config.rb +32 -0
- data/lib/hiiro/tui.rb +216 -0
- data/lib/hiiro/version.rb +1 -1
- data/lib/hiiro.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbd7598e65b8521905ec67a960f09700d7260c5329e066e6cfcbb4661c180c99
|
|
4
|
+
data.tar.gz: 189ca275fa4cc2af210c1d67c8976bc4b90343e68eb4a0fe86ebedea1421bb9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 50b6f3d077471531303d04f9ef234ff4ade40dbea85b52f575e6c9e3c48088ba1318dd0798eaeef15acdeb4ce0ca8fcf13100d4963fb5174dccdefaf87de228f
|
|
7
|
+
data.tar.gz: 4c852f5cea47667811dd2dd461b5c1bdfe3935cc7fecad2c95c7d3d43f28d3c7aea0554ea415cfda812e776b047bf4dee9ef77b34fdb0ffc5b806170864e2705
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.353] - 2026-04-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Hiiro::Tui::ListScreen` for building keyboard-driven full-screen list interfaces
|
|
9
|
+
- `Hiiro::Config.load_yaml` and `Hiiro::Config.yaml_dig` helpers for YAML file loading with nested key access
|
|
10
|
+
|
|
5
11
|
## [0.1.352] - 2026-04-24
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -323,4 +329,4 @@
|
|
|
323
329
|
- `h db cleanup` subcommand to preview and prune duplicate rows from SQLite tables
|
|
324
330
|
|
|
325
331
|
### Fixed
|
|
326
|
-
- Prevent duplicate pinned_prs during import with `insert_conflict` and per-row rescue
|
|
332
|
+
- Prevent duplicate pinned_prs during import with `insert_conflict` and per-row rescue
|
data/README.md
CHANGED
|
@@ -8,6 +8,7 @@ A lightweight, extensible CLI framework for Ruby. Build your own multi-command t
|
|
|
8
8
|
- **Abbreviation matching** - Type `h ex hel` instead of `h example hello`
|
|
9
9
|
- **Plugin system** - Extend functionality with reusable modules
|
|
10
10
|
- **Per-command storage** - Each command gets its own pin/config namespace
|
|
11
|
+
- **TUI helpers** - Build keyboard-driven list screens with `Hiiro::Tui::ListScreen`
|
|
11
12
|
|
|
12
13
|
See [docs/](docs/) for detailed documentation on all subcommands.
|
|
13
14
|
|
|
@@ -154,6 +155,10 @@ h example hello # => Hi!
|
|
|
154
155
|
h example bye # => Goodbye!
|
|
155
156
|
```
|
|
156
157
|
|
|
158
|
+
### Building TUIs
|
|
159
|
+
|
|
160
|
+
Use `Hiiro::Tui::ListScreen` for simple full-screen list interfaces. Subclass it, override `header_lines`, `format_row`, and `handle_key`, then run it from a normal `Hiiro.run` subcommand.
|
|
161
|
+
|
|
157
162
|
### Method 2: Inline Subcommands
|
|
158
163
|
|
|
159
164
|
Modify `exe/h` directly to add subcommands to the base `h` command:
|
data/lib/hiiro/config.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
1
3
|
class Hiiro
|
|
2
4
|
class Config
|
|
3
5
|
BASE_DIR = File.join(Dir.home, '.config/hiiro')
|
|
@@ -15,6 +17,36 @@ class Hiiro
|
|
|
15
17
|
File.join(BASE_DIR, relpath)
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
def load_yaml(relpath = 'config.yml', default: {}, permitted_classes: [Symbol])
|
|
21
|
+
file = path(relpath)
|
|
22
|
+
return default unless File.exist?(file)
|
|
23
|
+
|
|
24
|
+
YAML.safe_load_file(file, permitted_classes: permitted_classes) || default
|
|
25
|
+
rescue StandardError
|
|
26
|
+
default
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def yaml_dig(relpath = 'config.yml', *keys, default: nil, permitted_classes: [Symbol])
|
|
30
|
+
value = load_yaml(relpath, default: {}, permitted_classes: permitted_classes)
|
|
31
|
+
|
|
32
|
+
keys.flatten.each do |key|
|
|
33
|
+
return default unless value.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
value =
|
|
36
|
+
if value.key?(key)
|
|
37
|
+
value[key]
|
|
38
|
+
elsif value.key?(key.to_s)
|
|
39
|
+
value[key.to_s]
|
|
40
|
+
elsif key.respond_to?(:to_sym) && value.key?(key.to_sym)
|
|
41
|
+
value[key.to_sym]
|
|
42
|
+
else
|
|
43
|
+
return default
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
value.nil? ? default : value
|
|
48
|
+
end
|
|
49
|
+
|
|
18
50
|
def data_path(relpath='')
|
|
19
51
|
File.join(DATA_DIR, relpath)
|
|
20
52
|
end
|
data/lib/hiiro/tui.rb
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
require 'io/console'
|
|
2
|
+
|
|
3
|
+
class Hiiro
|
|
4
|
+
module Tui
|
|
5
|
+
module Terminal
|
|
6
|
+
def with_screen
|
|
7
|
+
input = $stdin
|
|
8
|
+
$stdout.write("\e[?1049h\e[?25l")
|
|
9
|
+
$stdout.flush
|
|
10
|
+
|
|
11
|
+
input.raw do
|
|
12
|
+
yield input
|
|
13
|
+
end
|
|
14
|
+
ensure
|
|
15
|
+
$stdout.write("\r\e[0m\e[?25h\e[?1049l")
|
|
16
|
+
$stdout.flush
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read_key(input)
|
|
20
|
+
char = input.getch
|
|
21
|
+
return :ctrl_c if char == "\u0003"
|
|
22
|
+
return :enter if char == "\r" || char == "\n"
|
|
23
|
+
|
|
24
|
+
if char == "\e"
|
|
25
|
+
first = read_escape_char(input)
|
|
26
|
+
return :escape if first.nil?
|
|
27
|
+
return :escape unless first == '['
|
|
28
|
+
|
|
29
|
+
case read_escape_char(input)
|
|
30
|
+
when 'A' then :up
|
|
31
|
+
when 'B' then :down
|
|
32
|
+
when 'C' then :right
|
|
33
|
+
when 'D' then :left
|
|
34
|
+
else :escape
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
char
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_escape_char(input)
|
|
42
|
+
return nil unless IO.select([input], nil, nil, 0.02)
|
|
43
|
+
|
|
44
|
+
input.getch
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def terminal_rows
|
|
48
|
+
env_dimension('LINES') || IO.console.winsize[0]
|
|
49
|
+
rescue StandardError
|
|
50
|
+
24
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def terminal_cols
|
|
54
|
+
env_dimension('COLUMNS') || IO.console.winsize[1]
|
|
55
|
+
rescue StandardError
|
|
56
|
+
80
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def terminal_line(text, cols)
|
|
60
|
+
truncate(text, cols) + "\e[K"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def center_text(text, cols)
|
|
64
|
+
truncated = truncate(text, cols)
|
|
65
|
+
return truncated if truncated.length >= cols
|
|
66
|
+
|
|
67
|
+
left_padding = [(cols - truncated.length) / 2, 0].max
|
|
68
|
+
(' ' * left_padding) + truncated
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def truncate(text, cols)
|
|
72
|
+
return text if text.length <= cols
|
|
73
|
+
return text[0, cols] if cols <= 1
|
|
74
|
+
|
|
75
|
+
text[0, cols - 1] + '…'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def visible_text(text, cols, offset = 0)
|
|
79
|
+
return truncate(text, cols) if offset <= 0
|
|
80
|
+
|
|
81
|
+
clipped = text[offset..] || ''
|
|
82
|
+
return truncate(clipped, cols) if cols <= 1
|
|
83
|
+
|
|
84
|
+
truncate("«#{clipped}", cols)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def env_dimension(name)
|
|
88
|
+
value = ENV[name].to_i
|
|
89
|
+
return nil unless value.positive?
|
|
90
|
+
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class ListScreen
|
|
96
|
+
include Terminal
|
|
97
|
+
|
|
98
|
+
attr_reader :items, :cursor, :top, :horizontal_offset
|
|
99
|
+
|
|
100
|
+
def initialize(items:, empty_message: 'No items.')
|
|
101
|
+
@items = items
|
|
102
|
+
@empty_message = empty_message
|
|
103
|
+
@cursor = 0
|
|
104
|
+
@top = 0
|
|
105
|
+
@horizontal_offset = 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run
|
|
109
|
+
if items.empty?
|
|
110
|
+
puts @empty_message
|
|
111
|
+
return false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
with_screen do |input|
|
|
115
|
+
loop do
|
|
116
|
+
render
|
|
117
|
+
|
|
118
|
+
result = handle_key(read_key(input))
|
|
119
|
+
return result unless result == :continue
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_key(key)
|
|
125
|
+
case key
|
|
126
|
+
when :up, 'k'
|
|
127
|
+
move(-1)
|
|
128
|
+
when :down, 'j'
|
|
129
|
+
move(1)
|
|
130
|
+
when :left, 'h'
|
|
131
|
+
scroll_horizontal(-4)
|
|
132
|
+
when :right, 'l'
|
|
133
|
+
scroll_horizontal(4)
|
|
134
|
+
when 'q', :escape, :ctrl_c
|
|
135
|
+
return false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
:continue
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def render
|
|
142
|
+
rows = terminal_rows
|
|
143
|
+
cols = terminal_cols
|
|
144
|
+
line_cols = [cols - 1, 1].max
|
|
145
|
+
headers = header_lines
|
|
146
|
+
visible_rows = [rows - headers.length - footer_height, 1].max
|
|
147
|
+
visible = items[@top, visible_rows] || []
|
|
148
|
+
@horizontal_offset = [horizontal_offset, max_horizontal_offset(line_cols, visible)].min
|
|
149
|
+
|
|
150
|
+
lines = headers.map { |line| terminal_line(line, line_cols) }
|
|
151
|
+
visible.each_with_index do |item, idx|
|
|
152
|
+
lines << format_row(item, @top + idx == cursor, line_cols)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
(visible_rows - visible.length).times { lines << terminal_line('', line_cols) }
|
|
156
|
+
footer_lines.each { |line| lines << terminal_line(line, line_cols) }
|
|
157
|
+
|
|
158
|
+
$stdout.write("\e[H\e[2J")
|
|
159
|
+
$stdout.write(lines.join("\r\n"))
|
|
160
|
+
$stdout.write("\r")
|
|
161
|
+
$stdout.flush
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def header_lines
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def footer_lines
|
|
169
|
+
["Showing #{@top + 1}-#{@top + visible_items.length} of #{items.length}"]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def footer_height
|
|
173
|
+
1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def format_row(item, current, line_cols)
|
|
177
|
+
prefix = current ? '> ' : ' '
|
|
178
|
+
style = current ? "\e[7m" : "\e[0m"
|
|
179
|
+
text = (prefix + visible_text(item.to_s, [line_cols - prefix.length, 1].max, horizontal_offset)).ljust(line_cols)
|
|
180
|
+
|
|
181
|
+
"#{style}#{text}\e[0m\e[K"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def move(delta)
|
|
185
|
+
@cursor = [[cursor + delta, 0].max, items.length - 1].min
|
|
186
|
+
visible_rows = body_rows_budget
|
|
187
|
+
@top = cursor if cursor < top
|
|
188
|
+
@top = cursor - visible_rows + 1 if cursor >= top + visible_rows
|
|
189
|
+
@horizontal_offset = [horizontal_offset, max_horizontal_offset].min
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def scroll_horizontal(delta)
|
|
193
|
+
@horizontal_offset = [[horizontal_offset + delta, 0].max, max_horizontal_offset].min
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def visible_items
|
|
197
|
+
items[top, body_rows_budget] || []
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def body_rows_budget
|
|
201
|
+
[terminal_rows - header_lines.length - footer_height, 1].max
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def max_horizontal_offset(line_cols = nil, visible = nil)
|
|
205
|
+
line_cols ||= [terminal_cols - 1, 1].max
|
|
206
|
+
visible ||= visible_items
|
|
207
|
+
return 0 if visible.empty?
|
|
208
|
+
|
|
209
|
+
longest_visible_item = visible.map { |item| item.to_s.length }.max || 0
|
|
210
|
+
min_visible_chars = [5, line_cols].min
|
|
211
|
+
|
|
212
|
+
[longest_visible_item - min_visible_chars, 0].max
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
data/lib/hiiro/version.rb
CHANGED
data/lib/hiiro.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hiiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.353
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua Toyota
|
|
@@ -377,6 +377,7 @@ files:
|
|
|
377
377
|
- lib/hiiro/tmux/window.rb
|
|
378
378
|
- lib/hiiro/tmux/windows.rb
|
|
379
379
|
- lib/hiiro/todo.rb
|
|
380
|
+
- lib/hiiro/tui.rb
|
|
380
381
|
- lib/hiiro/version.rb
|
|
381
382
|
- notes
|
|
382
383
|
- obsidian_slides.md
|