charming 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # FuzzyMatcher implements fzf-style subsequence matching with contiguity and
6
+ # word-boundary scoring. Used by CommandPalette (and available to any component
7
+ # that filters labels against typed input).
8
+ #
9
+ # FuzzyMatcher.score("opl", "Open palette") # => positive score
10
+ # FuzzyMatcher.score("xyz", "Open palette") # => nil (not a subsequence)
11
+ # FuzzyMatcher.filter("op", commands, &:label)
12
+ module FuzzyMatcher
13
+ # Score bonuses: base per matched char, consecutive-run bonus, word-start bonus.
14
+ # The run bonus outweighs the word-start bonus so a literal substring match
15
+ # ("pal" in "Open palette") beats the same letters scattered across word starts.
16
+ CHAR_SCORE = 1
17
+ CONSECUTIVE_BONUS = 4
18
+ WORD_START_BONUS = 3
19
+
20
+ module_function
21
+
22
+ # Returns a relevance score when every character of *query* appears in order
23
+ # within *candidate* (case-insensitive), nil otherwise. Higher is better:
24
+ # contiguous runs and matches at word starts score above scattered matches.
25
+ # All alignments are considered (memoized), so "pal" finds the contiguous run
26
+ # in "Open palette" rather than the scattered greedy match.
27
+ def score(query, candidate)
28
+ q = query.to_s.downcase
29
+ c = candidate.to_s.downcase
30
+ return 0 if q.empty?
31
+
32
+ best_alignment(q, 0, c, 0, false, {})
33
+ end
34
+
35
+ # Finds the best-scoring alignment of q[qi..] within c[ci..]. *consecutive_at_ci*
36
+ # is true when the previous query char matched at ci - 1 (enabling the run bonus
37
+ # for a match exactly at ci). Returns nil when no alignment exists.
38
+ def best_alignment(q, qi, c, ci, consecutive_at_ci, memo)
39
+ return 0 if qi == q.length
40
+
41
+ key = [qi, ci, consecutive_at_ci]
42
+ return memo[key] if memo.key?(key)
43
+
44
+ best = nil
45
+ index = ci
46
+ while (index = c.index(q[qi], index))
47
+ points = CHAR_SCORE
48
+ points += CONSECUTIVE_BONUS if consecutive_at_ci && index == ci
49
+ points += WORD_START_BONUS if word_start?(c, index)
50
+ rest = best_alignment(q, qi + 1, c, index + 1, true, memo)
51
+ if rest
52
+ total = points + rest
53
+ best = total if best.nil? || total > best
54
+ end
55
+ index += 1
56
+ end
57
+
58
+ memo[key] = best
59
+ end
60
+
61
+ # Filters *candidates* to those matching *query*, ordered best-score first
62
+ # (original order breaks ties). The optional block extracts the searchable
63
+ # label from each candidate (defaults to to_s).
64
+ def filter(query, candidates, &label)
65
+ scored = candidates.each_with_index.filter_map do |candidate, index|
66
+ text = label ? yield(candidate) : candidate.to_s
67
+ candidate_score = score(query, text)
68
+ [candidate_score, index, candidate] if candidate_score
69
+ end
70
+
71
+ scored.sort_by { |candidate_score, index, _| [-candidate_score, index] }.map(&:last)
72
+ end
73
+
74
+ # True when the character at *index* starts a word: position 0 or preceded by
75
+ # a separator (space, underscore, hyphen, slash, dot, colon).
76
+ def word_start?(text, index)
77
+ return true if index.zero?
78
+
79
+ text[index - 1].match?(%r{[\s_\-/.:]})
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # HelpOverlay renders a controller's key bindings as a two-column cheat-sheet inside
6
+ # a Modal — the classic `?` help screen. Build it straight from a controller class:
7
+ #
8
+ # HelpOverlay.for_controller(self.class, theme: theme)
9
+ #
10
+ # or with explicit entries:
11
+ #
12
+ # HelpOverlay.new(bindings: {"q" => "Quit", "ctrl+p" => "Command palette"})
13
+ #
14
+ # Any key dismisses it (`handle_key` returns :cancelled).
15
+ class HelpOverlay < Component
16
+ DEFAULT_TITLE = "Keyboard Shortcuts"
17
+ DEFAULT_WIDTH = 44
18
+
19
+ # Builds an overlay from a controller class's `key_bindings` (key → action name).
20
+ # Action names are humanized into descriptions ("open_command_palette" → "Open command palette").
21
+ def self.for_controller(controller_class, title: DEFAULT_TITLE, theme: nil)
22
+ bindings = controller_class.key_bindings.transform_values do |action|
23
+ action.to_s.tr("_", " ").capitalize
24
+ end
25
+ new(bindings: bindings, title: title, theme: theme)
26
+ end
27
+
28
+ # *bindings* maps key names (symbols or strings) to description strings.
29
+ def initialize(bindings:, title: DEFAULT_TITLE, width: DEFAULT_WIDTH, theme: nil)
30
+ super(theme: theme)
31
+ @bindings = bindings
32
+ @title = title
33
+ @width = width
34
+ end
35
+
36
+ # Free-typed characters belong to this component while it is focused.
37
+ def captures_text?
38
+ true
39
+ end
40
+
41
+ # Any key dismisses the overlay.
42
+ def handle_key(_event)
43
+ :cancelled
44
+ end
45
+
46
+ # Renders the bindings table inside a titled modal.
47
+ def render
48
+ render_component(Modal.new(content: table, title: @title, width: @width, theme: theme))
49
+ end
50
+
51
+ private
52
+
53
+ # The two-column key/description table, keys right-padded to align.
54
+ def table
55
+ return theme.muted.render("No key bindings") if @bindings.empty?
56
+
57
+ key_width = @bindings.keys.map { |key| key.to_s.length }.max
58
+ @bindings.map do |key, description|
59
+ padded = key.to_s.ljust(key_width)
60
+ "#{theme.title.render(padded)} #{description}"
61
+ end.join("\n")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3,14 +3,17 @@
3
3
  module Charming
