changelog-builder 1.0.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.
@@ -0,0 +1,704 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "curses"
5
+ require_relative "git"
6
+ require_relative "versioner"
7
+ require_relative "changelog_generator"
8
+ require_relative "repo_info"
9
+
10
+ module Changelogger
11
+ # +Changelogger::Graph+ caches `git log --graph` output for rendering.
12
+ class Graph
13
+ class << self
14
+ # +Changelogger::Graph::FILENAME+ stores the graph cache file name.
15
+ FILENAME = ".graph"
16
+
17
+ # +Changelogger::Graph.ensure!+ -> void
18
+ #
19
+ # Regenerates the graph cache file from the current repository.
20
+ # @return [void]
21
+ def ensure!
22
+ content = `git log --graph --decorate=short --date=short --pretty=format:'%h %d %s' 2>/dev/null`
23
+ if content.nil? || content.strip.empty?
24
+ content = "(no git graph available — empty repo or not a git repository)\n"
25
+ end
26
+ File.open(FILENAME, "w") { |f| f.write(content) }
27
+ rescue StandardError => e
28
+ File.open(FILENAME, "w") { |f| f.write("(error generating graph: #{e.message})\n") }
29
+ end
30
+
31
+ # +Changelogger::Graph.width+ -> Integer
32
+ #
33
+ # Returns the width of the widest graph line (used to size the pane).
34
+ # @return [Integer]
35
+ def width
36
+ ensure! unless File.exist?(FILENAME)
37
+ ensure!
38
+ max = 1
39
+ IO.foreach(FILENAME) { |line| max = [max, line.rstrip.length].max }
40
+ max
41
+ end
42
+
43
+ # +Changelogger::Graph.build+ -> String
44
+ #
45
+ # Reads the cached graph content (regenerates if missing).
46
+ # @return [String]
47
+ def build
48
+ ensure! unless File.exist?(FILENAME)
49
+ File.read(FILENAME)
50
+ end
51
+ end
52
+ end
53
+
54
+ # +Changelogger::BranchWindow+ is the left-right split TUI with live preview.
55
+ class BranchWindow
56
+ # @return [Array<String>, nil] selected SHAs after Enter, or nil if cancelled
57
+ attr_reader :selected_shas
58
+
59
+ # Color pair IDs used within curses
60
+ CP_HELP = 2
61
+ CP_HIGHLIGHT = 3 # current cursor commit block
62
+ CP_SELECTED = 4 # selected anchor commit header
63
+ CP_SEP = 5 # thin separators
64
+ CP_ALT = 6 # zebra alt shading
65
+
66
+ # +Changelogger::BranchWindow.new+ -> Changelogger::BranchWindow
67
+ #
68
+ # Builds the UI panes (graph on the left, preview on the right) and prepares
69
+ # state for selection and scrolling.
70
+ #
71
+ # @param [Integer] max_height maximum height for the panes
72
+ # @param [Integer] top top offset
73
+ # @param [Integer] left left offset
74
+ # @param [Integer, nil] left_width optional fixed width for the left pane
75
+ def initialize(max_height: 50, top: 1, left: 0, left_width: nil)
76
+ @top = top
77
+ @left = left
78
+ @width = Curses.cols - @left
79
+ screen_height = Curses.lines
80
+ @height = [screen_height - @top, max_height].min
81
+
82
+ @repo = Changelogger::Repo.info
83
+
84
+ @lines = Graph.build.split("\n")
85
+ preferred_left = Graph.width + 4
86
+ @left_min = 24
87
+ @right_min = 28
88
+ @left_w = compute_left_width(requested_left: left_width || preferred_left)
89
+
90
+ # Graph state
91
+ @headers = detect_headers(@lines)
92
+ @selected_header_idx = 0
93
+ @selected_header_idxs = []
94
+ @offset = 0
95
+ @fit_full_block = true
96
+ @zebra_blocks = true
97
+
98
+ recompute_blocks!
99
+
100
+ # Preview state
101
+ @commits = Changelogger::Git.commits
102
+ @preview_lines = []
103
+ @preview_offset = 0
104
+
105
+ @focus = :left
106
+ @cancelled = false
107
+
108
+ setup_windows
109
+ init_colors
110
+ update_titles
111
+ update_preview(reset_offset: true)
112
+ ensure_visible
113
+ redraw
114
+ end
115
+
116
+ # +Changelogger::BranchWindow#select_commits+ -> Array<String>, nil
117
+ #
118
+ # Enters the TUI input loop and returns when user confirms or cancels.
119
+ # @return [Array<String>, nil] anchor SHAs (2+ required) or nil when cancelled
120
+ def select_commits
121
+ handle_keyboard_input
122
+ @cancelled ? nil : (@selected_shas || [])
123
+ ensure
124
+ teardown
125
+ end
126
+
127
+ private
128
+
129
+ # @!visibility private
130
+
131
+ # Compute left pane width, respecting min widths for both panes.
132
+ # @param [Integer] requested_left
133
+ # @return [Integer]
134
+ def compute_left_width(requested_left:)
135
+ w = @width
136
+ lw = requested_left.to_i
137
+ [[lw, @left_min].max, w - @right_min].min
138
+ end
139
+
140
+ # Create frames and subwindows for both panes.
141
+ # @return [void]
142
+ def setup_windows
143
+ destroy_windows
144
+
145
+ @left_frame = Curses::Window.new(@height, @left_w, @top, @left)
146
+ @left_frame.box
147
+ @left_sub = @left_frame.subwin(@height - 2, @left_w - 2, @top + 1, @left + 1)
148
+ @left_sub.keypad(true)
149
+ @left_sub.scrollok(false)
150
+
151
+ right_w = @width - @left_w
152
+ right_x = @left + @left_w
153
+ @right_frame = Curses::Window.new(@height, right_w, @top, right_x)
154
+ @right_frame.box
155
+ @right_sub = @right_frame.subwin(@height - 2, right_w - 2, @top + 1, right_x + 1)
156
+ @right_sub.keypad(true)
157
+ @right_sub.scrollok(false)
158
+
159
+ @left_frame.refresh
160
+ @right_frame.refresh
161
+ end
162
+
163
+ # Close subwindows/frames and close the curses screen.
164
+ # @return [void]
165
+ def destroy_windows
166
+ @left_sub&.close
167
+ @left_frame&.close
168
+ @right_sub&.close
169
+ @right_frame&.close
170
+ rescue StandardError
171
+ # ignore
172
+ end
173
+
174
+ # Cleanup hook called when leaving the TUI.
175
+ # @return [void]
176
+ def teardown
177
+ destroy_windows
178
+ Curses.close_screen
179
+ rescue StandardError
180
+ # ignore
181
+ end
182
+
183
+ # Update titles with repo name/branch/HEAD and remote slug/identifier.
184
+ # @return [void]
185
+ def update_titles
186
+ dirty = @repo.dirty ? "*" : ""
187
+ left_title = " Graph — #{@repo.name} [#{@repo.branch}@#{@repo.head_short}#{dirty}] "
188
+ draw_title(@left_frame, left_title)
189
+ right_id = @repo.remote_slug || @repo.name
190
+ right_title = " Preview — #{right_id} "
191
+ draw_title(@right_frame, right_title)
192
+ end
193
+
194
+ # Draw a title into a frame (top border area).
195
+ # @param [Curses::Window] frame
196
+ # @param [String] label
197
+ # @return [void]
198
+ def draw_title(frame, label)
199
+ width = frame.maxx
200
+ text = label[0, [width - 4, 0].max]
201
+ frame.setpos(0, 2)
202
+ frame.addstr(" " * [width - 4, 0].max)
203
+ frame.setpos(0, 2)
204
+ frame.addstr(text)
205
+ frame.refresh
206
+ end
207
+
208
+ # Bold the focused frame’s title.
209
+ # @return [void]
210
+ def draw_focus
211
+ update_titles
212
+ if @focus == :left
213
+ @left_frame.setpos(0, 2)
214
+ @left_frame.attron(Curses::A_BOLD) { @left_frame.addstr("") }
215
+ else
216
+ @right_frame.setpos(0, 2)
217
+ @right_frame.attron(Curses::A_BOLD) { @right_frame.addstr("") }
218
+ end
219
+ @left_frame.refresh
220
+ @right_frame.refresh
221
+ end
222
+
223
+ # Help texts for the help bars.
224
+ # @return [String]
225
+ def left_help_text
226
+ "↑/↓ j/k move • Space select • Tab focus • Enter generate • PgUp/PgDn • f fit • r refresh • z zebra • </> split"
227
+ end
228
+
229
+ # @return [String]
230
+ def right_help_text
231
+ "↑/↓ j/k scroll • PgUp/PgDn • g top • G bottom • Tab focus"
232
+ end
233
+
234
+ # Number of content rows (excluding help bar) on the left pane.
235
+ # @return [Integer]
236
+ def left_content_rows
237
+ [@left_sub.maxy - 1, 0].max
238
+ end
239
+
240
+ # Number of content rows (excluding help bar) on the right pane.
241
+ # @return [Integer]
242
+ def right_content_rows
243
+ [@right_sub.maxy - 1, 0].max
244
+ end
245
+
246
+ # Initialize color pairs and attributes for styling.
247
+ # @return [void]
248
+ def init_colors
249
+ if Curses.has_colors?
250
+ begin
251
+ Curses.start_color
252
+ Curses.use_default_colors if Curses.respond_to?(:use_default_colors)
253
+ rescue StandardError
254
+ end
255
+ Curses.init_pair(CP_HELP, Curses::COLOR_CYAN, -1)
256
+ Curses.init_pair(CP_HIGHLIGHT, Curses::COLOR_BLACK, Curses::COLOR_CYAN)
257
+ Curses.init_pair(CP_SELECTED, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
258
+ Curses.init_pair(CP_SEP, Curses::COLOR_BLUE, -1)
259
+ Curses.init_pair(CP_ALT, Curses::COLOR_WHITE, -1)
260
+ end
261
+
262
+ if Curses.has_colors?
263
+ @style_help = Curses.color_pair(CP_HELP) | Curses::A_DIM
264
+ @style_highlight = Curses.color_pair(CP_HIGHLIGHT)
265
+ @style_selected = Curses.color_pair(CP_SELECTED) | Curses::A_BOLD
266
+ @style_sep = Curses.color_pair(CP_SEP) | Curses::A_DIM
267
+ @style_alt = Curses.color_pair(CP_ALT) | Curses::A_DIM
268
+ else
269
+ @style_help = Curses::A_DIM
270
+ @style_highlight = Curses::A_STANDOUT
271
+ @style_selected = Curses::A_BOLD
272
+ @style_sep = Curses::A_DIM
273
+ @style_alt = Curses::A_DIM
274
+ end
275
+ end
276
+
277
+ # Add a string with an attribute, guarding for zero attr.
278
+ # @param [Curses::Window] win
279
+ # @param [String] text
280
+ # @param [Integer, nil] attr curses attribute (or nil)
281
+ # @return [void]
282
+ def addstr_with_attr(win, text, attr)
283
+ if attr && attr != 0
284
+ win.attron(attr)
285
+ win.addstr(text)
286
+ win.attroff(attr)
287
+ else
288
+ win.addstr(text)
289
+ end
290
+ end
291
+
292
+ # Detect header lines in `git log --graph` output.
293
+ # @param [Array<String>] lines
294
+ # @return [Array<Integer>] indexes of header rows
295
+ def detect_headers(lines)
296
+ lines.each_index.select { |i| lines[i] =~ %r{^\s*[|\s\\/]*\*\s} }
297
+ end
298
+
299
+ # Map every line to a commit block index, and mark block boundaries.
300
+ # @return [void]
301
+ def recompute_blocks!
302
+ @block_index_by_line = Array.new(@lines.length, 0)
303
+ @boundary_set = Set.new
304
+ @headers.each_with_index do |start, j|
305
+ stop = @headers[j + 1] || @lines.length
306
+ (start...stop).each { |idx| @block_index_by_line[idx] = j }
307
+ @boundary_set << (stop - 1) if stop > start
308
+ end
309
+ end
310
+
311
+ # Absolute line number of the currently selected commit header.
312
+ # @return [Integer]
313
+ def header_line_abs
314
+ @headers[@selected_header_idx] || 0
315
+ end
316
+
317
+ # Extract an abbreviated SHA from a header line.
318
+ # @param [Integer] abs_index
319
+ # @return [String, nil] short or full SHA token
320
+ def header_sha_at(abs_index)
321
+ line = @lines[abs_index] || ""
322
+ m = line.match(/\b([a-f0-9]{7,40})\b/i)
323
+ m && m[1]
324
+ end
325
+
326
+ # Find current header index by a known SHA.
327
+ # @param [String] sha
328
+ # @return [Integer, nil]
329
+ def find_header_index_by_sha(sha)
330
+ return nil if sha.nil?
331
+
332
+ @headers.find_index { |abs| (@lines[abs] || "").include?(sha[0, 7]) }
333
+ end
334
+
335
+ # Current commit block line range.
336
+ # @return [Range]
337
+ def current_commit_range
338
+ start = header_line_abs
339
+ stop = @headers[@selected_header_idx + 1] || @lines.length
340
+ (start...stop)
341
+ end
342
+
343
+ # Ensure the selected header (and optionally its block) is visible.
344
+ # @param [Boolean] fit_full_block when true, keep the entire block inside viewport if it fits
345
+ # @return [void]
346
+ def ensure_visible(fit_full_block: @fit_full_block)
347
+ rows = [left_content_rows, 1].max
348
+ header_line = header_line_abs
349
+ stop = @headers[@selected_header_idx + 1] || @lines.length
350
+ block_size = stop - header_line
351
+
352
+ if header_line < @offset
353
+ @offset = header_line
354
+ elsif header_line >= @offset + rows
355
+ @offset = header_line - (rows - 1)
356
+ end
357
+
358
+ if fit_full_block && block_size <= rows
359
+ @offset = stop - rows if stop > @offset + rows
360
+ @offset = header_line if header_line < @offset
361
+ end
362
+
363
+ @offset = [[@offset, 0].max, [@lines.length - rows, 0].max].min
364
+ end
365
+
366
+ # Toggle selected mark on the current header.
367
+ # @return [void]
368
+ def toggle_selection
369
+ idx = @selected_header_idx
370
+ if @selected_header_idxs.include?(idx)
371
+ @selected_header_idxs.delete(idx)
372
+ else
373
+ @selected_header_idxs << idx
374
+ @selected_header_idxs.sort!
375
+ end
376
+ end
377
+
378
+ # List selected SHAs in header order.
379
+ # @return [Array<String>]
380
+ def selected_shas_from_idxs
381
+ @selected_header_idxs.map { |h_idx| header_sha_at(@headers[h_idx]) }.compact
382
+ end
383
+
384
+ # Render / update the live preview content.
385
+ # @param [Boolean] reset_offset reset preview scroll to top
386
+ # @return [void]
387
+ def update_preview(reset_offset: false)
388
+ anchors = selected_shas_from_idxs
389
+ content =
390
+ if anchors.size >= 2
391
+ begin
392
+ Changelogger::ChangelogGenerator.render(@commits, anchors)
393
+ rescue StandardError => e
394
+ "Preview error: #{e.message}"
395
+ end
396
+ else
397
+ <<~TXT
398
+ Preview — select at least 2 commits with SPACE to generate.
399
+ Controls: #{right_help_text}
400
+ TXT
401
+ end
402
+
403
+ @preview_lines = (content || "").split("\n")
404
+ @preview_offset = 0 if reset_offset
405
+ clamp_preview_offset
406
+ redraw_right
407
+ end
408
+
409
+ # Clamp preview offset to visible range.
410
+ # @return [void]
411
+ def clamp_preview_offset
412
+ max_off = [@preview_lines.length - right_content_rows, 0].max
413
+ @preview_offset = [[@preview_offset, 0].max, max_off].min
414
+ end
415
+
416
+ # Refresh repo/graph/commits and preserve cursor and selections.
417
+ # @return [void]
418
+ def refresh_graph
419
+ current_sha = header_sha_at(header_line_abs)
420
+ selected_shas = selected_shas_from_idxs
421
+
422
+ @repo = Changelogger::Repo.info
423
+ update_titles
424
+
425
+ Graph.ensure!
426
+ @lines = Graph.build.split("\n")
427
+ @headers = detect_headers(@lines)
428
+ recompute_blocks!
429
+
430
+ if current_sha
431
+ new_idx = find_header_index_by_sha(current_sha)
432
+ @selected_header_idx = new_idx || 0
433
+ else
434
+ @selected_header_idx = 0
435
+ end
436
+
437
+ @selected_header_idxs = selected_shas.filter_map { |sha| find_header_index_by_sha(sha) }.sort
438
+ @commits = Changelogger::Git.commits
439
+
440
+ ensure_visible
441
+ redraw_left
442
+ update_preview
443
+ end
444
+
445
+ # Resize split between panes.
446
+ # @param [Integer] delta positive to expand left, negative to shrink
447
+ # @return [void]
448
+ def adjust_split(delta)
449
+ new_left = compute_left_width(requested_left: @left_w + delta)
450
+ return if new_left == @left_w
451
+
452
+ @left_w = new_left
453
+ setup_windows
454
+ init_colors
455
+ update_titles
456
+ ensure_visible
457
+ redraw
458
+ end
459
+
460
+ # Full redraw for both panes and focus indicator.
461
+ # @return [void]
462
+ def redraw
463
+ redraw_left
464
+ redraw_right
465
+ draw_focus
466
+ end
467
+
468
+ # Redraw left pane (help bar + graph list with styling and separators).
469
+ # @return [void]
470
+ def redraw_left
471
+ ensure_visible
472
+ @left_sub.erase
473
+
474
+ @left_sub.setpos(0, 0)
475
+ help = left_help_text.ljust(@left_sub.maxx, " ")[0, @left_sub.maxx]
476
+ addstr_with_attr(@left_sub, help, @style_help)
477
+
478
+ content_h = left_content_rows
479
+ highlight = current_commit_range
480
+ selected_header_abs = @selected_header_idxs.map { |i| @headers[i] }.to_set
481
+
482
+ visible = @lines[@offset, content_h] || []
483
+ visible.each_with_index do |line, i|
484
+ idx = @offset + i
485
+ @left_sub.setpos(i + 1, 0)
486
+
487
+ text = line.ljust(@left_sub.maxx, " ")[0, @left_sub.maxx]
488
+
489
+ attr =
490
+ if selected_header_abs.include?(idx)
491
+ @style_selected
492
+ elsif highlight.cover?(idx)
493
+ @style_highlight
494
+ elsif @zebra_blocks && @block_index_by_line[idx].to_i.odd?
495
+ @style_alt
496
+ end
497
+
498
+ addstr_with_attr(@left_sub, text, attr)
499
+
500
+ next unless @boundary_set.include?(idx)
501
+
502
+ start_col = [line.rstrip.length, 0].max
503
+ start_col = [[start_col, 0].max, @left_sub.maxx - 1].min
504
+ sep_width = @left_sub.maxx - start_col
505
+ next unless sep_width.positive?
506
+
507
+ @left_sub.setpos(i + 1, start_col)
508
+ pattern = "┄" * sep_width
509
+ addstr_with_attr(@left_sub, pattern[0, sep_width], @style_sep)
510
+ end
511
+
512
+ (visible.length...content_h).each do |i|
513
+ @left_sub.setpos(i + 1, 0)
514
+ @left_sub.addstr(" " * @left_sub.maxx)
515
+ end
516
+
517
+ @left_sub.refresh
518
+ end
519
+
520
+ # Redraw right pane (help bar + markdown preview).
521
+ # @return [void]
522
+ def redraw_right
523
+ @right_sub.erase
524
+
525
+ @right_sub.setpos(0, 0)
526
+ help = right_help_text.ljust(@right_sub.maxx, " ")[0, @right_sub.maxx]
527
+ addstr_with_attr(@right_sub, help, @style_help)
528
+
529
+ content_h = right_content_rows
530
+ clamp_preview_offset
531
+ visible = @preview_lines[@preview_offset, content_h] || []
532
+ visible.each_with_index do |line, i|
533
+ @right_sub.setpos(i + 1, 0)
534
+ @right_sub.addstr(line.ljust(@right_sub.maxx, " ")[0, @right_sub.maxx])
535
+ end
536
+
537
+ (visible.length...content_h).each do |i|
538
+ @right_sub.setpos(i + 1, 0)
539
+ @right_sub.addstr(" " * @right_sub.maxx)
540
+ end
541
+
542
+ @right_sub.refresh
543
+ end
544
+
545
+ # Show a transient message at the bottom of a window.
546
+ # @param [Curses::Window] win
547
+ # @param [String] msg
548
+ # @return [void]
549
+ def flash_message(win, msg)
550
+ return if win.maxy <= 0 || win.maxx <= 0
551
+
552
+ win.setpos(win.maxy - 1, 0)
553
+ txt = msg.ljust(win.maxx, " ")[0, win.maxx]
554
+ win.attron(Curses::A_BOLD)
555
+ win.addstr(txt)
556
+ win.attroff(Curses::A_BOLD)
557
+ win.refresh
558
+ sleep(0.6)
559
+ redraw
560
+ end
561
+
562
+ # Safely fetch a Curses key constant.
563
+ # @param [Symbol] name
564
+ # @return [Integer, nil]
565
+ def key_const(name)
566
+ Curses::Key.const_get(name)
567
+ rescue NameError
568
+ nil
569
+ end
570
+
571
+ # Normalize raw key codes into symbols we can switch on.
572
+ # @param [Object] ch raw key
573
+ # @return [Symbol] normalized key
574
+ def normalize_key(ch)
575
+ return :none if ch.nil?
576
+
577
+ return :tab if ch == "\t" || ch == 9 || (kc = key_const(:TAB)) && ch == kc
578
+ return :shift_tab if (kc = key_const(:BTAB)) && ch == kc
579
+ return :quit if ["q", 27].include?(ch)
580
+
581
+ enter_key = key_const(:ENTER)
582
+ return :enter if ch == "\r" || ch == "\n" || ch == 10 || ch == 13 || (enter_key && ch == enter_key)
583
+
584
+ return :up if ch == key_const(:UP) || ch == "k"
585
+ return :down if ch == key_const(:DOWN) || ch == "j"
586
+ return :page_up if ch == key_const(:PPAGE)
587
+ return :page_down if ch == key_const(:NPAGE)
588
+
589
+ return :toggle if ch == " "
590
+ return :fit if ch == "f"
591
+ return :refresh if ch == "r"
592
+ return :zebra if ch == "z"
593
+ return :g if ch == "g"
594
+ return :G if ch == "G"
595
+ return :lt if ch == "<"
596
+ return :gt if ch == ">"
597
+
598
+ :other
599
+ end
600
+
601
+ # The main input loop (handles focus, navigation, selection, actions).
602
+ # @return [void]
603
+ def handle_keyboard_input
604
+ loop do
605
+ raw = (@focus == :left ? @left_sub.getch : @right_sub.getch)
606
+ key = normalize_key(raw)
607
+
608
+ case key
609
+ when :tab, :shift_tab
610
+ @focus = (@focus == :left ? :right : :left)
611
+ draw_focus
612
+ when :quit
613
+ @cancelled = true
614
+ break
615
+ when :enter
616
+ shas = selected_shas_from_idxs
617
+ if shas.size >= 2
618
+ @selected_shas = shas
619
+ break
620
+ else
621
+ flash_message(@left_sub, "Select at least 2 commits (space)")
622
+ end
623
+ when :lt
624
+ adjust_split(-4)
625
+ when :gt
626
+ adjust_split(+4)
627
+ when :up
628
+ if @focus == :left
629
+ if @selected_header_idx.positive?
630
+ @selected_header_idx -= 1
631
+ ensure_visible
632
+ redraw_left
633
+ update_preview
634
+ end
635
+ else
636
+ @preview_offset -= 1
637
+ redraw_right
638
+ end
639
+ when :down
640
+ if @focus == :left
641
+ if @selected_header_idx < @headers.length - 1
642
+ @selected_header_idx += 1
643
+ ensure_visible
644
+ redraw_left
645
+ update_preview
646
+ end
647
+ else
648
+ @preview_offset += 1
649
+ redraw_right
650
+ end
651
+ when :page_up
652
+ if @focus == :left
653
+ @selected_header_idx = [@selected_header_idx - 5, 0].max
654
+ ensure_visible
655
+ redraw_left
656
+ update_preview
657
+ else
658
+ @preview_offset -= right_content_rows
659
+ redraw_right
660
+ end
661
+ when :page_down
662
+ if @focus == :left
663
+ @selected_header_idx = [@selected_header_idx + 5, @headers.length - 1].min
664
+ ensure_visible
665
+ redraw_left
666
+ update_preview
667
+ else
668
+ @preview_offset += right_content_rows
669
+ redraw_right
670
+ end
671
+ when :g
672
+ if @focus == :right
673
+ @preview_offset = 0
674
+ redraw_right
675
+ end
676
+ when :G
677
+ if @focus == :right
678
+ @preview_offset = [@preview_lines.length - right_content_rows, 0].max
679
+ redraw_right
680
+ end
681
+ when :toggle
682
+ if @focus == :left
683
+ toggle_selection
684
+ redraw_left
685
+ update_preview(reset_offset: true)
686
+ end
687
+ when :fit
688
+ if @focus == :left
689
+ @fit_full_block = !@fit_full_block
690
+ ensure_visible
691
+ redraw_left
692
+ end
693
+ when :zebra
694
+ if @focus == :left
695
+ @zebra_blocks = !@zebra_blocks
696
+ redraw_left
697
+ end
698
+ when :refresh
699
+ refresh_graph
700
+ end
701
+ end
702
+ end
703
+ end
704
+ end