marvi 0.7.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 +5 -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 +242 -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: 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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## [0.7.0] - 2026-06-15
|
|
4
9
|
|
|
5
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.
|
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,30 +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
|
|
37
48
|
@search_input = nil
|
|
38
|
-
@
|
|
39
|
-
@current_match = nil
|
|
40
|
-
mark_reloaded
|
|
49
|
+
@status_message = nil
|
|
41
50
|
|
|
42
51
|
init_curses_state
|
|
43
|
-
|
|
52
|
+
current_tab.ensure_walked(content_width, page_size)
|
|
44
53
|
draw
|
|
45
54
|
|
|
46
55
|
catch(:quit) do
|
|
47
56
|
loop do
|
|
48
57
|
key = ::Curses.getch
|
|
49
58
|
if key.nil? || key == -1
|
|
50
|
-
|
|
59
|
+
poll_files
|
|
51
60
|
else
|
|
52
61
|
handle_key(key)
|
|
53
62
|
end
|
|
@@ -57,7 +66,9 @@ module Marvi
|
|
|
57
66
|
::Curses.close_screen
|
|
58
67
|
end
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
def current_tab
|
|
70
|
+
@tabs[@current]
|
|
71
|
+
end
|
|
61
72
|
|
|
62
73
|
# ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
|
|
63
74
|
# identical glyphs, but ghostty mishandles it and drops the run from the
|
|
@@ -115,6 +126,7 @@ module Marvi
|
|
|
115
126
|
end
|
|
116
127
|
|
|
117
128
|
def handle_key(key)
|
|
129
|
+
@status_message = nil
|
|
118
130
|
case key
|
|
119
131
|
when "q", "Q", 27 then throw :quit
|
|
120
132
|
when "j", ::Curses::Key::DOWN then scroll_by(1)
|
|
@@ -123,28 +135,119 @@ module Marvi
|
|
|
123
135
|
when "u", CTRL_U then scroll_by(-page_size / 2)
|
|
124
136
|
when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
|
|
125
137
|
when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
|
|
126
|
-
when "g" then
|
|
138
|
+
when "g" then current_tab.scroll = 0
|
|
127
139
|
draw
|
|
128
|
-
when "G" then
|
|
140
|
+
when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
|
|
129
141
|
draw
|
|
130
142
|
when "/" then start_search
|
|
131
143
|
when "n" then jump_match(1)
|
|
132
144
|
when "N" then jump_match(-1)
|
|
133
|
-
when "e" then launch_editor if
|
|
134
|
-
when "r", "R" then reload_from_key if
|
|
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
|
|
135
153
|
when ::Curses::Key::RESIZE then handle_resize
|
|
136
154
|
end
|
|
137
155
|
end
|
|
138
156
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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)
|
|
143
170
|
draw
|
|
144
171
|
end
|
|
145
172
|
|
|
146
|
-
def
|
|
147
|
-
|
|
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
|
|
148
251
|
end
|
|
149
252
|
|
|
150
253
|
def horizontal_padding
|
|
@@ -158,56 +261,28 @@ module Marvi
|
|
|
158
261
|
end
|
|
159
262
|
|
|
160
263
|
def reload_from_key
|
|
161
|
-
reload
|
|
162
|
-
mark_reloaded
|
|
264
|
+
current_tab.reload(content_width, page_size)
|
|
163
265
|
draw
|
|
164
266
|
end
|
|
165
267
|
|
|
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
|
|
268
|
+
def poll_files
|
|
269
|
+
updated = @tabs.select(&:check_file_updated)
|
|
270
|
+
draw unless updated.empty?
|
|
189
271
|
end
|
|
190
272
|
|
|
191
273
|
def launch_editor
|
|
274
|
+
tab = current_tab
|
|
192
275
|
editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
|
|
193
|
-
line = current_source_line
|
|
194
|
-
cmd = build_editor_command(editor,
|
|
276
|
+
line = tab.current_source_line(page_size)
|
|
277
|
+
cmd = build_editor_command(editor, tab.file, line)
|
|
195
278
|
|
|
196
279
|
::Curses.close_screen
|
|
197
280
|
system(cmd)
|
|
198
281
|
init_curses_state
|
|
199
|
-
reload
|
|
200
|
-
mark_reloaded
|
|
282
|
+
tab.reload(content_width, page_size)
|
|
201
283
|
draw
|
|
202
284
|
end
|
|
203
285
|
|
|
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
286
|
def init_curses_state
|
|
212
287
|
with_safe_term { ::Curses.init_screen }
|
|
213
288
|
::Curses.start_color
|
|
@@ -217,6 +292,7 @@ module Marvi
|
|
|
217
292
|
::Curses.stdscr.keypad(true)
|
|
218
293
|
::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
|
|
219
294
|
setup_colors
|
|
295
|
+
update_mousemask
|
|
220
296
|
end
|
|
221
297
|
|
|
222
298
|
def build_editor_command(editor, file, line)
|
|
@@ -233,39 +309,39 @@ module Marvi
|
|
|
233
309
|
end
|
|
234
310
|
end
|
|
235
311
|
|
|
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
312
|
def draw
|
|
244
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)
|
|
245
319
|
padding = horizontal_padding
|
|
246
|
-
visible_lines.each_with_index do |line, row|
|
|
247
|
-
::Curses.setpos(row, padding)
|
|
248
|
-
render_line(line, highlight_ranges_for(
|
|
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))
|
|
249
323
|
end
|
|
250
324
|
draw_status_bar
|
|
251
325
|
::Curses.refresh
|
|
252
326
|
end
|
|
253
327
|
|
|
254
328
|
def draw_status_bar
|
|
329
|
+
tab = current_tab
|
|
255
330
|
::Curses.setpos(::Curses.lines - 1, 0)
|
|
256
|
-
top =
|
|
257
|
-
bottom = [
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
262
338
|
|
|
263
339
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
264
340
|
::Curses.addstr(status.ljust(available)[0, available])
|
|
265
341
|
end
|
|
266
|
-
unless
|
|
342
|
+
unless right_hint.empty?
|
|
267
343
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
|
|
268
|
-
::Curses.addstr(
|
|
344
|
+
::Curses.addstr(right_hint)
|
|
269
345
|
end
|
|
270
346
|
end
|
|
271
347
|
end
|
|
@@ -344,31 +420,23 @@ module Marvi
|
|
|
344
420
|
attr
|
|
345
421
|
end
|
|
346
422
|
|
|
347
|
-
def visible_lines
|
|
348
|
-
@lines[@scroll, page_size] || []
|
|
349
|
-
end
|
|
350
|
-
|
|
351
423
|
def page_size
|
|
352
|
-
[::Curses.lines - 1, 1].max
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
def max_scroll
|
|
356
|
-
[@lines.size - page_size, 0].max
|
|
424
|
+
[::Curses.lines - 1 - tab_bar_height, 1].max
|
|
357
425
|
end
|
|
358
426
|
|
|
359
427
|
def scroll_by(delta)
|
|
360
|
-
|
|
428
|
+
current_tab.scroll_by(delta, page_size)
|
|
361
429
|
draw
|
|
362
430
|
end
|
|
363
431
|
|
|
364
|
-
# ---
|
|
432
|
+
# --- line-editing prompts (search and open) ---
|
|
365
433
|
|
|
366
434
|
BACKSPACE_KEYS = [127, 8].freeze
|
|
367
435
|
|
|
368
436
|
def start_search
|
|
369
437
|
@search_input = ""
|
|
370
438
|
update_search(@search_input)
|
|
371
|
-
|
|
439
|
+
draw_prompt("/#{@search_input}")
|
|
372
440
|
|
|
373
441
|
loop do
|
|
374
442
|
key = ::Curses.getch
|
|
@@ -376,118 +444,126 @@ module Marvi
|
|
|
376
444
|
|
|
377
445
|
case key
|
|
378
446
|
when 27 # ESC cancels and clears the search
|
|
379
|
-
clear_search
|
|
447
|
+
current_tab.clear_search
|
|
448
|
+
@search_input = nil
|
|
380
449
|
draw
|
|
381
450
|
return
|
|
382
451
|
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
383
|
-
commit_search
|
|
452
|
+
current_tab.commit_search
|
|
453
|
+
@search_input = nil
|
|
454
|
+
draw
|
|
384
455
|
return
|
|
385
456
|
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
386
457
|
@search_input = @search_input[0...-1] || ""
|
|
387
458
|
update_search(@search_input)
|
|
388
|
-
|
|
459
|
+
draw_prompt("/#{@search_input}")
|
|
389
460
|
else
|
|
390
461
|
next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
391
462
|
@search_input += key
|
|
392
463
|
update_search(@search_input)
|
|
393
|
-
|
|
464
|
+
draw_prompt("/#{@search_input}")
|
|
394
465
|
end
|
|
395
466
|
end
|
|
396
467
|
end
|
|
397
468
|
|
|
398
469
|
def update_search(query)
|
|
399
|
-
|
|
400
|
-
recompute_matches
|
|
401
|
-
focus_match_from(@scroll) unless @matches.empty?
|
|
470
|
+
current_tab.update_search(query, page_size)
|
|
402
471
|
draw
|
|
403
472
|
end
|
|
404
473
|
|
|
405
|
-
def
|
|
406
|
-
|
|
407
|
-
clear_search
|
|
408
|
-
end
|
|
409
|
-
@search_input = nil
|
|
474
|
+
def jump_match(direction)
|
|
475
|
+
current_tab.jump_match(direction, page_size)
|
|
410
476
|
draw
|
|
411
477
|
end
|
|
412
478
|
|
|
413
|
-
def
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
@current_match = nil
|
|
418
|
-
end
|
|
479
|
+
def start_open
|
|
480
|
+
picker = FilePicker.new
|
|
481
|
+
input = ""
|
|
482
|
+
selected = 0
|
|
419
483
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return if needle.empty?
|
|
484
|
+
loop do
|
|
485
|
+
candidates = picker.candidates(input)
|
|
486
|
+
selected = candidates.empty? ? 0 : selected % candidates.size
|
|
487
|
+
draw_picker(input, candidates, selected)
|
|
425
488
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
next if haystack.empty?
|
|
489
|
+
key = ::Curses.getch
|
|
490
|
+
next if key.nil? || key == -1
|
|
429
491
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
434
529
|
end
|
|
435
530
|
end
|
|
436
531
|
end
|
|
437
532
|
|
|
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
|
|
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])
|
|
453
541
|
end
|
|
454
|
-
end
|
|
455
542
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
462
555
|
end
|
|
463
|
-
draw
|
|
464
|
-
end
|
|
465
556
|
|
|
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
|
|
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])
|
|
477
561
|
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"
|
|
562
|
+
::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
|
|
563
|
+
::Curses.refresh
|
|
487
564
|
end
|
|
488
565
|
|
|
489
|
-
def
|
|
490
|
-
prompt = "/#{@search_input}"
|
|
566
|
+
def draw_prompt(prompt)
|
|
491
567
|
::Curses.setpos(::Curses.lines - 1, 0)
|
|
492
568
|
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
493
569
|
::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
|
|
@@ -498,3 +574,7 @@ module Marvi
|
|
|
498
574
|
end
|
|
499
575
|
end
|
|
500
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
|