4
4
  module Components
5
5
  # Markdown renders CommonMark/GFM source as ANSI-styled terminal text.
6
+ # *hyperlinks* (default false) emits OSC 8 escapes so links are clickable in
7
+ # modern terminals.
6
8
  class Markdown < Component
7
- def initialize(content:, width: nil, theme: nil, syntax_highlighting: true, style: :dark, base_url: nil)
9
+ def initialize(content:, width: nil, theme: nil, syntax_highlighting: true, style: :dark, base_url: nil, hyperlinks: false)
8
10
  super(theme: theme)
9
11
  @content = content
10
12
  @width = width
11
13
  @syntax_highlighting = syntax_highlighting
12
14
  @style = style
13
15
  @base_url = base_url
16
+ @hyperlinks = hyperlinks
14
17
  end
15
18
 
16
19
  # Renders the Markdown body to a styled, terminal-safe string.
@@ -21,7 +24,8 @@ module Charming
21
24
  theme: theme,
22
25
  syntax_highlighting: @syntax_highlighting,
23
26
  style: @style,
24
- base_url: @base_url
27
+ base_url: @base_url,
28
+ hyperlinks: @hyperlinks
25
29
  ).render
26
30
  end
27
31
  end
@@ -5,19 +5,40 @@ module Charming
5
5
  # Modal is a centered, boxed overlay with an optional title, help line, and body content.
6
6
  # The body may be a string, View, or Component; when it responds to `render`, its output
7
7
  # is used. The result is wrapped in a UI::Style border with padding.
8
+ #
9
+ # When *max_body_height* is given and the body is taller, the body is windowed through a
10
+ # Viewport: up/down (and page/home/end) keys scroll it via `handle_key`, and the current
11
+ # scroll position is exposed as `scroll_offset` so controllers can persist it.
8
12
  class Modal < Component
13
+ # The body's current scroll offset (only meaningful with max_body_height).
14
+ attr_reader :scroll_offset
15
+
9
16
  # *content* is the modal body. *title* (optional) is rendered centered at the top.
10
17
  # *help* (optional) is rendered as a muted footer line. *width* is the modal's total width.
