marvi 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +18 -0
- data/exe/marvi +20 -7
- data/lib/marvi/renderer/curses/file_picker.rb +88 -0
- data/lib/marvi/renderer/curses/tab.rb +208 -0
- data/lib/marvi/renderer/curses/tab_bar.rb +62 -0
- data/lib/marvi/renderer/curses.rb +348 -82
- data/lib/marvi/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f6a2c8745ddc2086bb12cb705f8564f1a23b770bb8b1026bdf89e2ccfea8bea
|
|
4
|
+
data.tar.gz: 879c6bbf77fac95dc588c8abce142144e2b27a5216d9831fd7c8d231956d248d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4edaa52fd6b82ad2443c821dab35e40ceb87cd28def6fa6401f413fb654f175d9fdf64b2ac070004c6dfdac99a1c757b0f4b6648388a4cbf952ab744d3815b2
|
|
7
|
+
data.tar.gz: b53ac675a9618f8e2be301758c3cb768315942665dc22dd81273bd35d8b490206d575b07689308c522f796f24987a6bd970a85c51cf9463dd8ee8d4e372077f8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-07-06
|
|
4
|
+
|
|
5
|
+
- Turn the `o` prompt into a file picker. It lists Markdown/text documents under the current directory and narrows the list as you type (all space-separated terms must match, case-insensitive); select with `C-n`/`C-p` or the arrow keys and open with `Enter`. Input starting with `/`, `~`, `./`, or `../` switches to explicit path entry with shell-style `Tab` completion, and selecting a directory descends into it.
|
|
6
|
+
- Open multiple files in tabs. Pass several files on the command line (`marvi a.md b.md`), and a tab bar with the file names appears at the top of the screen, wrapping to multiple rows when the terminal is narrow. Switch tabs with `Tab`/`Shift-Tab`, jump directly with `1`–`9`, or click a tab with the mouse. Press `o` to open another file in a new tab and `x` to close the current one. File-change polling covers every tab and marks updated ones with `●` in the bar.
|
|
7
|
+
|
|
8
|
+
## [0.7.0] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
- Add incremental search to the curses pager. Press `/` to search as you type, with all matches highlighted (current match in bold) and the view jumping to the nearest match. Navigate matches with `n`/`N` (vi/less style); search is case-insensitive.
|
|
11
|
+
|
|
3
12
|
## [0.6.0] - 2026-06-11
|
|
4
13
|
|
|
5
14
|
- Render Mermaid fenced code blocks as Unicode box-drawing art. Supports `flowchart`/`graph` (TD/TB/LR/RL), `sequenceDiagram`, `classDiagram`, and `stateDiagram`/`stateDiagram-v2`. Unsupported diagram types, malformed syntax, and over-width output fall back to the highlighted code block.
|
data/README.md
CHANGED
|
@@ -24,6 +24,24 @@ Render a Markdown file:
|
|
|
24
24
|
marvi README.md
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Open multiple files at once — each file gets its own tab in a bar at the top of the screen (the bar wraps to multiple rows when tabs don't fit the terminal width):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
marvi README.md CHANGELOG.md docs/guide.md
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Tab keys inside the pager:
|
|
34
|
+
|
|
35
|
+
| Key | Action |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `Tab` / `Shift-Tab` | next / previous tab |
|
|
38
|
+
| `1`–`9` | jump to tab by number |
|
|
39
|
+
| mouse click on a tab | switch to that tab |
|
|
40
|
+
| `o` | open a file in a new tab via the file picker |
|
|
41
|
+
| `x` | close the current tab (closing the last one quits) |
|
|
42
|
+
|
|
43
|
+
The `o` picker lists Markdown/text documents under the current directory and narrows the list as you type (every space-separated term must match, case-insensitive). Move the selection with `C-n`/`C-p` (or the arrow keys) and press `Enter` to open. Start the input with `/`, `~`, `./`, or `../` to enter an explicit path instead — `Tab` then completes it shell-style, and selecting a directory descends into it.
|
|
44
|
+
|
|
27
45
|
Read from stdin:
|
|
28
46
|
|
|
29
47
|
```bash
|
data/exe/marvi
CHANGED
|
@@ -5,7 +5,7 @@ require "optparse"
|
|
|
5
5
|
require_relative "../lib/marvi"
|
|
6
6
|
|
|
7
7
|
OptionParser.new do |opts|
|
|
8
|
-
opts.banner = "Usage: marvi [FILE]\n cat FILE | marvi"
|
|
8
|
+
opts.banner = "Usage: marvi [FILE...]\n cat FILE | marvi"
|
|
9
9
|
opts.on("-h", "--help", "Show help") do
|
|
10
10
|
puts opts
|
|
11
11
|
exit
|
|
@@ -16,14 +16,27 @@ OptionParser.new do |opts|
|
|
|
16
16
|
end
|
|
17
17
|
end.parse!
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
else
|
|
22
|
-
File.read(ARGV[0])
|
|
19
|
+
ARGV.each do |file|
|
|
20
|
+
abort "marvi: cannot open #{file}" unless File.file?(file) && File.readable?(file)
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
if $stdout.tty?
|
|
26
|
-
|
|
24
|
+
if ARGV.empty?
|
|
25
|
+
Marvi::Renderer::Curses.new.render($stdin.read)
|
|
26
|
+
else
|
|
27
|
+
Marvi::Renderer::Curses.new.render_files(ARGV)
|
|
28
|
+
end
|
|
27
29
|
else
|
|
28
|
-
|
|
30
|
+
renderer = Marvi::Renderer::ANSI.new
|
|
31
|
+
if ARGV.empty?
|
|
32
|
+
print renderer.render($stdin.read)
|
|
33
|
+
else
|
|
34
|
+
ARGV.each_with_index do |file, i|
|
|
35
|
+
if ARGV.size > 1
|
|
36
|
+
puts if i.positive?
|
|
37
|
+
puts "==> #{file} <=="
|
|
38
|
+
end
|
|
39
|
+
print renderer.render(File.read(file, encoding: Encoding::UTF_8))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
29
42
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Marvi
|
|
4
|
+
module Renderer
|
|
5
|
+
class Curses
|
|
6
|
+
# Candidate discovery, filtering, and tab-completion behind the `o`
|
|
7
|
+
# (open file) picker. Pure filesystem/string logic with no curses calls,
|
|
8
|
+
# so it can be tested headlessly.
|
|
9
|
+
#
|
|
10
|
+
# Two modes, decided per keystroke from the typed input:
|
|
11
|
+
# - list mode: filter documents scanned under the base directory; every
|
|
12
|
+
# whitespace-separated term must match somewhere in the path.
|
|
13
|
+
# - path mode (input starts with /, ~, ./ or ../): complete the typed
|
|
14
|
+
# path against the filesystem, shell-style.
|
|
15
|
+
class FilePicker
|
|
16
|
+
SCAN_GLOB = "**/*.{md,markdown,mdown,mkd,txt}"
|
|
17
|
+
MAX_FILES = 2000
|
|
18
|
+
|
|
19
|
+
def initialize(base_dir = Dir.pwd)
|
|
20
|
+
@base_dir = base_dir
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Documents under the base directory, capped to keep the picker snappy
|
|
24
|
+
# in huge trees. Dir.glob skips hidden directories by default.
|
|
25
|
+
def all_files
|
|
26
|
+
@all_files ||= Dir.glob(SCAN_GLOB, base: @base_dir)
|
|
27
|
+
.reject { |path| File.directory?(File.join(@base_dir, path)) }
|
|
28
|
+
.sort
|
|
29
|
+
.first(MAX_FILES)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def path_mode?(input)
|
|
33
|
+
input.start_with?("/", "~", "./", "../")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def candidates(input)
|
|
37
|
+
path_mode?(input) ? path_candidates(input) : filter(input)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def filter(input)
|
|
41
|
+
terms = input.downcase.split
|
|
42
|
+
return all_files if terms.empty?
|
|
43
|
+
|
|
44
|
+
all_files.select do |path|
|
|
45
|
+
haystack = path.downcase
|
|
46
|
+
terms.all? { |term| haystack.include?(term) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Longest unambiguous extension of the typed path. A lone directory
|
|
51
|
+
# match gains a trailing slash so the next Tab descends into it.
|
|
52
|
+
def complete(input)
|
|
53
|
+
cands = path_candidates(input)
|
|
54
|
+
return input if cands.empty?
|
|
55
|
+
|
|
56
|
+
prefix = common_prefix(cands)
|
|
57
|
+
(prefix.length > input.length) ? prefix : input
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Filesystem entries matching the typed fragment. Directories get a
|
|
63
|
+
# trailing slash; a leading ~ in the input is preserved in the results.
|
|
64
|
+
def path_candidates(input)
|
|
65
|
+
home = Dir.home
|
|
66
|
+
home_style = input.start_with?("~/") || input == "~"
|
|
67
|
+
raw = home_style ? input.sub(/\A~/, home) : input
|
|
68
|
+
Dir.glob("#{glob_escape(raw)}*").sort.map do |path|
|
|
69
|
+
path = "#{path}/" if File.directory?(path)
|
|
70
|
+
home_style ? path.sub(home, "~") : path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def glob_escape(path)
|
|
75
|
+
path.gsub(/[\[\]{}*?\\]/) { |ch| "\\#{ch}" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def common_prefix(strings)
|
|
79
|
+
strings.reduce do |prefix, s|
|
|
80
|
+
i = 0
|
|
81
|
+
i += 1 while i < prefix.length && i < s.length && prefix[i] == s[i]
|
|
82
|
+
prefix[0, i]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Marvi
|
|
4
|
+
module Renderer
|
|
5
|
+
class Curses
|
|
6
|
+
# Per-document state for one open file: source markdown, walked lines,
|
|
7
|
+
# scroll position, search state, and file-watch bookkeeping. All layout
|
|
8
|
+
# inputs (content width, page size) are passed in by the renderer so this
|
|
9
|
+
# class stays free of curses calls and can be tested headlessly.
|
|
10
|
+
class Tab
|
|
11
|
+
attr_reader :file, :lines, :markdown, :search_query, :matches, :current_match
|
|
12
|
+
attr_accessor :scroll
|
|
13
|
+
|
|
14
|
+
def initialize(file: nil, markdown: nil)
|
|
15
|
+
@file = file
|
|
16
|
+
@markdown = markdown || read_file
|
|
17
|
+
@scroll = 0
|
|
18
|
+
@lines = []
|
|
19
|
+
@walked_width = nil
|
|
20
|
+
clear_search
|
|
21
|
+
mark_reloaded
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def label
|
|
25
|
+
@file ? File.basename(@file) : "(stdin)"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Walk lazily: tabs that are not visible keep their lines from the last
|
|
29
|
+
# width they were displayed at and re-walk only when shown again.
|
|
30
|
+
def ensure_walked(width, page_size)
|
|
31
|
+
return if @walked_width == width
|
|
32
|
+
|
|
33
|
+
@walked_width = width
|
|
34
|
+
@lines = ASTWalker.new.walk(@markdown, max_width: width)
|
|
35
|
+
clamp_scroll(page_size)
|
|
36
|
+
refresh_search_after_rewalk(page_size)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rewalk(width, page_size)
|
|
40
|
+
@walked_width = nil
|
|
41
|
+
ensure_walked(width, page_size)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reload(width, page_size)
|
|
45
|
+
@markdown = read_file
|
|
46
|
+
rewalk(width, page_size)
|
|
47
|
+
mark_reloaded
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def max_scroll(page_size)
|
|
51
|
+
[@lines.size - page_size, 0].max
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clamp_scroll(page_size)
|
|
55
|
+
@scroll = @scroll.clamp(0, max_scroll(page_size))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scroll_by(delta, page_size)
|
|
59
|
+
@scroll = (@scroll + delta).clamp(0, max_scroll(page_size))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def visible_lines(page_size)
|
|
63
|
+
@lines[@scroll, page_size] || []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def current_source_line(page_size)
|
|
67
|
+
visible_lines(page_size).each { |line| return line.source_line if line.source_line }
|
|
68
|
+
# fall back to searching upward from scroll position
|
|
69
|
+
@scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
|
|
70
|
+
1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- file watching ---
|
|
74
|
+
|
|
75
|
+
# Returns true when the file changed on disk since the last check and
|
|
76
|
+
# the updated flag was newly raised.
|
|
77
|
+
def check_file_updated
|
|
78
|
+
return false unless @file
|
|
79
|
+
|
|
80
|
+
mtime = current_mtime
|
|
81
|
+
return false if mtime.nil? || mtime == @last_mtime
|
|
82
|
+
|
|
83
|
+
@last_mtime = mtime
|
|
84
|
+
return false if @file_updated
|
|
85
|
+
|
|
86
|
+
@file_updated = true
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def file_updated?
|
|
91
|
+
@file_updated
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mark_reloaded
|
|
95
|
+
@last_mtime = current_mtime
|
|
96
|
+
@file_updated = false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- search ---
|
|
100
|
+
|
|
101
|
+
def update_search(query, page_size)
|
|
102
|
+
@search_query = query
|
|
103
|
+
recompute_matches
|
|
104
|
+
focus_match_from(@scroll, page_size) unless @matches.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def commit_search
|
|
108
|
+
clear_search if @search_query.to_s.empty? || @matches.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def clear_search
|
|
112
|
+
@search_query = nil
|
|
113
|
+
@matches = []
|
|
114
|
+
@current_match = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def jump_match(direction, page_size)
|
|
118
|
+
return if @matches.empty?
|
|
119
|
+
|
|
120
|
+
if @current_match.nil?
|
|
121
|
+
focus_match_from(@scroll, page_size)
|
|
122
|
+
else
|
|
123
|
+
set_current_match(@current_match + direction, page_size)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def highlight_ranges_for(line_index)
|
|
128
|
+
return nil if @matches.empty?
|
|
129
|
+
|
|
130
|
+
ranges = []
|
|
131
|
+
@matches.each_with_index do |(li, start, finish), i|
|
|
132
|
+
ranges << [start, finish, i == @current_match] if li == line_index
|
|
133
|
+
end
|
|
134
|
+
ranges.empty? ? nil : ranges
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def search_hint
|
|
138
|
+
return " n/N next/prev" if @search_query.nil? || @search_query.empty?
|
|
139
|
+
return " no matches: #{@search_query}" if @matches.empty?
|
|
140
|
+
|
|
141
|
+
position = @current_match ? @current_match + 1 : 0
|
|
142
|
+
" [#{position}/#{@matches.size}] n/N"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def read_file
|
|
148
|
+
# Force UTF-8 so markdown parses even under a C/POSIX locale where
|
|
149
|
+
# Encoding.default_external would be US-ASCII.
|
|
150
|
+
@file ? File.read(@file, encoding: Encoding::UTF_8) : ""
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def current_mtime
|
|
154
|
+
return nil unless @file
|
|
155
|
+
|
|
156
|
+
File.mtime(@file)
|
|
157
|
+
rescue SystemCallError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def refresh_search_after_rewalk(page_size)
|
|
162
|
+
return unless @search_query
|
|
163
|
+
|
|
164
|
+
recompute_matches
|
|
165
|
+
focus_match_from(@scroll, page_size) unless @matches.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def recompute_matches
|
|
169
|
+
@matches = []
|
|
170
|
+
@current_match = nil
|
|
171
|
+
needle = @search_query.to_s.downcase
|
|
172
|
+
return if needle.empty?
|
|
173
|
+
|
|
174
|
+
@lines.each_with_index do |line, line_index|
|
|
175
|
+
haystack = line.plain_text.downcase
|
|
176
|
+
next if haystack.empty?
|
|
177
|
+
|
|
178
|
+
pos = 0
|
|
179
|
+
while (idx = haystack.index(needle, pos))
|
|
180
|
+
@matches << [line_index, idx, idx + needle.length]
|
|
181
|
+
pos = idx + needle.length
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def focus_match_from(from_line, page_size)
|
|
187
|
+
return if @matches.empty?
|
|
188
|
+
|
|
189
|
+
index = @matches.index { |match| match[0] >= from_line } || 0
|
|
190
|
+
set_current_match(index, page_size)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def set_current_match(index, page_size)
|
|
194
|
+
return if @matches.empty?
|
|
195
|
+
|
|
196
|
+
@current_match = index % @matches.size
|
|
197
|
+
ensure_match_visible(@matches[@current_match][0], page_size)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def ensure_match_visible(line_index, page_size)
|
|
201
|
+
if line_index < @scroll || line_index >= @scroll + page_size
|
|
202
|
+
@scroll = [line_index, max_scroll(page_size)].min
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Marvi
|
|
6
|
+
module Renderer
|
|
7
|
+
class Curses
|
|
8
|
+
# Pure layout computation for the tab bar so it can be tested without a
|
|
9
|
+
# terminal. Tabs are packed greedily left to right and wrap to a new row
|
|
10
|
+
# when the screen width is exceeded, producing a multi-row bar.
|
|
11
|
+
module TabBar
|
|
12
|
+
Item = Struct.new(:index, :text, :row, :col, :width, keyword_init: true) do
|
|
13
|
+
def contains?(y, x)
|
|
14
|
+
y == row && x >= col && x < col + width
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def layout(labels, screen_width)
|
|
21
|
+
screen_width = [screen_width, 1].max
|
|
22
|
+
row = 0
|
|
23
|
+
col = 0
|
|
24
|
+
labels.each_with_index.map do |label, index|
|
|
25
|
+
text = truncate_to_width(" #{label} ", screen_width)
|
|
26
|
+
width = Unicode::DisplayWidth.of(text)
|
|
27
|
+
if col.positive? && col + width > screen_width
|
|
28
|
+
row += 1
|
|
29
|
+
col = 0
|
|
30
|
+
end
|
|
31
|
+
item = Item.new(index: index, text: text, row: row, col: col, width: width)
|
|
32
|
+
col += width
|
|
33
|
+
item
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rows(items)
|
|
38
|
+
items.empty? ? 0 : items.last.row + 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def item_at(items, y, x)
|
|
42
|
+
items.find { |item| item.contains?(y, x) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def truncate_to_width(text, max_width)
|
|
46
|
+
return text if Unicode::DisplayWidth.of(text) <= max_width
|
|
47
|
+
|
|
48
|
+
result = +""
|
|
49
|
+
width = 0
|
|
50
|
+
text.each_char do |ch|
|
|
51
|
+
ch_width = Unicode::DisplayWidth.of(ch)
|
|
52
|
+
break if width + ch_width > max_width
|
|
53
|
+
|
|
54
|
+
result << ch
|
|
55
|
+
width += ch_width
|
|
56
|
+
end
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -24,26 +24,39 @@ module Marvi
|
|
|
24
24
|
FILE_POLL_INTERVAL_MS = 500
|
|
25
25
|
|
|
26
26
|
CTRL_D = 4
|
|
27
|
+
CTRL_N = 14
|
|
28
|
+
CTRL_P = 16
|
|
27
29
|
CTRL_U = 21
|
|
30
|
+
TAB_KEY = 9
|
|
28
31
|
|
|
29
32
|
MIN_HORIZONTAL_PADDING = 2
|
|
30
33
|
HORIZONTAL_PADDING_DIVISOR = 12
|
|
31
34
|
|
|
32
35
|
def render(markdown, file: nil)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
run([Tab.new(file: file, markdown: markdown)])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render_files(files)
|
|
40
|
+
run(files.map { |file| Tab.new(file: file) })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def run(tabs)
|
|
46
|
+
@tabs = tabs
|
|
47
|
+
@current = 0
|
|
48
|
+
@search_input = nil
|
|
49
|
+
@status_message = nil
|
|
37
50
|
|
|
38
51
|
init_curses_state
|
|
39
|
-
|
|
52
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
40
53
|
draw
|
|
41
54
|
|
|
42
55
|
catch(:quit) do
|
|
43
56
|
loop do
|
|
44
57
|
key = ::Curses.getch
|
|
45
58
|
if key.nil? || key == -1
|
|
46
|
-
|
|
59
|
+
poll_files
|
|
47
60
|
else
|
|
48
61
|
handle_key(key)
|
|
49
62
|
end
|
|
@@ -53,7 +66,9 @@ module Marvi
|
|
|
53
66
|
::Curses.close_screen
|
|
54
67
|
end
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
def current_tab
|
|
70
|
+
@tabs[@current]
|
|
71
|
+
end
|
|
57
72
|
|
|
58
73
|
# ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
|
|
59
74
|
# identical glyphs, but ghostty mishandles it and drops the run from the
|
|
@@ -111,6 +126,7 @@ module Marvi
|
|
|
111
126
|
end
|
|
112
127
|
|
|
113
128
|
def handle_key(key)
|
|
129
|
+
@status_message = nil
|
|
114
130
|
case key
|
|
115
131
|
when "q", "Q", 27 then throw :quit
|
|
116
132
|
when "j", ::Curses::Key::DOWN then scroll_by(1)
|
|
@@ -119,24 +135,119 @@ module Marvi
|
|
|
119
135
|
when "u", CTRL_U then scroll_by(-page_size / 2)
|
|
120
136
|
when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
|
|
121
137
|
when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
|
|
122
|
-
when "g" then
|
|
138
|
+
when "g" then current_tab.scroll = 0
|
|
123
139
|
draw
|
|
124
|
-
when "G" then
|
|
140
|
+
when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
|
|
125
141
|
draw
|
|
126
|
-
when "
|
|
127
|
-
when "
|
|
142
|
+
when "/" then start_search
|
|
143
|
+
when "n" then jump_match(1)
|
|
144
|
+
when "N" then jump_match(-1)
|
|
145
|
+
when "e" then launch_editor if current_tab.file
|
|
146
|
+
when "r", "R" then reload_from_key if current_tab.file
|
|
147
|
+
when "\t", TAB_KEY then cycle_tab(1)
|
|
148
|
+
when ::Curses::Key::BTAB then cycle_tab(-1)
|
|
149
|
+
when "1".."9" then switch_to(key.to_i - 1)
|
|
150
|
+
when "o" then start_open
|
|
151
|
+
when "x" then close_current_tab
|
|
152
|
+
when ::Curses::Key::MOUSE then handle_mouse
|
|
128
153
|
when ::Curses::Key::RESIZE then handle_resize
|
|
129
154
|
end
|
|
130
155
|
end
|
|
131
156
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
# --- tabs ---
|
|
158
|
+
|
|
159
|
+
def cycle_tab(delta)
|
|
160
|
+
return if @tabs.size <= 1
|
|
161
|
+
|
|
162
|
+
switch_to((@current + delta) % @tabs.size)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def switch_to(index)
|
|
166
|
+
return if index.negative? || index >= @tabs.size
|
|
167
|
+
|
|
168
|
+
@current = index
|
|
169
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
135
170
|
draw
|
|
136
171
|
end
|
|
137
172
|
|
|
138
|
-
def
|
|
139
|
-
|
|
173
|
+
def close_current_tab
|
|
174
|
+
throw :quit if @tabs.size == 1
|
|
175
|
+
|
|
176
|
+
@tabs.delete_at(@current)
|
|
177
|
+
@current = [@current, @tabs.size - 1].min
|
|
178
|
+
update_mousemask
|
|
179
|
+
# The bar may have lost a row (or disappeared), changing the page size.
|
|
180
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
181
|
+
draw
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def open_tab(path)
|
|
185
|
+
path = File.expand_path(path)
|
|
186
|
+
existing = @tabs.index { |tab| tab.file == path }
|
|
187
|
+
return switch_to(existing) if existing
|
|
188
|
+
|
|
189
|
+
unless File.file?(path) && File.readable?(path)
|
|
190
|
+
@status_message = " cannot open: #{path} "
|
|
191
|
+
draw
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@tabs << Tab.new(file: path)
|
|
196
|
+
update_mousemask
|
|
197
|
+
switch_to(@tabs.size - 1)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def update_mousemask
|
|
201
|
+
return unless ::Curses.respond_to?(:mousemask)
|
|
202
|
+
|
|
203
|
+
# Claim the mouse only when the tab bar is shown, so single-document
|
|
204
|
+
# sessions keep native terminal text selection.
|
|
205
|
+
mask = (@tabs.size > 1) ? (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED) : 0
|
|
206
|
+
::Curses.mousemask(mask)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_mouse
|
|
210
|
+
event = ::Curses.getmouse
|
|
211
|
+
return unless event
|
|
212
|
+
return if (event.bstate & (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED)).zero?
|
|
213
|
+
|
|
214
|
+
item = TabBar.item_at(tab_layout, event.y, event.x)
|
|
215
|
+
switch_to(item.index) if item
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def tab_layout
|
|
219
|
+
return [] if @tabs.size <= 1
|
|
220
|
+
|
|
221
|
+
labels = @tabs.each_with_index.map { |tab, i| tab_label(tab, i) }
|
|
222
|
+
TabBar.layout(labels, ::Curses.cols)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def tab_label(tab, index)
|
|
226
|
+
updated_mark = tab.file_updated? ? " ●" : ""
|
|
227
|
+
"#{index + 1}:#{tab.label}#{updated_mark}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def tab_bar_height
|
|
231
|
+
TabBar.rows(tab_layout)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def draw_tab_bar(layout)
|
|
235
|
+
layout.each do |item|
|
|
236
|
+
::Curses.setpos(item.row, item.col)
|
|
237
|
+
attr = if item.index == @current
|
|
238
|
+
::Curses::A_REVERSE | ::Curses::A_BOLD
|
|
239
|
+
else
|
|
240
|
+
::Curses.color_pair(COLOR_PAIRS[:cyan])
|
|
241
|
+
end
|
|
242
|
+
::Curses.attron(attr) { ::Curses.addstr(item.text) }
|
|
243
|
+
rescue ::Curses::Error
|
|
244
|
+
# ignore write errors at screen edge
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def handle_resize
|
|
249
|
+
current_tab.rewalk(content_width, page_size)
|
|
250
|
+
draw
|
|
140
251
|
end
|
|
141
252
|
|
|
142
253
|
def horizontal_padding
|
|
@@ -150,55 +261,28 @@ module Marvi
|
|
|
150
261
|
end
|
|
151
262
|
|
|
152
263
|
def reload_from_key
|
|
153
|
-
reload
|
|
154
|
-
mark_reloaded
|
|
264
|
+
current_tab.reload(content_width, page_size)
|
|
155
265
|
draw
|
|
156
266
|
end
|
|
157
267
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return if mtime.nil? || mtime == @last_mtime
|
|
162
|
-
|
|
163
|
-
@last_mtime = mtime
|
|
164
|
-
return if @file_updated
|
|
165
|
-
|
|
166
|
-
@file_updated = true
|
|
167
|
-
draw_status_bar
|
|
168
|
-
::Curses.refresh
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def mark_reloaded
|
|
172
|
-
@last_mtime = current_mtime
|
|
173
|
-
@file_updated = false
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def current_mtime
|
|
177
|
-
return nil unless @file
|
|
178
|
-
File.mtime(@file)
|
|
179
|
-
rescue SystemCallError
|
|
180
|
-
nil
|
|
268
|
+
def poll_files
|
|
269
|
+
updated = @tabs.select(&:check_file_updated)
|
|
270
|
+
draw unless updated.empty?
|
|
181
271
|
end
|
|
182
272
|
|
|
183
273
|
def launch_editor
|
|
274
|
+
tab = current_tab
|
|
184
275
|
editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
|
|
185
|
-
line = current_source_line
|
|
186
|
-
cmd = build_editor_command(editor,
|
|
276
|
+
line = tab.current_source_line(page_size)
|
|
277
|
+
cmd = build_editor_command(editor, tab.file, line)
|
|
187
278
|
|
|
188
279
|
::Curses.close_screen
|
|
189
280
|
system(cmd)
|
|
190
281
|
init_curses_state
|
|
191
|
-
reload
|
|
192
|
-
mark_reloaded
|
|
282
|
+
tab.reload(content_width, page_size)
|
|
193
283
|
draw
|
|
194
284
|
end
|
|
195
285
|
|
|
196
|
-
def reload
|
|
197
|
-
@markdown = File.read(@file)
|
|
198
|
-
rewalk
|
|
199
|
-
@scroll = [@scroll, max_scroll].min
|
|
200
|
-
end
|
|
201
|
-
|
|
202
286
|
def init_curses_state
|
|
203
287
|
with_safe_term { ::Curses.init_screen }
|
|
204
288
|
::Curses.start_color
|
|
@@ -208,6 +292,7 @@ module Marvi
|
|
|
208
292
|
::Curses.stdscr.keypad(true)
|
|
209
293
|
::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
|
|
210
294
|
setup_colors
|
|
295
|
+
update_mousemask
|
|
211
296
|
end
|
|
212
297
|
|
|
213
298
|
def build_editor_command(editor, file, line)
|
|
@@ -224,58 +309,100 @@ module Marvi
|
|
|
224
309
|
end
|
|
225
310
|
end
|
|
226
311
|
|
|
227
|
-
def current_source_line
|
|
228
|
-
visible_lines.each { |line| return line.source_line if line.source_line }
|
|
229
|
-
# fall back to searching upward from scroll position
|
|
230
|
-
@scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
|
|
231
|
-
1
|
|
232
|
-
end
|
|
233
|
-
|
|
234
312
|
def draw
|
|
235
313
|
::Curses.clear
|
|
314
|
+
layout = tab_layout
|
|
315
|
+
draw_tab_bar(layout)
|
|
316
|
+
offset = TabBar.rows(layout)
|
|
317
|
+
tab = current_tab
|
|
318
|
+
tab.clamp_scroll(page_size)
|
|
236
319
|
padding = horizontal_padding
|
|
237
|
-
visible_lines.each_with_index do |line, row|
|
|
238
|
-
::Curses.setpos(row, padding)
|
|
239
|
-
render_line(line)
|
|
320
|
+
tab.visible_lines(page_size).each_with_index do |line, row|
|
|
321
|
+
::Curses.setpos(row + offset, padding)
|
|
322
|
+
render_line(line, tab.highlight_ranges_for(tab.scroll + row))
|
|
240
323
|
end
|
|
241
324
|
draw_status_bar
|
|
242
325
|
::Curses.refresh
|
|
243
326
|
end
|
|
244
327
|
|
|
245
328
|
def draw_status_bar
|
|
329
|
+
tab = current_tab
|
|
246
330
|
::Curses.setpos(::Curses.lines - 1, 0)
|
|
247
|
-
top =
|
|
248
|
-
bottom = [
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
331
|
+
top = tab.scroll + 1
|
|
332
|
+
bottom = [tab.scroll + page_size, tab.lines.size].min
|
|
333
|
+
tab_hint = (@tabs.size > 1) ? " [#{@current + 1}/#{@tabs.size}] Tab next" : ""
|
|
334
|
+
edit_hint = tab.file ? " e edit" : ""
|
|
335
|
+
status = "#{tab_hint} #{top}-#{bottom}/#{tab.lines.size} j/k scroll g/G top/bottom / search#{tab.search_hint}#{edit_hint} o open q quit"
|
|
336
|
+
right_hint = @status_message || (tab.file_updated? ? " ● updated (r to reload) " : "")
|
|
337
|
+
available = [::Curses.cols - right_hint.length, 0].max
|
|
253
338
|
|
|
254
339
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
255
340
|
::Curses.addstr(status.ljust(available)[0, available])
|
|
256
341
|
end
|
|
257
|
-
unless
|
|
342
|
+
unless right_hint.empty?
|
|
258
343
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
|
|
259
|
-
::Curses.addstr(
|
|
344
|
+
::Curses.addstr(right_hint)
|
|
260
345
|
end
|
|
261
346
|
end
|
|
262
347
|
end
|
|
263
348
|
|
|
264
|
-
def render_line(line)
|
|
349
|
+
def render_line(line, highlights = nil)
|
|
350
|
+
col = 0
|
|
265
351
|
line.spans.each do |span|
|
|
266
|
-
render_span(span)
|
|
352
|
+
render_span(span, col, highlights)
|
|
353
|
+
col += span.text.length
|
|
267
354
|
rescue ::Curses::Error
|
|
268
355
|
# ignore write errors at line edge
|
|
269
356
|
end
|
|
270
357
|
end
|
|
271
358
|
|
|
272
|
-
def render_span(span)
|
|
359
|
+
def render_span(span, col_offset = 0, highlights = nil)
|
|
273
360
|
attr = build_attr(span)
|
|
361
|
+
if highlights.nil? || highlights.empty?
|
|
362
|
+
write_text(span.text, attr)
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
highlight_segments(span.text, col_offset, highlights).each do |text, state|
|
|
367
|
+
seg_attr = attr
|
|
368
|
+
seg_attr |= ::Curses::A_REVERSE if state
|
|
369
|
+
seg_attr |= ::Curses::A_BOLD if state == :current
|
|
370
|
+
write_text(text, seg_attr)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def write_text(text, attr)
|
|
274
375
|
if attr != 0
|
|
275
|
-
::Curses.attron(attr) { ::Curses.addstr(
|
|
376
|
+
::Curses.attron(attr) { ::Curses.addstr(text) }
|
|
276
377
|
else
|
|
277
|
-
::Curses.addstr(
|
|
378
|
+
::Curses.addstr(text)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Split a span's text into runs of identical highlight state so each run can
|
|
383
|
+
# be drawn with the matching attributes. Highlight ranges are expressed in
|
|
384
|
+
# whole-line character offsets, hence col_offset locates this span on the line.
|
|
385
|
+
def highlight_segments(text, col_offset, highlights)
|
|
386
|
+
chars = text.chars
|
|
387
|
+
states = Array.new(chars.length)
|
|
388
|
+
highlights.each do |start, finish, current|
|
|
389
|
+
start.upto(finish - 1) do |c|
|
|
390
|
+
idx = c - col_offset
|
|
391
|
+
next if idx.negative? || idx >= chars.length
|
|
392
|
+
states[idx] = :current if current
|
|
393
|
+
states[idx] ||= true
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
segments = []
|
|
398
|
+
chars.each_with_index do |ch, i|
|
|
399
|
+
if segments.empty? || segments.last[1] != states[i]
|
|
400
|
+
segments << [ch.dup, states[i]]
|
|
401
|
+
else
|
|
402
|
+
segments.last[0] << ch
|
|
403
|
+
end
|
|
278
404
|
end
|
|
405
|
+
segments
|
|
279
406
|
end
|
|
280
407
|
|
|
281
408
|
def build_attr(span)
|
|
@@ -293,22 +420,161 @@ module Marvi
|
|
|
293
420
|
attr
|
|
294
421
|
end
|
|
295
422
|
|
|
296
|
-
def
|
|
297
|
-
|
|
423
|
+
def page_size
|
|
424
|
+
[::Curses.lines - 1 - tab_bar_height, 1].max
|
|
298
425
|
end
|
|
299
426
|
|
|
300
|
-
def
|
|
301
|
-
|
|
427
|
+
def scroll_by(delta)
|
|
428
|
+
current_tab.scroll_by(delta, page_size)
|
|
429
|
+
draw
|
|
302
430
|
end
|
|
303
431
|
|
|
304
|
-
|
|
305
|
-
|
|
432
|
+
# --- line-editing prompts (search and open) ---
|
|
433
|
+
|
|
434
|
+
BACKSPACE_KEYS = [127, 8].freeze
|
|
435
|
+
|
|
436
|
+
def start_search
|
|
437
|
+
@search_input = ""
|
|
438
|
+
update_search(@search_input)
|
|
439
|
+
draw_prompt("/#{@search_input}")
|
|
440
|
+
|
|
441
|
+
loop do
|
|
442
|
+
key = ::Curses.getch
|
|
443
|
+
next if key.nil? || key == -1
|
|
444
|
+
|
|
445
|
+
case key
|
|
446
|
+
when 27 # ESC cancels and clears the search
|
|
447
|
+
current_tab.clear_search
|
|
448
|
+
@search_input = nil
|
|
449
|
+
draw
|
|
450
|
+
return
|
|
451
|
+
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
452
|
+
current_tab.commit_search
|
|
453
|
+
@search_input = nil
|
|
454
|
+
draw
|
|
455
|
+
return
|
|
456
|
+
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
457
|
+
@search_input = @search_input[0...-1] || ""
|
|
458
|
+
update_search(@search_input)
|
|
459
|
+
draw_prompt("/#{@search_input}")
|
|
460
|
+
else
|
|
461
|
+
next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
462
|
+
@search_input += key
|
|
463
|
+
update_search(@search_input)
|
|
464
|
+
draw_prompt("/#{@search_input}")
|
|
465
|
+
end
|
|
466
|
+
end
|
|
306
467
|
end
|
|
307
468
|
|
|
308
|
-
def
|
|
309
|
-
|
|
469
|
+
def update_search(query)
|
|
470
|
+
current_tab.update_search(query, page_size)
|
|
310
471
|
draw
|
|
311
472
|
end
|
|
473
|
+
|
|
474
|
+
def jump_match(direction)
|
|
475
|
+
current_tab.jump_match(direction, page_size)
|
|
476
|
+
draw
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def start_open
|
|
480
|
+
picker = FilePicker.new
|
|
481
|
+
input = ""
|
|
482
|
+
selected = 0
|
|
483
|
+
|
|
484
|
+
loop do
|
|
485
|
+
candidates = picker.candidates(input)
|
|
486
|
+
selected = candidates.empty? ? 0 : selected % candidates.size
|
|
487
|
+
draw_picker(input, candidates, selected)
|
|
488
|
+
|
|
489
|
+
key = ::Curses.getch
|
|
490
|
+
next if key.nil? || key == -1
|
|
491
|
+
|
|
492
|
+
case key
|
|
493
|
+
when 27 # ESC cancels
|
|
494
|
+
draw
|
|
495
|
+
return
|
|
496
|
+
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
497
|
+
choice = candidates[selected] || input.strip
|
|
498
|
+
if choice.empty?
|
|
499
|
+
draw
|
|
500
|
+
return
|
|
501
|
+
elsif choice.end_with?("/")
|
|
502
|
+
# descend into the selected directory and keep picking
|
|
503
|
+
input = choice
|
|
504
|
+
selected = 0
|
|
505
|
+
else
|
|
506
|
+
open_tab(choice)
|
|
507
|
+
return
|
|
508
|
+
end
|
|
509
|
+
when "\t", TAB_KEY
|
|
510
|
+
if picker.path_mode?(input)
|
|
511
|
+
input = picker.complete(input)
|
|
512
|
+
else
|
|
513
|
+
selected += 1
|
|
514
|
+
end
|
|
515
|
+
when ::Curses::Key::BTAB
|
|
516
|
+
selected -= 1
|
|
517
|
+
when CTRL_N, ::Curses::Key::DOWN
|
|
518
|
+
selected += 1
|
|
519
|
+
when CTRL_P, ::Curses::Key::UP
|
|
520
|
+
selected -= 1
|
|
521
|
+
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
522
|
+
input = input[0...-1] || ""
|
|
523
|
+
selected = 0
|
|
524
|
+
else
|
|
525
|
+
if key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
526
|
+
input += key
|
|
527
|
+
selected = 0
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def draw_picker(input, candidates, selected)
|
|
534
|
+
::Curses.clear
|
|
535
|
+
width = ::Curses.cols
|
|
536
|
+
position = candidates.empty? ? 0 : selected + 1
|
|
537
|
+
header = " open file (#{position}/#{candidates.size}) C-n/C-p select Enter open ESC cancel"
|
|
538
|
+
::Curses.setpos(0, 0)
|
|
539
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan]) | ::Curses::A_BOLD) do
|
|
540
|
+
::Curses.addstr(header.ljust(width)[0, width])
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
list_rows = [::Curses.lines - 2, 1].max
|
|
544
|
+
offset = (selected < list_rows) ? 0 : selected - list_rows + 1
|
|
545
|
+
(candidates[offset, list_rows] || []).each_with_index do |candidate, i|
|
|
546
|
+
::Curses.setpos(1 + i, 0)
|
|
547
|
+
text = " #{candidate} ".ljust(width)[0, width]
|
|
548
|
+
if offset + i == selected
|
|
549
|
+
::Curses.attron(::Curses::A_REVERSE) { ::Curses.addstr(text) }
|
|
550
|
+
else
|
|
551
|
+
::Curses.addstr(text)
|
|
552
|
+
end
|
|
553
|
+
rescue ::Curses::Error
|
|
554
|
+
# ignore write errors at screen edge
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
prompt = "open: #{input}"
|
|
558
|
+
::Curses.setpos(::Curses.lines - 1, 0)
|
|
559
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
560
|
+
::Curses.addstr(prompt.ljust(width)[0, width])
|
|
561
|
+
end
|
|
562
|
+
::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
|
|
563
|
+
::Curses.refresh
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def draw_prompt(prompt)
|
|
567
|
+
::Curses.setpos(::Curses.lines - 1, 0)
|
|
568
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
569
|
+
::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
|
|
570
|
+
end
|
|
571
|
+
::Curses.setpos(::Curses.lines - 1, [prompt.length, ::Curses.cols - 1].min)
|
|
572
|
+
::Curses.refresh
|
|
573
|
+
end
|
|
312
574
|
end
|
|
313
575
|
end
|
|
314
576
|
end
|
|
577
|
+
|
|
578
|
+
require_relative "curses/tab"
|
|
579
|
+
require_relative "curses/tab_bar"
|
|
580
|
+
require_relative "curses/file_picker"
|
data/lib/marvi/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: marvi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mitsutaka Mimura
|
|
@@ -110,6 +110,9 @@ files:
|
|
|
110
110
|
- lib/marvi/highlighter.rb
|
|
111
111
|
- lib/marvi/renderer/ansi.rb
|
|
112
112
|
- lib/marvi/renderer/curses.rb
|
|
113
|
+
- lib/marvi/renderer/curses/file_picker.rb
|
|
114
|
+
- lib/marvi/renderer/curses/tab.rb
|
|
115
|
+
- lib/marvi/renderer/curses/tab_bar.rb
|
|
113
116
|
- lib/marvi/version.rb
|
|
114
117
|
- marvi-0.1.0.gem
|
|
115
118
|
- sig/marvi.rbs
|