marvi 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5131e252ac808321286aa802bdd386e5b3bb9b494dda00df1761ef9700088a4e
4
- data.tar.gz: 2e9bccb531018e22014e43053b6a05d081e60a422c9107966c54739829cf528d
3
+ metadata.gz: b0110473458c01229add5274f0ab0df77dad55587286ee01c426612fa0505707
4
+ data.tar.gz: 8e59b8386b4fb283613a2fc81d2841473ccd78eaa216a4608e10c17ae62c61b0
5
5
  SHA512:
6
- metadata.gz: d87aa8d7de7acd2414e5301416fd09b09d1f33e48f64c93c86b5d0b7fc833e20102c297e89a77396cdcb4176269493459e835a6ab9df2a677d6d112ad9bbb3ed
7
- data.tar.gz: 673ca0703a768010c6116fa4cae6b29c723b439a50e7591c3a7ac7f9928e614eda42f2ef2fda7f377ef7453babd37b729786579ef20479b24107f800be453c59
6
+ metadata.gz: 3abaf2190555600e5e04cd0a04d7176d6ef5b8384afc902cff76389ec782a448bd99609cf2474d7f62827bc24750514b7517cf780b61739c327a665bb3cc9821
7
+ data.tar.gz: 2caddfb8de48b8fc5f6c894f839d06239b57845993a67f576f8cde2cb6591509d3167dbe35d11636e1c03f5cb140e2c16cfeec945bf8204eeb4095f3a31c8bcb
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook: run standardrb on the edited file and block the turn
3
+ # when violations remain so Claude must fix them before continuing.
4
+
5
+ set -u
6
+
7
+ payload="$(cat)"
8
+ file_path=$(printf '%s' "$payload" | jq -r '.tool_response.filePath // .tool_input.file_path // empty')
9
+ [ -z "$file_path" ] && exit 0
10
+
11
+ case "$file_path" in
12
+ "$PWD"/*.rb | "$PWD"/*.gemspec | "$PWD"/Rakefile | "$PWD"/Gemfile) ;;
13
+ *) exit 0 ;;
14
+ esac
15
+
16
+ output=$(bundle exec standardrb "$file_path" 2>&1)
17
+ if [ $? -ne 0 ]; then
18
+ reason="standardrb violations in $file_path. Fix manually or run \`bundle exec rake standard:fix\`:
19
+
20
+ $output"
21
+ jq -n --arg reason "$reason" '{decision:"block",reason:$reason}'
22
+ fi
23
+ exit 0
@@ -0,0 +1,17 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Write|Edit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": ".claude/hooks/standardrb-check.sh",
10
+ "timeout": 30,
11
+ "statusMessage": "Running standardrb"
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-18
4
+
5
+ - Bind `Ctrl-D` / `Ctrl-U` for vim-style half-page scrolling in the curses pager.
6
+
7
+ ## [0.3.0] - 2026-05-18
8
+
9
+ - Render tables correctly when cells contain East Asian wide characters and emoji (uses `unicode-display_width`).
10
+ - Wrap long table cells to fit the terminal width so borders no longer break across physical lines; curses pager re-flows on window resize.
11
+
3
12
  ## [0.1.0] - 2026-03-17
4
13
 
5
14
  - Initial release
data/lib/marvi/ansi.rb CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Marvi
4
4
  module ANSI
5
- RESET = "\e[0m"
6
- BOLD = "\e[1m"
7
- ITALIC = "\e[3m"
8
- CYAN = "\e[36m"
9
- YELLOW = "\e[33m"
10
- GREEN = "\e[32m"
5
+ RESET = "\e[0m"
6
+ BOLD = "\e[1m"
7
+ ITALIC = "\e[3m"
8
+ CYAN = "\e[36m"
9
+ YELLOW = "\e[33m"
10
+ GREEN = "\e[32m"
11
11
  MAGENTA = "\e[35m"
12
- WHITE = "\e[37m"
12
+ WHITE = "\e[37m"
13
13
  BG_DARK = "\e[48;5;236m"
14
14
 
15
15
  HEADER_COLORS = [CYAN, GREEN, YELLOW, MAGENTA, WHITE, WHITE].freeze
@@ -2,12 +2,17 @@
2
2
 
3
3
  require "kramdown"
4
4
  require "kramdown-parser-gfm"
5
+ require "unicode/display_width"
5
6
 
6
7
  module Marvi
7
8
  class ASTWalker
8
9
  HEADER_COLORS = %i[cyan green yellow magenta white white].freeze
9
10
 
10
- def walk(markdown)
11
+ DEFAULT_MAX_WIDTH = 80
12
+ MIN_COL_WIDTH = 4
13
+
14
+ def walk(markdown, max_width: nil)
15
+ @max_width = max_width || Integer(ENV["COLUMNS"] || DEFAULT_MAX_WIDTH)
11
16
  doc = Kramdown::Document.new(markdown, input: "GFM")
12
17
  lines = render_block(doc.root)
13
18
  lines.pop while lines.last&.plain_text&.empty?
@@ -52,7 +57,7 @@ module Marvi
52
57
  def render_header(el)
53
58
  level = el.options[:level]
54
59
  color = HEADER_COLORS[level - 1]
55
- src = el.options[:location]
60
+ src = el.options[:location]
56
61
  prefix = Span.new(text: "#" * level + " ", bold: true, color: color)
57
62
  content = render_inline_children(el).map do |s|
58
63
  Span.new(text: s.text, bold: true, italic: s.italic, color: s.color || color, bg_color: s.bg_color)
@@ -61,10 +66,10 @@ module Marvi
61
66
  end
62
67
 
63
68
  def render_li(el, indent:, list_type:, list_index:)
64
- bullet = list_type == :ol ? "#{list_index}." : "•"
69
+ bullet = (list_type == :ol) ? "#{list_index}." : "•"
65
70
  prefix = Span.new(text: "#{" " * indent}#{bullet} ", color: :cyan)
66
- src = el.options[:location]
67
- lines = []
71
+ src = el.options[:location]
72
+ lines = []
68
73
 
69
74
  el.children.each do |child|
70
75
  case child.type
@@ -79,10 +84,10 @@ module Marvi
79
84
  lines += render_block(child)
80
85
  end
81
86
  else
82
- if lines.empty?
83
- lines << RichLine.new([prefix] + render_inline(child), source_line: src)
87
+ lines << if lines.empty?
88
+ RichLine.new([prefix] + render_inline(child), source_line: src)
84
89
  else
85
- lines << RichLine.new(render_inline(child))
90
+ RichLine.new(render_inline(child))
86
91
  end
87
92
  end
88
93
  end
@@ -90,12 +95,14 @@ module Marvi
90
95
  end
91
96
 
92
97
  def render_codeblock(el)
93
- src = el.options[:location]
98
+ src = el.options[:location]
94
99
  lang = el.options[:lang]
95
100
  lines = []
96
101
  lines << RichLine.new([Span.new(text: lang, color: :yellow)], source_line: src) if lang
97
102
  el.value.chomp.split("\n").each_with_index do |line, i|
98
- line_src = src ? src + i + (lang ? 1 : 0) : nil
103
+ line_src = if src
104
+ src + i + (lang ? 1 : 0)
105
+ end
99
106
  lines << RichLine.new([Span.new(text: " #{line}", color: :green, bg_color: :dark)], source_line: line_src)
100
107
  end
101
108
  lines << RichLine.blank
@@ -103,20 +110,21 @@ module Marvi
103
110
  end
104
111
 
105
112
  def render_blockquote(el)
106
- inner = el.children.flat_map { |child| render_block(child) }
113
+ inner = el.children.flat_map { |child| render_block(child) }
107
114
  prefix = Span.new(text: "│ ", color: :cyan)
108
115
  # preserve source_line from inner lines
109
116
  inner.map { |line| RichLine.new([prefix] + line.spans, source_line: line.source_line) } + [RichLine.blank]
110
117
  end
111
118
 
112
119
  def render_table(el)
113
- src = el.options[:location]
120
+ src = el.options[:location]
114
121
  rows = el.children.flat_map(&:children)
115
122
  header_row = el.children.find { |s| s.type == :thead }&.children&.first
116
123
 
117
124
  cell_spans = rows.map { |row| row.children.map { |cell| render_inline_children(cell) } }
118
- col_widths = cell_spans.map { |row| row.map { |spans| spans.sum { |s| s.text.length } } }
125
+ natural_widths = cell_spans.map { |row| row.map { |spans| spans_display_width(spans) } }
119
126
  .transpose.map { |col| col.max }
127
+ col_widths = shrink_col_widths(natural_widths, @max_width)
120
128
 
121
129
  lines = []
122
130
  top = col_widths.map { |w| "─" * (w + 2) }.join("┬")
@@ -124,16 +132,23 @@ module Marvi
124
132
 
125
133
  rows.each_with_index do |row, ri|
126
134
  is_header = row == header_row
127
- row_spans = []
128
- row.children.each_with_index do |cell, ci|
135
+ wrapped = row.children.each_with_index.map do |_cell, ci|
129
136
  content = cell_spans[ri][ci]
130
- plain_len = content.sum { |s| s.text.length }
131
- padding = col_widths[ci] - plain_len
132
- styled = is_header ? content.map { |s| Span.new(text: s.text, bold: true, color: :cyan) } : content
133
- row_spans += [Span.new(text: "│ ", color: :cyan)] + styled + [Span.new(text: " " * (padding + 1))]
137
+ styled = is_header ? content.map { |s| Span.new(text: s.text, bold: true, italic: s.italic, color: :cyan, bg_color: s.bg_color) } : content
138
+ wrap_spans(styled, col_widths[ci])
139
+ end
140
+ sub_row_count = wrapped.map(&:size).max
141
+ sub_row_count.times do |j|
142
+ row_spans = []
143
+ wrapped.each_with_index do |cell_lines, ci|
144
+ sub_spans = cell_lines[j] || []
145
+ sub_len = spans_display_width(sub_spans)
146
+ padding = col_widths[ci] - sub_len
147
+ row_spans += [Span.new(text: "│ ", color: :cyan)] + sub_spans + [Span.new(text: " " * (padding + 1))]
148
+ end
149
+ row_spans << Span.new(text: "│", color: :cyan)
150
+ lines << RichLine.new(row_spans)
134
151
  end
135
- row_spans << Span.new(text: "│", color: :cyan)
136
- lines << RichLine.new(row_spans)
137
152
 
138
153
  if is_header
139
154
  sep = col_widths.map { |w| "─" * (w + 2) }.join("┼")
@@ -147,6 +162,63 @@ module Marvi
147
162
  lines
148
163
  end
149
164
 
165
+ def shrink_col_widths(widths, max_width)
166
+ budget = max_width - (3 * widths.size + 1)
167
+ total = widths.sum
168
+ return widths if total <= budget
169
+
170
+ shrunk = widths.dup
171
+ while total > budget
172
+ max_w, i = shrunk.each_with_index.max_by { |w, _| w }
173
+ break if max_w <= MIN_COL_WIDTH
174
+ shrunk[i] -= 1
175
+ total -= 1
176
+ end
177
+ shrunk
178
+ end
179
+
180
+ def wrap_spans(spans, width)
181
+ lines = [[]]
182
+ current_width = 0
183
+ spans.each do |span|
184
+ text = span.text.dup
185
+ until text.empty?
186
+ remaining = width - current_width
187
+ if remaining <= 0
188
+ lines << []
189
+ current_width = 0
190
+ remaining = width
191
+ end
192
+ taken = 0
193
+ chunk_width = 0
194
+ text.each_char do |c|
195
+ cw = Unicode::DisplayWidth.of(c)
196
+ break if chunk_width + cw > remaining
197
+ chunk_width += cw
198
+ taken += c.bytesize
199
+ end
200
+ if taken.zero?
201
+ first_char = text.each_char.first
202
+ taken = first_char.bytesize
203
+ chunk_width = Unicode::DisplayWidth.of(first_char)
204
+ end
205
+ chunk = text.byteslice(0, taken)
206
+ text = text.byteslice(taken..) || ""
207
+ lines.last << Span.new(text: chunk, bold: span.bold, italic: span.italic, color: span.color, bg_color: span.bg_color)
208
+ current_width += chunk_width
209
+ unless text.empty?
210
+ lines << []
211
+ current_width = 0
212
+ end
213
+ end
214
+ end
215
+ lines
216
+ end
217
+
218
+ def spans_display_width(spans)
219
+ spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
220
+ end
221
+
150
222
  def render_inline_children(el)
151
223
  el.children.flat_map { |child| render_inline(child) }
152
224
  end
@@ -1,23 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/console"
4
+
3
5
  module Marvi
4
6
  module Renderer
5
7
  class ANSI
6
8
  COLOR_MAP = {
7
- cyan: Marvi::ANSI::CYAN,
8
- green: Marvi::ANSI::GREEN,
9
- yellow: Marvi::ANSI::YELLOW,
9
+ cyan: Marvi::ANSI::CYAN,
10
+ green: Marvi::ANSI::GREEN,
11
+ yellow: Marvi::ANSI::YELLOW,
10
12
  magenta: Marvi::ANSI::MAGENTA,
11
- white: Marvi::ANSI::WHITE
13
+ white: Marvi::ANSI::WHITE
12
14
  }.freeze
13
15
 
14
16
  def render(markdown)
15
- lines = ASTWalker.new.walk(markdown)
17
+ lines = ASTWalker.new.walk(markdown, max_width: terminal_width)
16
18
  lines.map { |line| render_line(line) }.join("\n") + "\n"
17
19
  end
18
20
 
19
21
  private
20
22
 
23
+ def terminal_width
24
+ IO.console&.winsize&.last || Integer(ENV["COLUMNS"] || ASTWalker::DEFAULT_MAX_WIDTH)
25
+ rescue
26
+ ASTWalker::DEFAULT_MAX_WIDTH
27
+ end
28
+
21
29
  def render_line(line)
22
30
  line.spans.map { |span| render_span(span) }.join
23
31
  end
@@ -7,27 +7,30 @@ module Marvi
7
7
  module Renderer
8
8
  class Curses
9
9
  COLOR_PAIRS = {
10
- cyan: 1,
11
- green: 2,
12
- yellow: 3,
13
- magenta: 4,
14
- white: 5,
10
+ cyan: 1,
11
+ green: 2,
12
+ yellow: 3,
13
+ magenta: 4,
14
+ white: 5,
15
15
  green_on_dark: 6,
16
- cyan_on_dark: 7
16
+ cyan_on_dark: 7
17
17
  }.freeze
18
18
 
19
19
  ITALIC_ATTR = (defined?(::Curses::A_ITALIC) ? ::Curses::A_ITALIC : 0)
20
20
 
21
21
  FILE_POLL_INTERVAL_MS = 500
22
22
 
23
+ CTRL_D = 4
24
+ CTRL_U = 21
25
+
23
26
  def render(markdown, file: nil)
24
- @file = file
27
+ @file = file
25
28
  @markdown = markdown
26
- @lines = ASTWalker.new.walk(markdown)
27
- @scroll = 0
29
+ @scroll = 0
28
30
  mark_reloaded
29
31
 
30
32
  init_curses_state
33
+ rewalk
31
34
  draw
32
35
 
33
36
  catch(:quit) do
@@ -84,36 +87,49 @@ module Marvi
84
87
 
85
88
  output = IO.popen(["infocmp", "-1", term, err: File::NULL], &:read)
86
89
  @infocmp_cache[term] = $?.success? ? output : nil
87
- rescue StandardError
90
+ rescue
88
91
  @infocmp_cache[term] = nil
89
92
  end
90
93
 
91
94
  def setup_colors
92
- ::Curses.init_pair(COLOR_PAIRS[:cyan], ::Curses::COLOR_CYAN, -1)
93
- ::Curses.init_pair(COLOR_PAIRS[:green], ::Curses::COLOR_GREEN, -1)
94
- ::Curses.init_pair(COLOR_PAIRS[:yellow], ::Curses::COLOR_YELLOW, -1)
95
- ::Curses.init_pair(COLOR_PAIRS[:magenta], ::Curses::COLOR_MAGENTA, -1)
96
- ::Curses.init_pair(COLOR_PAIRS[:white], ::Curses::COLOR_WHITE, -1)
97
- ::Curses.init_pair(COLOR_PAIRS[:green_on_dark], ::Curses::COLOR_GREEN, ::Curses::COLOR_BLACK)
98
- ::Curses.init_pair(COLOR_PAIRS[:cyan_on_dark], ::Curses::COLOR_CYAN, ::Curses::COLOR_BLACK)
95
+ ::Curses.init_pair(COLOR_PAIRS[:cyan], ::Curses::COLOR_CYAN, -1)
96
+ ::Curses.init_pair(COLOR_PAIRS[:green], ::Curses::COLOR_GREEN, -1)
97
+ ::Curses.init_pair(COLOR_PAIRS[:yellow], ::Curses::COLOR_YELLOW, -1)
98
+ ::Curses.init_pair(COLOR_PAIRS[:magenta], ::Curses::COLOR_MAGENTA, -1)
99
+ ::Curses.init_pair(COLOR_PAIRS[:white], ::Curses::COLOR_WHITE, -1)
100
+ ::Curses.init_pair(COLOR_PAIRS[:green_on_dark], ::Curses::COLOR_GREEN, ::Curses::COLOR_BLACK)
101
+ ::Curses.init_pair(COLOR_PAIRS[:cyan_on_dark], ::Curses::COLOR_CYAN, ::Curses::COLOR_BLACK)
99
102
  end
100
103
 
101
104
  def handle_key(key)
102
105
  case key
103
- when "q", "Q", 27 then throw :quit
104
- when "j", ::Curses::Key::DOWN then scroll_by(1)
105
- when "k", ::Curses::Key::UP then scroll_by(-1)
106
- when "d" then scroll_by(page_size / 2)
107
- when "u" then scroll_by(-page_size / 2)
106
+ when "q", "Q", 27 then throw :quit
107
+ when "j", ::Curses::Key::DOWN then scroll_by(1)
108
+ when "k", ::Curses::Key::UP then scroll_by(-1)
109
+ when "d", CTRL_D then scroll_by(page_size / 2)
110
+ when "u", CTRL_U then scroll_by(-page_size / 2)
108
111
  when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
109
- when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
110
- when "g" then @scroll = 0; draw
111
- when "G" then @scroll = max_scroll; draw
112
- when "e" then launch_editor if @file
113
- when "r", "R" then reload_from_key if @file
112
+ when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
113
+ when "g" then @scroll = 0
114
+ draw
115
+ when "G" then @scroll = max_scroll
116
+ draw
117
+ when "e" then launch_editor if @file
118
+ when "r", "R" then reload_from_key if @file
119
+ when ::Curses::Key::RESIZE then handle_resize
114
120
  end
115
121
  end
116
122
 
123
+ def handle_resize
124
+ rewalk
125
+ @scroll = [@scroll, max_scroll].min
126
+ draw
127
+ end
128
+
129
+ def rewalk
130
+ @lines = ASTWalker.new.walk(@markdown, max_width: ::Curses.cols)
131
+ end
132
+
117
133
  def reload_from_key
118
134
  reload
119
135
  mark_reloaded
@@ -134,7 +150,7 @@ module Marvi
134
150
  end
135
151
 
136
152
  def mark_reloaded
137
- @last_mtime = current_mtime
153
+ @last_mtime = current_mtime
138
154
  @file_updated = false
139
155
  end
140
156
 
@@ -147,21 +163,21 @@ module Marvi
147
163
 
148
164
  def launch_editor
149
165
  editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
150
- line = current_source_line
151
- cmd = build_editor_command(editor, @file, line)
166
+ line = current_source_line
167
+ cmd = build_editor_command(editor, @file, line)
152
168
 
153
169
  ::Curses.close_screen
154
170
  system(cmd)
171
+ init_curses_state
155
172
  reload
156
173
  mark_reloaded
157
- init_curses_state
158
174
  draw
159
175
  end
160
176
 
161
177
  def reload
162
178
  @markdown = File.read(@file)
163
- @lines = ASTWalker.new.walk(@markdown)
164
- @scroll = [@scroll, max_scroll].min
179
+ rewalk
180
+ @scroll = [@scroll, max_scroll].min
165
181
  end
166
182
 
167
183
  def init_curses_state
@@ -208,7 +224,7 @@ module Marvi
208
224
 
209
225
  def draw_status_bar
210
226
  ::Curses.setpos(::Curses.lines - 1, 0)
211
- top = @scroll + 1
227
+ top = @scroll + 1
212
228
  bottom = [@scroll + page_size, @lines.size].min
213
229
  edit_hint = @file ? " e edit" : ""
214
230
  status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom#{edit_hint} q quit"
@@ -245,10 +261,10 @@ module Marvi
245
261
  def build_attr(span)
246
262
  attr = 0
247
263
  attr |= ::Curses::A_BOLD if span.bold
248
- attr |= ITALIC_ATTR if span.italic
264
+ attr |= ITALIC_ATTR if span.italic
249
265
 
250
266
  pair_key = if span.bg_color == :dark
251
- span.color == :cyan ? :cyan_on_dark : :green_on_dark
267
+ (span.color == :cyan) ? :cyan_on_dark : :green_on_dark
252
268
  elsif span.color
253
269
  span.color
254
270
  end
@@ -270,7 +286,7 @@ module Marvi
270
286
  end
271
287
 
272
288
  def scroll_by(delta)
273
- @scroll = [[@scroll + delta, 0].max, max_scroll].min
289
+ @scroll = (@scroll + delta).clamp(0, max_scroll)
274
290
  draw
275
291
  end
276
292
  end
data/lib/marvi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Marvi
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mitsutaka Mimura
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: unicode-display_width
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
54
68
  description: Renders Markdown with ANSI colors in pipes and an interactive curses
55
69
  pager in TTY.
56
70
  email:
@@ -60,6 +74,8 @@ executables:
60
74
  extensions: []
61
75
  extra_rdoc_files: []
62
76
  files:
77
+ - ".claude/hooks/standardrb-check.sh"
78
+ - ".claude/settings.json"
63
79
  - CHANGELOG.md
64
80
  - CODE_OF_CONDUCT.md
65
81
  - LICENSE.txt