11
- # *style* overrides the default `theme.modal` style.
12
- def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
18
+ # *max_body_height* caps the visible body rows (scrollable). *scroll_offset* restores a
19
+ # previous scroll position. *style* overrides the default `theme.modal` style.
20
+ def initialize(content:, title: nil, help: nil, width: 52, max_body_height: nil, scroll_offset: 0, style: nil, theme: nil)
13
21
  super(theme: theme)
14
22
  @content = content
15
23
  @title = title
16
24
  @help = help
17
25
  @width = width
26
+ @max_body_height = max_body_height
27
+ @scroll_offset = scroll_offset
18
28
  @style = style
19
29
  end
20
30
 
31
+ # Scrolls the body when it is taller than max_body_height. Returns :handled when the
32
+ # key moved the viewport, nil otherwise (so callers can route unconsumed keys).
33
+ def handle_key(event)
34
+ return nil unless scrollable?
35
+
36
+ viewport = body_viewport
37
+ result = viewport.handle_key(event)
38
+ @scroll_offset = viewport.offset
39
+ result
40
+ end
41
+
21
42
  # Renders the modal as a bordered, padded string with the title and help lines stacked
22
43
  # above the content.
23
44
  def render
@@ -30,7 +51,26 @@ module Charming
30
51
 
31
52
  # Returns the array of non-nil lines: title, help, content.
32
53
  def lines
33
- [title_line, help_line, render_content].compact
54
+ [title_line, help_line, body_content].compact
55
+ end
56
+
57
+ # The body: windowed through a Viewport when scrollable, otherwise rendered directly.
58
+ def body_content
59
+ return render_content unless scrollable?
60
+
61
+ body_viewport.render
62
+ end
63
+
64
+ # True when a max body height is set and the content exceeds it.
65
+ def scrollable?
66
+ return false unless @max_body_height
67
+
68
+ render_content.lines.length > @max_body_height
69
+ end
70
+
71
+ # A Viewport over the rendered body at the current scroll offset.
72
+ def body_viewport
73
+ @body_viewport ||= Viewport.new(content: render_content, height: @max_body_height, offset: @scroll_offset)
34
74
  end
35
75
 
36
76
  # Returns the centered title line styled with the theme's title style, when a title was given.
@@ -43,9 +83,9 @@ module Charming
43
83
  text(help, style: theme.muted) if help
44
84
  end
45
85
 
46
- # Returns the rendered content string, calling `render` on the body when applicable.
86
+ # Returns the rendered content string (memoized), calling `render` on the body when applicable.
47
87
  def render_content
48
- content.respond_to?(:render) ? render_component(content) : content.to_s
88
+ @render_content ||= content.respond_to?(:render) ? render_component(content) : content.to_s
49
89
  end
50
90
 
51
91
  # Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # MultiSelectList is a List variant where Space toggles per-item checkmarks and
