hiiro 0.1.352 → 0.1.354
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 +9 -0
- data/CLAUDE.md +4 -1
- data/README.md +7 -2
- data/docs/h-task.md +25 -2
- data/docs/h.md +1 -1
- data/lib/hiiro/config.rb +32 -0
- data/lib/hiiro/tasks.rb +92 -16
- 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: 9a368e5a5a2c932aa609faa46c699b3a112e835908fe7788cdab234f583a92ee
|
|
4
|
+
data.tar.gz: 2153cf58429b63859d220615d9a5404fa14e1388ce735bc385d399c2856ca204
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 422c65b26c19e47db3a28eb5643f9b525ba862dbfcdab1d75ada76f43b1c3add1bc70ce97183d4934a06403fc6bb4fa244b8821127c0a0e6ec8b9778e8e022a0
|
|
7
|
+
data.tar.gz: 1b005ce7ebb9acec393d6587d939ef1623c5d69a1d8869bf9540ca1e4e4095008bc4d6a3f0fa76bbcac6ac7849059b8166ae2e6fb08073ed31d48d186356459f
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Added
|
|
6
|
+
- `h task from <worktree-path> <task-name>` registers an existing git worktree from any path as a Hiiro task and switches to it.
|
|
7
|
+
|
|
8
|
+
## [0.1.353] - 2026-04-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `Hiiro::Tui::ListScreen` for building keyboard-driven full-screen list interfaces
|
|
12
|
+
- `Hiiro::Config.load_yaml` and `Hiiro::Config.yaml_dig` helpers for YAML file loading with nested key access
|
|
13
|
+
|
|
5
14
|
## [0.1.352] - 2026-04-24
|
|
6
15
|
|
|
7
16
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
4
|
|
|
5
|
+
## Safety
|
|
6
|
+
|
|
7
|
+
- NEVER commit secrets, tokens, api keys, etc. do not include any work-related content or information
|
|
8
|
+
|
|
5
9
|
## Project Overview
|
|
6
10
|
|
|
7
11
|
Hiiro is a lightweight CLI framework for Ruby that enables building multi-command tools similar to `git` or `docker`. It provides subcommand dispatch, abbreviation matching (e.g., `h ex hel` matches `h example hello`), and a plugin system.
|
|
@@ -546,4 +550,3 @@ All config lives in `~/.config/hiiro/`:
|
|
|
546
550
|
- lib/hiiro/tmux.rb
|
|
547
551
|
- lib/hiiro/tmux/*
|
|
548
552
|
|
|
549
|
-
|
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
|
|
|
@@ -64,7 +65,7 @@ h ping
|
|
|
64
65
|
| `h setup` | Install plugins and subcommands to system paths |
|
|
65
66
|
| `h edit` | Open the h script in your editor |
|
|
66
67
|
| `h alert` | macOS desktop notifications via terminal-notifier |
|
|
67
|
-
| `h task` | Task management across git worktrees (via Tasks plugin) |
|
|
68
|
+
| `h task` | Task management across git worktrees, including existing external worktrees (via Tasks plugin) |
|
|
68
69
|
| `h subtask` | Subtask management within tasks (via Tasks plugin) |
|
|
69
70
|
|
|
70
71
|
### External Subcommands
|
|
@@ -115,7 +116,7 @@ Plugins are Ruby modules loaded from `~/.config/hiiro/plugins/`:
|
|
|
115
116
|
|--------|-------------|
|
|
116
117
|
| Pins | Per-command YAML key-value storage |
|
|
117
118
|
| Project | Project directory navigation with tmux session management |
|
|
118
|
-
| Tasks | Task lifecycle management across git worktrees with subtask support |
|
|
119
|
+
| Tasks | Task lifecycle management across git worktrees with external-worktree registration and subtask support |
|
|
119
120
|
| Notify | macOS desktop notifications via terminal-notifier |
|
|
120
121
|
|
|
121
122
|
## Adding Subcommands
|
|
@@ -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/docs/h-task.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# h task
|
|
2
2
|
|
|
3
|
-
Manage top-level tasks. Each task is a worktree + tmux session pair. Tasks enable parallel development across multiple features — each with an isolated checkout and its own tmux session.
|
|
3
|
+
Manage top-level tasks. Each task is a worktree + tmux session pair. Tasks enable parallel development across multiple features — each with an isolated checkout and its own tmux session. `h task start` creates worktrees under Hiiro's default work root; `h task from` can register an existing worktree from any repo/path.
|
|
4
4
|
|
|
5
5
|
Tasks are stored in `~/.config/hiiro/tasks/tasks.yml` (SQLite-backed with YAML backup).
|
|
6
6
|
|
|
@@ -164,6 +164,30 @@ h task file add myapp path/to/file.rb
|
|
|
164
164
|
|
|
165
165
|
---
|
|
166
166
|
|
|
167
|
+
### from
|
|
168
|
+
|
|
169
|
+
Register an existing git worktree as a Hiiro task and switch to it. The path is normalized to the worktree root with `git rev-parse --show-toplevel`, then stored as the task's tree path.
|
|
170
|
+
|
|
171
|
+
**Examples**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
h task from ~/ic_repos/other_repo_base_dir other-task
|
|
175
|
+
h task path other-task
|
|
176
|
+
h task switch other-task
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Stored task shape:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
{
|
|
183
|
+
name: "other-task",
|
|
184
|
+
tree: "/Users/josh/ic_repos/other_repo_base_dir",
|
|
185
|
+
session: "other-task"
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
167
191
|
### ls / list
|
|
168
192
|
|
|
169
193
|
List all tasks with their worktree, branch, and session. Also shows available (unassigned) worktrees and extra tmux sessions.
|
|
@@ -548,4 +572,3 @@ h task untag my-feature # remove all tags
|
|
|
548
572
|
List worktrees for the current task. Delegates to `h wtree`.
|
|
549
573
|
|
|
550
574
|
---
|
|
551
|
-
|
data/docs/h.md
CHANGED
|
@@ -30,7 +30,7 @@ These subcommands are defined directly in `exe/h` or loaded from `lib/`:
|
|
|
30
30
|
| [`h service`](h-service.md) | Dev service management with tmux, env files, and service groups | `lib/hiiro/service_manager.rb` |
|
|
31
31
|
| `h setup` | Install plugins and bin scripts to `~/bin` | `exe/h` |
|
|
32
32
|
| [`h subtask`](h-subtask.md) | Subtask management scoped to the current parent task | `lib/hiiro/tasks.rb` |
|
|
33
|
-
| [`h task`](h-task.md) | Task management — worktree + tmux session pairs
|
|
33
|
+
| [`h task`](h-task.md) | Task management — worktree + tmux session pairs, including existing external worktrees | `lib/hiiro/tasks.rb` |
|
|
34
34
|
| `h version` | Print installed hiiro version (`-a` for all rbenv versions) | `exe/h` |
|
|
35
35
|
|
|
36
36
|
## External subcommands
|
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/tasks.rb
CHANGED
|
@@ -91,7 +91,10 @@ class Hiiro
|
|
|
91
91
|
return target.path if target.is_a?(FallbackTarget)
|
|
92
92
|
|
|
93
93
|
tree = environment.find_tree(target.tree_name)
|
|
94
|
-
|
|
94
|
+
return tree.path if tree
|
|
95
|
+
return target.tree_name if target.absolute_tree?
|
|
96
|
+
|
|
97
|
+
File.join(Hiiro::WORK_DIR, target.tree_name)
|
|
95
98
|
end
|
|
96
99
|
|
|
97
100
|
def task_by_tree(tree_name)
|
|
@@ -116,6 +119,43 @@ class Hiiro
|
|
|
116
119
|
|
|
117
120
|
# --- Actions ---
|
|
118
121
|
|
|
122
|
+
def task_from_worktree(path, name)
|
|
123
|
+
if path.nil? || name.nil? || name.empty?
|
|
124
|
+
puts "Usage: h #{scope} from <worktree-path> <task-name>"
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
root = git_worktree_root(path)
|
|
129
|
+
unless root
|
|
130
|
+
puts "ERROR: '#{path}' is not inside a git worktree"
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
existing = task_by_name(name)
|
|
135
|
+
if existing
|
|
136
|
+
existing_path = resolve_path(existing)
|
|
137
|
+
if same_path?(existing_path, root)
|
|
138
|
+
puts "Task '#{existing.name}' already uses '#{root}'. Switching..."
|
|
139
|
+
return existing
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
puts "ERROR: Task '#{existing.name}' already exists for '#{existing_path}'"
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
existing_for_path = environment.all_tasks.find { |task| same_path?(resolve_path(task), root) }
|
|
147
|
+
if existing_for_path
|
|
148
|
+
puts "Worktree '#{root}' is already registered as task '#{existing_for_path.name}'. Switching..."
|
|
149
|
+
return existing_for_path
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
color_index = Hiiro::TaskColors.next_index(config.tasks.map(&:color_index).compact)
|
|
153
|
+
task = Task.new(name: name, tree: root, session: name, color_index: color_index)
|
|
154
|
+
config.save_task(task)
|
|
155
|
+
puts "Added task '#{name}' from worktree '#{root}'"
|
|
156
|
+
task
|
|
157
|
+
end
|
|
158
|
+
|
|
119
159
|
def start_task(name, app_name: nil, sparse_groups: [])
|
|
120
160
|
existing = task_by_name(name)
|
|
121
161
|
if existing
|
|
@@ -185,8 +225,7 @@ class Hiiro
|
|
|
185
225
|
return
|
|
186
226
|
end
|
|
187
227
|
|
|
188
|
-
|
|
189
|
-
tree_path = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name)
|
|
228
|
+
tree_path = resolve_path(task)
|
|
190
229
|
|
|
191
230
|
session_name = task.session_name
|
|
192
231
|
session_exists = system('tmux', 'has-session', '-t', "=#{session_name}", err: File::NULL)
|
|
@@ -369,8 +408,7 @@ class Hiiro
|
|
|
369
408
|
|
|
370
409
|
puts "Task: #{task.name}"
|
|
371
410
|
puts "Worktree: #{task.tree_name}"
|
|
372
|
-
|
|
373
|
-
puts "Path: #{tree&.path || '(unknown)'}"
|
|
411
|
+
puts "Path: #{resolve_path(task) || '(unknown)'}"
|
|
374
412
|
puts "Session: #{task.session_name}"
|
|
375
413
|
puts "Parent: #{task.parent_name}" if task.subtask?
|
|
376
414
|
end
|
|
@@ -445,9 +483,7 @@ class Hiiro
|
|
|
445
483
|
return
|
|
446
484
|
end
|
|
447
485
|
|
|
448
|
-
|
|
449
|
-
path = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name)
|
|
450
|
-
send_cd(path)
|
|
486
|
+
send_cd(resolve_path(task))
|
|
451
487
|
end
|
|
452
488
|
|
|
453
489
|
def cd_to_app(app_name = nil)
|
|
@@ -458,8 +494,7 @@ class Hiiro
|
|
|
458
494
|
end
|
|
459
495
|
|
|
460
496
|
if app_name.nil? || app_name.empty?
|
|
461
|
-
|
|
462
|
-
send_cd(tree&.path || File.join(Hiiro::WORK_DIR, task.tree_name))
|
|
497
|
+
send_cd(resolve_path(task))
|
|
463
498
|
return
|
|
464
499
|
end
|
|
465
500
|
|
|
@@ -473,8 +508,7 @@ class Hiiro
|
|
|
473
508
|
def app_path(app_name = nil)
|
|
474
509
|
task = current_task
|
|
475
510
|
tree_root = if task
|
|
476
|
-
|
|
477
|
-
tree&.path || File.join(Hiiro::WORK_DIR, task.tree_name)
|
|
511
|
+
resolve_path(task)
|
|
478
512
|
else
|
|
479
513
|
Hiiro::Git.new(nil, Dir.pwd).root
|
|
480
514
|
end
|
|
@@ -596,14 +630,31 @@ class Hiiro
|
|
|
596
630
|
end
|
|
597
631
|
end
|
|
598
632
|
|
|
633
|
+
def git_worktree_root(path)
|
|
634
|
+
expanded = File.expand_path(path.to_s)
|
|
635
|
+
return nil unless Dir.exist?(expanded)
|
|
636
|
+
|
|
637
|
+
root = IO.popen(['git', '-C', expanded, 'rev-parse', '--show-toplevel'], err: File::NULL, &:read).to_s.strip
|
|
638
|
+
return nil if root.empty?
|
|
639
|
+
|
|
640
|
+
File.expand_path(root)
|
|
641
|
+
rescue
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def same_path?(left, right)
|
|
646
|
+
return false if left.nil? || right.nil?
|
|
647
|
+
|
|
648
|
+
File.expand_path(left) == File.expand_path(right)
|
|
649
|
+
end
|
|
650
|
+
|
|
599
651
|
def find_available_tree
|
|
600
652
|
assigned_tree_names = environment.all_tasks.map(&:tree_name)
|
|
601
653
|
environment.all_trees.find { |tree| !assigned_tree_names.include?(tree.name) }
|
|
602
654
|
end
|
|
603
655
|
|
|
604
656
|
def resolve_app(app_name, task)
|
|
605
|
-
|
|
606
|
-
tree_root = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name)
|
|
657
|
+
tree_root = resolve_path(task)
|
|
607
658
|
|
|
608
659
|
result = environment.app_matcher.find_all(app_name)
|
|
609
660
|
|
|
@@ -1036,6 +1087,13 @@ class Hiiro
|
|
|
1036
1087
|
|
|
1037
1088
|
h.add_subcmd(:apps) { tm.list_apps }
|
|
1038
1089
|
|
|
1090
|
+
if tm.scope == :task
|
|
1091
|
+
h.add_subcmd(:from) do |path = nil, task_name = nil|
|
|
1092
|
+
task = tm.task_from_worktree(path, task_name)
|
|
1093
|
+
tm.switch_to_task(task) if task
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1039
1097
|
h.add_subcmd(:cd) do |*raw_args|
|
|
1040
1098
|
opts = Hiiro::Options.parse(raw_args, &task_opts_block)
|
|
1041
1099
|
task, positional = resolve_task.call(opts, opts.args)
|
|
@@ -1419,6 +1477,13 @@ class Hiiro
|
|
|
1419
1477
|
class Tree
|
|
1420
1478
|
attr_reader :path, :head, :branch
|
|
1421
1479
|
|
|
1480
|
+
def self.from_path(path)
|
|
1481
|
+
expanded = File.expand_path(path.to_s)
|
|
1482
|
+
branch = Hiiro::Git.new(nil, expanded).branch if Dir.exist?(expanded)
|
|
1483
|
+
branch = nil if branch.nil? || branch.empty? || branch == 'HEAD'
|
|
1484
|
+
new(path: expanded, branch: branch)
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1422
1487
|
def self.all(repo_path: Hiiro::REPO_PATH)
|
|
1423
1488
|
git = Hiiro::Git.new(nil, repo_path)
|
|
1424
1489
|
git.worktrees(repo_path: repo_path).filter_map do |wt|
|
|
@@ -1485,6 +1550,10 @@ class Hiiro
|
|
|
1485
1550
|
!subtask?
|
|
1486
1551
|
end
|
|
1487
1552
|
|
|
1553
|
+
def absolute_tree?
|
|
1554
|
+
tree_name.to_s.start_with?('/')
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1488
1557
|
def tree
|
|
1489
1558
|
@tree ||= Environment.current&.find_tree(tree_name)
|
|
1490
1559
|
end
|
|
@@ -1649,7 +1718,7 @@ class Hiiro
|
|
|
1649
1718
|
t = tree
|
|
1650
1719
|
all_tasks.find { |task|
|
|
1651
1720
|
(s && task.session_name == s.name) ||
|
|
1652
|
-
(t && task.tree_name == t.name)
|
|
1721
|
+
(t && (task.tree_name == t.name || task.tree_name == t.path))
|
|
1653
1722
|
}
|
|
1654
1723
|
end
|
|
1655
1724
|
end
|
|
@@ -1659,7 +1728,7 @@ class Hiiro
|
|
|
1659
1728
|
end
|
|
1660
1729
|
|
|
1661
1730
|
def tree
|
|
1662
|
-
@tree ||= all_trees.find { |t| t.match?(path) }
|
|
1731
|
+
@tree ||= all_trees.find { |t| t.match?(path) } || external_task_tree
|
|
1663
1732
|
end
|
|
1664
1733
|
|
|
1665
1734
|
def find_task(abbreviated)
|
|
@@ -1682,6 +1751,8 @@ class Hiiro
|
|
|
1682
1751
|
|
|
1683
1752
|
def find_tree(abbreviated)
|
|
1684
1753
|
return nil if abbreviated.nil?
|
|
1754
|
+
return Tree.from_path(abbreviated) if abbreviated.to_s.start_with?('/') && Dir.exist?(abbreviated)
|
|
1755
|
+
|
|
1685
1756
|
tree_matcher.find(abbreviated).first&.item
|
|
1686
1757
|
end
|
|
1687
1758
|
|
|
@@ -1694,5 +1765,10 @@ class Hiiro
|
|
|
1694
1765
|
return nil if abbreviated.nil?
|
|
1695
1766
|
app_matcher.find(abbreviated).first&.item
|
|
1696
1767
|
end
|
|
1768
|
+
|
|
1769
|
+
def external_task_tree
|
|
1770
|
+
task = all_tasks.find { |t| t.absolute_tree? && t.tree_name && (path == t.tree_name || path.start_with?(t.tree_name + '/')) }
|
|
1771
|
+
Tree.from_path(task.tree_name) if task
|
|
1772
|
+
end
|
|
1697
1773
|
end
|
|
1698
1774
|
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.354
|
|
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
|