marvi 0.7.0 → 0.8.1
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 +68 -0
- data/lib/marvi/renderer/curses.rb +249 -162
- 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: ce10cf349eaa081b2f4fc1f2f9c038fd62c7e3aaa5f01ed782e03e6a05a93e8c
|
|
4
|
+
data.tar.gz: 7e4c20a1f8f53dd60e21ed27ad3f95ae08f5be365e6eda5fba514fb947b25f0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6ebac4779d6b0d88b3473d26d9455322cd0680defe3c94e7784dc6506c23173d4dc8236be67f28ff8feae4b25f9526b3a53502decb8a16e107285414cf4b4f3
|
|
7
|
+
data.tar.gz: bf8467ba67c1f3ff3cd87c02cb577492c76ff36ff8b67efbc89e5e9cd219f5388175ced544043008ea1d4bf1b2f1e3c94cc20f9473ec8f9377e6798e4e63aae7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.1] - 2026-07-07
|
|
4
|
+
|
|
5
|
+
- Add a blank margin row between the tab bar and the document. Clicks on the margin row select the tab directly above it, giving each tab a taller effective click target.
|
|
6
|
+
|
|
7
|
+
## [0.8.0] - 2026-07-06
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
3
12
|
## [0.7.0] - 2026-06-15
|
|
4
13
|
|
|
5
14
|
- 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.
|
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,68 @@
|
|
|
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
|
+
# Resolve a click including the margin row directly below the bar,
|
|
46
|
+
# which is attributed to the tab above it — a taller click target.
|
|
47
|
+
def item_for_click(items, y, x)
|
|
48
|
+
item_at(items, y, x) || ((y == rows(items)) ? item_at(items, y - 1, x) : nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def truncate_to_width(text, max_width)
|
|
52
|
+
return text if Unicode::DisplayWidth.of(text) <= max_width
|
|
53
|
+
|
|
54
|
+
result = +""
|
|
55
|
+
width = 0
|
|
56
|
+
text.each_char do |ch|
|
|
57
|
+
ch_width = Unicode::DisplayWidth.of(ch)
|
|
58
|
+
break if width + ch_width > max_width
|
|
59
|
+
|
|
60
|
+
result << ch
|
|
61
|
+
width += ch_width
|
|
62
|
+
end
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -24,30 +24,44 @@ 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
|
|
|
35
|
+
# Blank row between the tab bar and the document. Clicks landing on it
|
|
36
|
+
# are attributed to the tab directly above, giving each tab a taller
|
|
37
|
+
# effective click target than the single character cell.
|
|
38
|
+
TAB_BAR_MARGIN_ROWS = 1
|
|
39
|
+
|
|
32
40
|
def render(markdown, file: nil)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
run([Tab.new(file: file, markdown: markdown)])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_files(files)
|
|
45
|
+
run(files.map { |file| Tab.new(file: file) })
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def run(tabs)
|
|
51
|
+
@tabs = tabs
|
|
52
|
+
@current = 0
|
|
37
53
|
@search_input = nil
|
|
38
|
-
@
|
|
39
|
-
@current_match = nil
|
|
40
|
-
mark_reloaded
|
|
54
|
+
@status_message = nil
|
|
41
55
|
|
|
42
56
|
init_curses_state
|
|
43
|
-
|
|
57
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
44
58
|
draw
|
|
45
59
|
|
|
46
60
|
catch(:quit) do
|
|
47
61
|
loop do
|
|
48
62
|
key = ::Curses.getch
|
|
49
63
|
if key.nil? || key == -1
|
|
50
|
-
|
|
64
|
+
poll_files
|
|
51
65
|
else
|
|
52
66
|
handle_key(key)
|
|
53
67
|
end
|
|
@@ -57,7 +71,9 @@ module Marvi
|
|
|
57
71
|
::Curses.close_screen
|
|
58
72
|
end
|
|
59
73
|
|
|
60
|
-
|
|
74
|
+
def current_tab
|
|
75
|
+
@tabs[@current]
|
|
76
|
+
end
|
|
61
77
|
|
|
62
78
|
# ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
|
|
63
79
|
# identical glyphs, but ghostty mishandles it and drops the run from the
|
|
@@ -115,6 +131,7 @@ module Marvi
|
|
|
115
131
|
end
|
|
116
132
|
|
|
117
133
|
def handle_key(key)
|
|
134
|
+
@status_message = nil
|
|
118
135
|
case key
|
|
119
136
|
when "q", "Q", 27 then throw :quit
|
|
120
137
|
when "j", ::Curses::Key::DOWN then scroll_by(1)
|
|
@@ -123,28 +140,120 @@ module Marvi
|
|
|
123
140
|
when "u", CTRL_U then scroll_by(-page_size / 2)
|
|
124
141
|
when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
|
|
125
142
|
when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
|
|
126
|
-
when "g" then
|
|
143
|
+
when "g" then current_tab.scroll = 0
|
|
127
144
|
draw
|
|
128
|
-
when "G" then
|
|
145
|
+
when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
|
|
129
146
|
draw
|
|
130
147
|
when "/" then start_search
|
|
131
148
|
when "n" then jump_match(1)
|
|
132
149
|
when "N" then jump_match(-1)
|
|
133
|
-
when "e" then launch_editor if
|
|
134
|
-
when "r", "R" then reload_from_key if
|
|
150
|
+
when "e" then launch_editor if current_tab.file
|
|
151
|
+
when "r", "R" then reload_from_key if current_tab.file
|
|
152
|
+
when "\t", TAB_KEY then cycle_tab(1)
|
|
153
|
+
when ::Curses::Key::BTAB then cycle_tab(-1)
|
|
154
|
+
when "1".."9" then switch_to(key.to_i - 1)
|
|
155
|
+
when "o" then start_open
|
|
156
|
+
when "x" then close_current_tab
|
|
157
|
+
when ::Curses::Key::MOUSE then handle_mouse
|
|
135
158
|
when ::Curses::Key::RESIZE then handle_resize
|
|
136
159
|
end
|
|
137
160
|
end
|
|
138
161
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
# --- tabs ---
|
|
163
|
+
|
|
164
|
+
def cycle_tab(delta)
|
|
165
|
+
return if @tabs.size <= 1
|
|
166
|
+
|
|
167
|
+
switch_to((@current + delta) % @tabs.size)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def switch_to(index)
|
|
171
|
+
return if index.negative? || index >= @tabs.size
|
|
172
|
+
|
|
173
|
+
@current = index
|
|
174
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
143
175
|
draw
|
|
144
176
|
end
|
|
145
177
|
|
|
146
|
-
def
|
|
147
|
-
|
|
178
|
+
def close_current_tab
|
|
179
|
+
throw :quit if @tabs.size == 1
|
|
180
|
+
|
|
181
|
+
@tabs.delete_at(@current)
|
|
182
|
+
@current = [@current, @tabs.size - 1].min
|
|
183
|
+
update_mousemask
|
|
184
|
+
# The bar may have lost a row (or disappeared), changing the page size.
|
|
185
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
186
|
+
draw
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def open_tab(path)
|
|
190
|
+
path = File.expand_path(path)
|
|
191
|
+
existing = @tabs.index { |tab| tab.file == path }
|
|
192
|
+
return switch_to(existing) if existing
|
|
193
|
+
|
|
194
|
+
unless File.file?(path) && File.readable?(path)
|
|
195
|
+
@status_message = " cannot open: #{path} "
|
|
196
|
+
draw
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
@tabs << Tab.new(file: path)
|
|
201
|
+
update_mousemask
|
|
202
|
+
switch_to(@tabs.size - 1)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def update_mousemask
|
|
206
|
+
return unless ::Curses.respond_to?(:mousemask)
|
|
207
|
+
|
|
208
|
+
# Claim the mouse only when the tab bar is shown, so single-document
|
|
209
|
+
# sessions keep native terminal text selection.
|
|
210
|
+
mask = (@tabs.size > 1) ? (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED) : 0
|
|
211
|
+
::Curses.mousemask(mask)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def handle_mouse
|
|
215
|
+
event = ::Curses.getmouse
|
|
216
|
+
return unless event
|
|
217
|
+
return if (event.bstate & (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED)).zero?
|
|
218
|
+
|
|
219
|
+
item = TabBar.item_for_click(tab_layout, event.y, event.x)
|
|
220
|
+
switch_to(item.index) if item
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def tab_layout
|
|
224
|
+
return [] if @tabs.size <= 1
|
|
225
|
+
|
|
226
|
+
labels = @tabs.each_with_index.map { |tab, i| tab_label(tab, i) }
|
|
227
|
+
TabBar.layout(labels, ::Curses.cols)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def tab_label(tab, index)
|
|
231
|
+
updated_mark = tab.file_updated? ? " ●" : ""
|
|
232
|
+
"#{index + 1}:#{tab.label}#{updated_mark}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def tab_bar_height
|
|
236
|
+
rows = TabBar.rows(tab_layout)
|
|
237
|
+
rows.zero? ? 0 : rows + TAB_BAR_MARGIN_ROWS
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def draw_tab_bar(layout)
|
|
241
|
+
layout.each do |item|
|
|
242
|
+
::Curses.setpos(item.row, item.col)
|
|
243
|
+
attr = if item.index == @current
|
|
244
|
+
::Curses::A_REVERSE | ::Curses::A_BOLD
|
|
245
|
+
else
|
|
246
|
+
::Curses.color_pair(COLOR_PAIRS[:cyan])
|
|
247
|
+
end
|
|
248
|
+
::Curses.attron(attr) { ::Curses.addstr(item.text) }
|
|
249
|
+
rescue ::Curses::Error
|
|
250
|
+
# ignore write errors at screen edge
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def handle_resize
|
|
255
|
+
current_tab.rewalk(content_width, page_size)
|
|
256
|
+
draw
|
|
148
257
|
end
|
|
149
258
|
|
|
150
259
|
def horizontal_padding
|
|
@@ -158,56 +267,28 @@ module Marvi
|
|
|
158
267
|
end
|
|
159
268
|
|
|
160
269
|
def reload_from_key
|
|
161
|
-
reload
|
|
162
|
-
mark_reloaded
|
|
270
|
+
current_tab.reload(content_width, page_size)
|
|
163
271
|
draw
|
|
164
272
|
end
|
|
165
273
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return if mtime.nil? || mtime == @last_mtime
|
|
170
|
-
|
|
171
|
-
@last_mtime = mtime
|
|
172
|
-
return if @file_updated
|
|
173
|
-
|
|
174
|
-
@file_updated = true
|
|
175
|
-
draw_status_bar
|
|
176
|
-
::Curses.refresh
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def mark_reloaded
|
|
180
|
-
@last_mtime = current_mtime
|
|
181
|
-
@file_updated = false
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def current_mtime
|
|
185
|
-
return nil unless @file
|
|
186
|
-
File.mtime(@file)
|
|
187
|
-
rescue SystemCallError
|
|
188
|
-
nil
|
|
274
|
+
def poll_files
|
|
275
|
+
updated = @tabs.select(&:check_file_updated)
|
|
276
|
+
draw unless updated.empty?
|
|
189
277
|
end
|
|
190
278
|
|
|
191
279
|
def launch_editor
|
|
280
|
+
tab = current_tab
|
|
192
281
|
editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
|
|
193
|
-
line = current_source_line
|
|
194
|
-
cmd = build_editor_command(editor,
|
|
282
|
+
line = tab.current_source_line(page_size)
|
|
283
|
+
cmd = build_editor_command(editor, tab.file, line)
|
|
195
284
|
|
|
196
285
|
::Curses.close_screen
|
|
197
286
|
system(cmd)
|
|
198
287
|
init_curses_state
|
|
199
|
-
reload
|
|
200
|
-
mark_reloaded
|
|
288
|
+
tab.reload(content_width, page_size)
|
|
201
289
|
draw
|
|
202
290
|
end
|
|
203
291
|
|
|
204
|
-
def reload
|
|
205
|
-
@markdown = File.read(@file)
|
|
206
|
-
rewalk
|
|
207
|
-
@scroll = [@scroll, max_scroll].min
|
|
208
|
-
refresh_search_after_rewalk
|
|
209
|
-
end
|
|
210
|
-
|
|
211
292
|
def init_curses_state
|
|
212
293
|
with_safe_term { ::Curses.init_screen }
|
|
213
294
|
::Curses.start_color
|
|
@@ -217,6 +298,7 @@ module Marvi
|
|
|
217
298
|
::Curses.stdscr.keypad(true)
|
|
218
299
|
::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
|
|
219
300
|
setup_colors
|
|
301
|
+
update_mousemask
|
|
220
302
|
end
|
|
221
303
|
|
|
222
304
|
def build_editor_command(editor, file, line)
|
|
@@ -233,39 +315,40 @@ module Marvi
|
|
|
233
315
|
end
|
|
234
316
|
end
|
|
235
317
|
|
|
236
|
-
def current_source_line
|
|
237
|
-
visible_lines.each { |line| return line.source_line if line.source_line }
|
|
238
|
-
# fall back to searching upward from scroll position
|
|
239
|
-
@scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
|
|
240
|
-
1
|
|
241
|
-
end
|
|
242
|
-
|
|
243
318
|
def draw
|
|
244
319
|
::Curses.clear
|
|
320
|
+
layout = tab_layout
|
|
321
|
+
draw_tab_bar(layout)
|
|
322
|
+
bar_rows = TabBar.rows(layout)
|
|
323
|
+
offset = bar_rows.zero? ? 0 : bar_rows + TAB_BAR_MARGIN_ROWS
|
|
324
|
+
tab = current_tab
|
|
325
|
+
tab.clamp_scroll(page_size)
|
|
245
326
|
padding = horizontal_padding
|
|
246
|
-
visible_lines.each_with_index do |line, row|
|
|
247
|
-
::Curses.setpos(row, padding)
|
|
248
|
-
render_line(line, highlight_ranges_for(
|
|
327
|
+
tab.visible_lines(page_size).each_with_index do |line, row|
|
|
328
|
+
::Curses.setpos(row + offset, padding)
|
|
329
|
+
render_line(line, tab.highlight_ranges_for(tab.scroll + row))
|
|
249
330
|
end
|
|
250
331
|
draw_status_bar
|
|
251
332
|
::Curses.refresh
|
|
252
333
|
end
|
|
253
334
|
|
|
254
335
|
def draw_status_bar
|
|
336
|
+
tab = current_tab
|
|
255
337
|
::Curses.setpos(::Curses.lines - 1, 0)
|
|
256
|
-
top =
|
|
257
|
-
bottom = [
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
338
|
+
top = tab.scroll + 1
|
|
339
|
+
bottom = [tab.scroll + page_size, tab.lines.size].min
|
|
340
|
+
tab_hint = (@tabs.size > 1) ? " [#{@current + 1}/#{@tabs.size}] Tab next" : ""
|
|
341
|
+
edit_hint = tab.file ? " e edit" : ""
|
|
342
|
+
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"
|
|
343
|
+
right_hint = @status_message || (tab.file_updated? ? " ● updated (r to reload) " : "")
|
|
344
|
+
available = [::Curses.cols - right_hint.length, 0].max
|
|
262
345
|
|
|
263
346
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
264
347
|
::Curses.addstr(status.ljust(available)[0, available])
|
|
265
348
|
end
|
|
266
|
-
unless
|
|
349
|
+
unless right_hint.empty?
|
|
267
350
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
|
|
268
|
-
::Curses.addstr(
|
|
351
|
+
::Curses.addstr(right_hint)
|
|
269
352
|
end
|
|
270
353
|
end
|
|
271
354
|
end
|
|
@@ -344,31 +427,23 @@ module Marvi
|
|
|
344
427
|
attr
|
|
345
428
|
end
|
|
346
429
|
|
|
347
|
-
def visible_lines
|
|
348
|
-
@lines[@scroll, page_size] || []
|
|
349
|
-
end
|
|
350
|
-
|
|
351
430
|
def page_size
|
|
352
|
-
[::Curses.lines - 1, 1].max
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def max_scroll
|
|
356
|
-
[@lines.size - page_size, 0].max
|
|
431
|
+
[::Curses.lines - 1 - tab_bar_height, 1].max
|
|
357
432
|
end
|
|
358
433
|
|
|
359
434
|
def scroll_by(delta)
|
|
360
|
-
|
|
435
|
+
current_tab.scroll_by(delta, page_size)
|
|
361
436
|
draw
|
|
362
437
|
end
|
|
363
438
|
|
|
364
|
-
# ---
|
|
439
|
+
# --- line-editing prompts (search and open) ---
|
|
365
440
|
|
|
366
441
|
BACKSPACE_KEYS = [127, 8].freeze
|
|
367
442
|
|
|
368
443
|
def start_search
|
|
369
444
|
@search_input = ""
|
|
370
445
|
update_search(@search_input)
|
|
371
|
-
|
|
446
|
+
draw_prompt("/#{@search_input}")
|
|
372
447
|
|
|
373
448
|
loop do
|
|
374
449
|
key = ::Curses.getch
|
|
@@ -376,118 +451,126 @@ module Marvi
|
|
|
376
451
|
|
|
377
452
|
case key
|
|
378
453
|
when 27 # ESC cancels and clears the search
|
|
379
|
-
clear_search
|
|
454
|
+
current_tab.clear_search
|
|
455
|
+
@search_input = nil
|
|
380
456
|
draw
|
|
381
457
|
return
|
|
382
458
|
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
383
|
-
commit_search
|
|
459
|
+
current_tab.commit_search
|
|
460
|
+
@search_input = nil
|
|
461
|
+
draw
|
|
384
462
|
return
|
|
385
463
|
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
386
464
|
@search_input = @search_input[0...-1] || ""
|
|
387
465
|
update_search(@search_input)
|
|
388
|
-
|
|
466
|
+
draw_prompt("/#{@search_input}")
|
|
389
467
|
else
|
|
390
468
|
next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
391
469
|
@search_input += key
|
|
392
470
|
update_search(@search_input)
|
|
393
|
-
|
|
471
|
+
draw_prompt("/#{@search_input}")
|
|
394
472
|
end
|
|
395
473
|
end
|
|
396
474
|
end
|
|
397
475
|
|
|
398
476
|
def update_search(query)
|
|
399
|
-
|
|
400
|
-
recompute_matches
|
|
401
|
-
focus_match_from(@scroll) unless @matches.empty?
|
|
477
|
+
current_tab.update_search(query, page_size)
|
|
402
478
|
draw
|
|
403
479
|
end
|
|
404
480
|
|
|
405
|
-
def
|
|
406
|
-
|
|
407
|
-
clear_search
|
|
408
|
-
end
|
|
409
|
-
@search_input = nil
|
|
481
|
+
def jump_match(direction)
|
|
482
|
+
current_tab.jump_match(direction, page_size)
|
|
410
483
|
draw
|
|
411
484
|
end
|
|
412
485
|
|
|
413
|
-
def
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
@current_match = nil
|
|
418
|
-
end
|
|
486
|
+
def start_open
|
|
487
|
+
picker = FilePicker.new
|
|
488
|
+
input = ""
|
|
489
|
+
selected = 0
|
|
419
490
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return if needle.empty?
|
|
491
|
+
loop do
|
|
492
|
+
candidates = picker.candidates(input)
|
|
493
|
+
selected = candidates.empty? ? 0 : selected % candidates.size
|
|
494
|
+
draw_picker(input, candidates, selected)
|
|
425
495
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
next if haystack.empty?
|
|
496
|
+
key = ::Curses.getch
|
|
497
|
+
next if key.nil? || key == -1
|
|
429
498
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
499
|
+
case key
|
|
500
|
+
when 27 # ESC cancels
|
|
501
|
+
draw
|
|
502
|
+
return
|
|
503
|
+
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
504
|
+
choice = candidates[selected] || input.strip
|
|
505
|
+
if choice.empty?
|
|
506
|
+
draw
|
|
507
|
+
return
|
|
508
|
+
elsif choice.end_with?("/")
|
|
509
|
+
# descend into the selected directory and keep picking
|
|
510
|
+
input = choice
|
|
511
|
+
selected = 0
|
|
512
|
+
else
|
|
513
|
+
open_tab(choice)
|
|
514
|
+
return
|
|
515
|
+
end
|
|
516
|
+
when "\t", TAB_KEY
|
|
517
|
+
if picker.path_mode?(input)
|
|
518
|
+
input = picker.complete(input)
|
|
519
|
+
else
|
|
520
|
+
selected += 1
|
|
521
|
+
end
|
|
522
|
+
when ::Curses::Key::BTAB
|
|
523
|
+
selected -= 1
|
|
524
|
+
when CTRL_N, ::Curses::Key::DOWN
|
|
525
|
+
selected += 1
|
|
526
|
+
when CTRL_P, ::Curses::Key::UP
|
|
527
|
+
selected -= 1
|
|
528
|
+
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
529
|
+
input = input[0...-1] || ""
|
|
530
|
+
selected = 0
|
|
531
|
+
else
|
|
532
|
+
if key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
533
|
+
input += key
|
|
534
|
+
selected = 0
|
|
535
|
+
end
|
|
434
536
|
end
|
|
435
537
|
end
|
|
436
538
|
end
|
|
437
539
|
|
|
438
|
-
def
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
@current_match = index % @matches.size
|
|
447
|
-
ensure_match_visible(@matches[@current_match][0])
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
def ensure_match_visible(line_index)
|
|
451
|
-
if line_index < @scroll || line_index >= @scroll + page_size
|
|
452
|
-
@scroll = [line_index, max_scroll].min
|
|
540
|
+
def draw_picker(input, candidates, selected)
|
|
541
|
+
::Curses.clear
|
|
542
|
+
width = ::Curses.cols
|
|
543
|
+
position = candidates.empty? ? 0 : selected + 1
|
|
544
|
+
header = " open file (#{position}/#{candidates.size}) C-n/C-p select Enter open ESC cancel"
|
|
545
|
+
::Curses.setpos(0, 0)
|
|
546
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan]) | ::Curses::A_BOLD) do
|
|
547
|
+
::Curses.addstr(header.ljust(width)[0, width])
|
|
453
548
|
end
|
|
454
|
-
end
|
|
455
549
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
550
|
+
list_rows = [::Curses.lines - 2, 1].max
|
|
551
|
+
offset = (selected < list_rows) ? 0 : selected - list_rows + 1
|
|
552
|
+
(candidates[offset, list_rows] || []).each_with_index do |candidate, i|
|
|
553
|
+
::Curses.setpos(1 + i, 0)
|
|
554
|
+
text = " #{candidate} ".ljust(width)[0, width]
|
|
555
|
+
if offset + i == selected
|
|
556
|
+
::Curses.attron(::Curses::A_REVERSE) { ::Curses.addstr(text) }
|
|
557
|
+
else
|
|
558
|
+
::Curses.addstr(text)
|
|
559
|
+
end
|
|
560
|
+
rescue ::Curses::Error
|
|
561
|
+
# ignore write errors at screen edge
|
|
462
562
|
end
|
|
463
|
-
draw
|
|
464
|
-
end
|
|
465
563
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def highlight_ranges_for(line_index)
|
|
473
|
-
return nil if @matches.empty?
|
|
474
|
-
ranges = []
|
|
475
|
-
@matches.each_with_index do |(li, start, finish), i|
|
|
476
|
-
ranges << [start, finish, i == @current_match] if li == line_index
|
|
564
|
+
prompt = "open: #{input}"
|
|
565
|
+
::Curses.setpos(::Curses.lines - 1, 0)
|
|
566
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
567
|
+
::Curses.addstr(prompt.ljust(width)[0, width])
|
|
477
568
|
end
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def search_hint
|
|
482
|
-
return " n/N next/prev" if @search_query.nil? || @search_query.empty?
|
|
483
|
-
return " no matches: #{@search_query}" if @matches.empty?
|
|
484
|
-
|
|
485
|
-
position = @current_match ? @current_match + 1 : 0
|
|
486
|
-
" [#{position}/#{@matches.size}] n/N"
|
|
569
|
+
::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
|
|
570
|
+
::Curses.refresh
|
|
487
571
|
end
|
|
488
572
|
|
|
489
|
-
def
|
|
490
|
-
prompt = "/#{@search_input}"
|
|
573
|
+
def draw_prompt(prompt)
|
|
491
574
|
::Curses.setpos(::Curses.lines - 1, 0)
|
|
492
575
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
493
576
|
::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
|
|
@@ -498,3 +581,7 @@ module Marvi
|
|
|
498
581
|
end
|
|
499
582
|
end
|
|
500
583
|
end
|
|
584
|
+
|
|
585
|
+
require_relative "curses/tab"
|
|
586
|
+
require_relative "curses/tab_bar"
|
|
587
|
+
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.1
|
|
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
|