6
+ # Enter submits the checked set. Renders `[x]` / `[ ]` prefixes.
7
+ #
8
+ # `handle_key` returns `[:submitted, [item, ...]]` on Enter, :handled for toggles
9
+ # and navigation, nil otherwise. *max_selections* optionally caps how many items
10
+ # can be checked at once.
11
+ class MultiSelectList < List
12
+ # The set of selected (checked) item indices.
13
+ attr_reader :selected_indices
14
+
15
+ # Same options as List, plus *selected_indices* (initially checked items) and
16
+ # *max_selections* (cap on simultaneous checks; nil = unlimited).
17
+ def initialize(items:, selected_indices: [], max_selections: nil, **options)
18
+ super(items: items, **options)
19
+ @selected_indices = selected_indices.to_a.map(&:to_i).uniq.select { |index| index.between?(0, items.length - 1) }
20
+ @max_selections = max_selections
21
+ end
22
+
23
+ # Space toggles the highlighted item, Enter submits the checked items.
24
+ def handle_key(event)
25
+ case Charming.key_of(event)
26
+ when :space then toggle_current
27
+ when :enter then [:submitted, selected_items]
28
+ else
29
+ # Bypass List#handle_key (its Enter means single-select); use its navigation.
30
+ keyboard_navigation(event)
31
+ end
32
+ end
33
+
34
+ # The checked items, in list order.
35
+ def selected_items
36
+ selected_indices.sort.map { |index| items[index] }
37
+ end
38
+
39
+ # Renders each visible item with a checkbox prefix; the highlighted row uses the
40
+ # selected style.
41
+ def render
42
+ visible_items.each_with_index.map do |item, index|
43
+ render_checkbox_item(item, viewport_start + index)
44
+ end.join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ # Toggles the highlighted item's checkbox, respecting max_selections.
50
+ def toggle_current
51
+ index = selected_index
52
+ if selected_indices.include?(index)
53
+ selected_indices.delete(index)
54
+ else
55
+ return :handled if @max_selections && selected_indices.length >= @max_selections
56
+
57
+ selected_indices << index
58
+ end
59
+ :handled
60
+ end
61
+
62
+ # Navigation via the KeyboardHandler key actions (up/down/home/end and keymap aliases).
63
+ def keyboard_navigation(event)
64
+ key = Charming.key_of(event)
65
+ action = key_actions[key]
66
+ return nil unless action
67
+
68
+ send(action)
69
+ :handled
70
+ end
71
+
72
+ # One row: checkbox, then the labeled item; highlighted row in selected style.
73
+ def render_checkbox_item(item, index)
74
+ checkbox = selected_indices.include?(index) ? "[x]" : "[ ]"
75
+ rendered = "#{checkbox} #{label_for(item)}"
76
+ (index == selected_index) ? theme.selected.render(rendered) : rendered
77
+ end
78
+
79
+ # The display label for *item* via the List's label callable.
80
+ def label_for(item)
81
+ @label.call(item)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -46,7 +46,6 @@ module Charming
46
46
  width = [@total, 1].max
47
47
  completed = completed_width(width)
48
48
  incomplete = width - completed
49
- incomplete -= 1 if @current.zero?
50
49
  bar = (@complete * completed) + (@incomplete * incomplete)
51
50
  result = "[" + bar + "]"
