marvi 0.2.0 → 0.3.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: c5df9bb69ae060cf562d656a78f4aa152223a4ad58178af6b6b18891e19cb525
4
+ data.tar.gz: 1590ce11f925c30f25e256e1e0f0571355a4a4fd7690cc636310d9bf20e349eb
5
5
  SHA512:
6
- metadata.gz: d87aa8d7de7acd2414e5301416fd09b09d1f33e48f64c93c86b5d0b7fc833e20102c297e89a77396cdcb4176269493459e835a6ab9df2a677d6d112ad9bbb3ed
7
- data.tar.gz: 673ca0703a768010c6116fa4cae6b29c723b439a50e7591c3a7ac7f9928e614eda42f2ef2fda7f377ef7453babd37b729786579ef20479b24107f800be453c59
6
+ metadata.gz: 1541589372fbdd04e4dbf7fe929de77788f7308c4abdf31ed8fea657712de058d1e38f7a48000c1122405b0c8a44f679b57946937ed8c779dcc81e2e7f4cfd10
7
+ data.tar.gz: 8637f420f782945d396f8dfa97ec3f4b748ffd056bb43baf23c6e6adbd54c6e8d9f8b3dbe896a1540faf46c6affa772cb7637bc929a9c9fe60e626c0420392e3
@@ -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,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-18
4
+
5
+ - Render tables correctly when cells contain East Asian wide characters and emoji (uses `unicode-display_width`).
6
+ - Wrap long table cells to fit the terminal width so borders no longer break across physical lines; curses pager re-flows on window resize.
7
+
3
8
  ## [0.1.0] - 2026-03-17
4
9
 
5
10
  - 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,13 +7,13 @@ 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)
@@ -21,13 +21,13 @@ module Marvi
21
21
  FILE_POLL_INTERVAL_MS = 500
22
22
 
23
23
  def render(markdown, file: nil)
24
- @file = file
24
+ @file = file
25
25
  @markdown = markdown
26
- @lines = ASTWalker.new.walk(markdown)
27
- @scroll = 0
26
+ @scroll = 0
28
27
  mark_reloaded
29
28
 
30
29
  init_curses_state
30
+ rewalk
31
31
  draw
32
32
 
33
33
  catch(:quit) do
@@ -84,36 +84,49 @@ module Marvi
84
84
 
85
85
  output = IO.popen(["infocmp", "-1", term, err: File::NULL], &:read)
86
86
  @infocmp_cache[term] = $?.success? ? output : nil
87
- rescue StandardError
87
+ rescue
88
88
  @infocmp_cache[term] = nil
89
89
  end
90
90
 
91
91
  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)
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)
99
99
  end
100
100
 
101
101
  def handle_key(key)
102
102
  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)
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)
108
108
  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
109
+ when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
110
+ when "g" then @scroll = 0
111
+ draw
112
+ when "G" then @scroll = max_scroll
113
+ draw
114
+ when "e" then launch_editor if @file
115
+ when "r", "R" then reload_from_key if @file
116
+ when ::Curses::Key::RESIZE then handle_resize
114
117
  end
115
118
  end
116
119
 
120
+ def handle_resize
121
+ rewalk
122
+ @scroll = [@scroll, max_scroll].min
123
+ draw
124
+ end
125
+
126
+ def rewalk
127
+ @lines = ASTWalker.new.walk(@markdown, max_width: ::Curses.cols)
128
+ end
129
+
117
130
  def reload_from_key
118
131
  reload
119
132
  mark_reloaded
@@ -134,7 +147,7 @@ module Marvi
134
147
  end
135
148
 
136
149
  def mark_reloaded
137
- @last_mtime = current_mtime
150
+ @last_mtime = current_mtime
138
151
  @file_updated = false
139
152
  end
140
153
 
@@ -147,21 +160,21 @@ module Marvi
147
160
 
148
161
  def launch_editor
149
162
  editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
150
- line = current_source_line
151
- cmd = build_editor_command(editor, @file, line)
163
+ line = current_source_line
164
+ cmd = build_editor_command(editor, @file, line)
152
165
 
153
166
  ::Curses.close_screen
154
167
  system(cmd)
168
+ init_curses_state
155
169
  reload
156
170
  mark_reloaded
157
- init_curses_state
158
171
  draw
159
172
  end
160
173
 
161
174
  def reload
162
175
  @markdown = File.read(@file)
163
- @lines = ASTWalker.new.walk(@markdown)
164
- @scroll = [@scroll, max_scroll].min
176
+ rewalk
177
+ @scroll = [@scroll, max_scroll].min
165
178
  end
166
179
 
167
180
  def init_curses_state
@@ -208,7 +221,7 @@ module Marvi
208
221
 
209
222
  def draw_status_bar
210
223
  ::Curses.setpos(::Curses.lines - 1, 0)
211
- top = @scroll + 1
224
+ top = @scroll + 1
212
225
  bottom = [@scroll + page_size, @lines.size].min
213
226
  edit_hint = @file ? " e edit" : ""
214
227
  status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom#{edit_hint} q quit"
@@ -245,10 +258,10 @@ module Marvi
245
258
  def build_attr(span)
246
259
  attr = 0
247
260
  attr |= ::Curses::A_BOLD if span.bold
248
- attr |= ITALIC_ATTR if span.italic
261
+ attr |= ITALIC_ATTR if span.italic
249
262
 
250
263
  pair_key = if span.bg_color == :dark
251
- span.color == :cyan ? :cyan_on_dark : :green_on_dark
264
+ (span.color == :cyan) ? :cyan_on_dark : :green_on_dark
252
265
  elsif span.color
253
266
  span.color
254
267
  end
@@ -270,7 +283,7 @@ module Marvi
270
283
  end
271
284
 
272
285
  def scroll_by(delta)
273
- @scroll = [[@scroll + delta, 0].max, max_scroll].min
286
+ @scroll = (@scroll + delta).clamp(0, max_scroll)
274
287
  draw
275
288
  end
276
289
  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.3.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.3.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