52
51
 
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # StatusBar renders a single fixed-width row with left/center/right segments —
6
+ # the classic TUI bottom bar for mode indicators, hints, and app state.
7
+ #
8
+ # StatusBar.new(width: screen.width, left: "NORMAL", right: "main ⎇")
9
+ #
10
+ # When *hints* is given (an array of [key, description] pairs), the center segment
11
+ # renders them as `key description` pairs — pass a controller's key bindings to get
12
+ # an automatic hint line.
13
+ class StatusBar < Component
14
+ # *width* is the total bar width. *left*/*center*/*right* are the segment contents.
15
+ # *hints* renders key-hint pairs in the center when no explicit center is given.
16
+ # *style* overrides the default bar background style.
17
+ def initialize(width:, left: "", center: "", right: "", hints: nil, style: nil, theme: nil)
18
+ super(theme: theme)
19
+ @width = width
20
+ @left = left.to_s
21
+ @center = center.to_s
22
+ @right = right.to_s
23
+ @hints = hints
24
+ @bar_style = style
25
+ end
26
+
27
+ # Renders the bar: left-aligned, centered, and right-aligned segments on one row,
28
+ # padded to the full width and wrapped in the bar style.
29
+ def render
30
+ resolved_style.render(compose_segments)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :width, :left, :right
36
+
37
+ # The center content: the explicit center, or formatted hints when given.
38
+ def center
39
+ return @center unless @center.empty?
40
+ return "" unless @hints
41
+
42
+ @hints.map { |key, description| "#{key} #{description}" }.join(" ")
43
+ end
44
+
45
+ # Lays the three segments onto a single row of exactly *width* columns.
46
+ # Center is positioned at the true middle; left/right anchor the edges.
47
+ # Segments are clipped if they would collide.
48
+ def compose_segments
49
+ row = " " * width
50
+ row = place_segment(row, left, 0)
51
+ center_text = center
52
+ center_start = [(width - UI::Width.measure(center_text)) / 2, 0].max
53
+ row = place_segment(row, center_text, center_start)
54
+ right_start = [width - UI::Width.measure(right), 0].max
55
+ place_segment(row, right, right_start)
56
+ end
57
+
58
+ # Writes *text* into *row* starting at *column*, clipping to the row width.
59
+ def place_segment(row, text, column)
60
+ return row if text.empty?
61
+
62
+ visible = UI.visible_slice(text, 0, width - column)
63
+ prefix = UI.visible_slice(row, 0, column)
64
+ suffix_start = column + UI::Width.measure(visible)
65
+ suffix = UI.visible_slice(row, suffix_start, width - suffix_start)
66
+ "#{prefix}#{visible}#{suffix}"
67
+ end
68
+
69
+ # The user style or a muted reverse bar derived from the theme.
70
+ def resolved_style
71
+ @bar_style || theme.selected
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # TabBar renders a horizontal list of tabs with one active tab, navigable with
6
+ # left/right (h/l in vim keymap) and selectable with Enter. Mouse clicks select the
7
+ # clicked tab.
8
+ #
9
+ # TabBar.new(tabs: ["Files", "Search", "Git"], selected_index: 0)
10
+ #
11
+ # `handle_key` returns `[:selected, index]` on Enter, `:handled` for navigation keys,
12
+ # and nil otherwise.
13
+ class TabBar < Component
14
+ include KeyboardHandler
15
+
16
+ # Maps navigation keys to instance methods via KeyboardHandler.
17
+ KEY_ACTIONS = {
18
+ left: :move_left,
19
+ right: :move_right,
20
+ home: :move_home,
21
+ end: :move_end
22
+ }.freeze
23
+
24
+ # The tab labels and the index of the active tab.
25
+ attr_reader :tabs, :selected_index
26
+
27
+ # *tabs* is the array of tab labels. *selected_index* is the active tab (default 0).
28
+ # *separator* spaces the tabs apart.
29
+ def initialize(tabs:, selected_index: 0, separator: " ", keymap: :vim, theme: nil)
30
+ super(theme: theme)
31
+ @tabs = Array(tabs).map(&:to_s)
32
+ @selected_index = @tabs.empty? ? 0 : selected_index.to_i.clamp(0, @tabs.length - 1)
33
+ @separator = separator
34
+ @keymap = keymap
35
+ end
36
+
37
+ # Returns `[:selected, index]` on Enter; navigation keys move the active tab.
38
+ def handle_key(event)
39
+ return nil if tabs.empty?
40
+ return [:selected, selected_index] if Charming.key_of(event) == :enter
41
+
42
+ super
43
+ end
44
+
45
+ # Selects the clicked tab. Returns :handled when a tab was hit, nil otherwise.
46
+ def handle_mouse(event)
47
+ return nil if tabs.empty?
48
+ return nil unless event.respond_to?(:click?) && event.click?
49
+
50
+ index = tab_index_at_column(event.x)
51
+ return nil unless index
52
+
53
+ @selected_index = index
54
+ :handled
55
+ end
56
+
57
+ # Renders the tabs on one row, the active tab in the selected style.
58
+ def render
59
+ tabs.each_with_index.map { |tab, index| render_tab(tab, index) }.join(@separator)
60
+ end
61
+
62
+ private
63
+
64
+ # Renders a single tab label, highlighting the active one.
65
+ def render_tab(tab, index)
66
+ label = " #{tab} "
67
+ (index == selected_index) ? theme.selected.render(label) : theme.muted.render(label)
68
+ end
69
+
70
+ # Maps a column offset to the tab whose rendered span covers it (nil between tabs).
71
+ def tab_index_at_column(column)
72
+ offset = 0
73
+ tabs.each_with_index do |tab, index|
74
+ tab_width = UI::Width.measure(" #{tab} ")
75
+ return index if column >= offset && column < offset + tab_width
76
+
77
+ offset += tab_width + UI::Width.measure(@separator)
78
+ end
79
+ nil
80
+ end
81
+
82
+ # Moves the active tab one position left.
83
+ def move_left
84
+ @selected_index -= 1 if selected_index.positive?
85
+ end
86
+
87
+ # Moves the active tab one position right.
88
+ def move_right
89
+ @selected_index += 1 if selected_index < tabs.length - 1
90
+ end
91
+
92
+ # Jumps to the first tab.
93
+ def move_home
94
+ @selected_index = 0
95
+ end
96
+
97
+ # Jumps to the last tab.
98
+ def move_end
99
+ @selected_index = tabs.length - 1
100
+ end
101
+ end
102
+ end
103
+ end
@@ -16,7 +16,9 @@ module Charming
16
16
  up: :move_up,
17
17
  down: :move_down,
18
18
  home: :move_home,
19
- end: :move_end
19
+ end: :move_end,
20
+ page_up: :page_up,
21
+ page_down: :page_down
20
22
  }.freeze
21
23
 
22
24
  # Number of terminal rows occupied by the table's top border and header line. Used by
@@ -29,12 +31,15 @@ module Charming
29
31
  # *header* is an array of column labels. *rows* is the array of body rows (each either a
30
32
  # String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
31
33
  # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
32
- def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
33
- super()
34
+ # *height* optionally limits the visible body rows; the window auto-scrolls to keep
35
+ # the selection in view, and page up/down move by a full window.
36
+ def initialize(header:, rows: [], selected_index: 0, keymap: :vim, theme: nil, height: nil)
37
+ super(theme: theme)
34
38
  @header = Array(header).map(&:to_s)
35
39
  @rows = Array(rows)
36
40
  @selected_index = clamp_index(selected_index)
37
41
  @keymap = keymap
42
+ @height = height
38
43
  end
39
44
 
40
45
  # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
@@ -48,16 +53,17 @@ module Charming
48
53
  end
49
54
  end
50
55
 
51
- # Handles mouse events: a click within the body area selects the clicked row.
56
+ # Handles mouse events: a click within the body area selects the clicked row
57
+ # (relative to the visible window when a height is set).
52
58
  # Returns :handled on a successful click.
53
59
  def handle_mouse(event)
54
60
  return nil if rows.empty?
55
61
  return nil unless event.respond_to?(:click?) && event.click?
56
62
 
57
63
  clicked = event.y - HEADER_HEIGHT
58
- return nil if clicked.negative? || clicked >= rows.length
64
+ return nil if clicked.negative? || clicked >= visible_row_count
59
65
 
60
- @selected_index = clicked
66
+ @selected_index = viewport_start + clicked
61
67
  :handled
62
68
  end
63
69
 
@@ -95,7 +101,8 @@ module Charming
95
101
  kept + [merged]
96
102
  end
97
103
 
98
- # Applies the selected-row highlight and trims unused body rows below the actual row count.
104
+ # Applies the selected-row highlight, windows the body to the configured height,
105
+ # and trims unused body rows below the actual row count.
99
106
  def compact_layout(lines)
100
107
  return lines.join("\n") if lines.length < 4
101
108
 
@@ -103,13 +110,37 @@ module Charming
103
110
  body = rest.first(rows.length)
104
111
  bottom = rest[rows.length]
105
112
 
106
- highlighted = body.each_with_index.map do |line, index|
107
- (index == selected_index) ? "\e[7m#{line}\e[m" : line
113
+ window = body[viewport_start, visible_row_count] || []
114
+ highlighted = window.each_with_index.map do |line, index|
115
+ ((viewport_start + index) == selected_index) ? theme.selected.render(line) : line
108
116
  end
109
117
 
110
118
  [top, header_line, *highlighted, bottom].compact.join("\n")
111
119
  end
112
120
 
121
+ # The top body row of the visible window (0 when no height is set), keeping the
122
+ # selection in view.
123
+ def viewport_start
124
+ return 0 unless @height
125
+
126
+ Layout.selected_window_start(selected_index: selected_index, item_count: rows.length, window_size: @height)
127
+ end
128
+
129
+ # The number of body rows shown at once.
130
+ def visible_row_count
131
+ @height ? [@height, rows.length].min : rows.length
132
+ end
133
+
134
+ # Moves the selection up by one window.
135
+ def page_up
136
+ @selected_index = [selected_index - visible_row_count, 0].max
137
+ end
138
+
139
+ # Moves the selection down by one window.
140
+ def page_down
141
+ @selected_index = [selected_index + visible_row_count, rows.length - 1].min
142
+ end
143
+
113
144
  # Moves the selection up one row.
114
145
  def move_up
115
146
  @selected_index -= 1 if selected_index.positive?