charming 0.2.0 → 0.2.2

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -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/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Paginator tracks a current page over a collection and renders a compact
6
+ # page indicator: bubbles-style dots ("○ ● ○") or arabic "2/3". Pair it with
7
+ # a List or Table by slicing items through `page_items`.
8
+ class Paginator < Component
9
+ ACTIVE_DOT = "●"
10
+ INACTIVE_DOT = "○"
11
+
12
+ attr_reader :page, :per_page, :total
13
+
14
+ # *total* is the collection size, *per_page* the page size, *page* the
15
+ # 0-based starting page, and *format* either :dots (default) or :arabic.
16
+ def initialize(total:, per_page:, page: 0, format: :dots, theme: nil)
17
+ super(theme: theme)
18
+ @total = [total.to_i, 0].max
19
+ @per_page = [per_page.to_i, 1].max
20
+ @format = format
21
+ @page = page.to_i.clamp(0, page_count - 1)
22
+ end
23
+
24
+ # The number of pages — at least 1, even for an empty collection.
25
+ def page_count
26
+ [(total.to_f / per_page).ceil, 1].max
27
+ end
28
+
29
+ # The slice of *items* belonging to the current page.
30
+ def page_items(items)
31
+ items[page * per_page, per_page] || []
32
+ end
33
+
34
+ # Advances one page, clamping at the last. Returns self.
35
+ def next_page
36
+ @page = [page + 1, page_count - 1].min
37
+ self
38
+ end
39
+
40
+ # Steps back one page, clamping at the first. Returns self.
41
+ def prev_page
42
+ @page = [page - 1, 0].max
43
+ self
44
+ end
45
+
46
+ # Renders the page indicator in the configured format.
47
+ def render
48
+ return "#{page + 1}/#{page_count}" if @format == :arabic
49
+
50
+ Array.new(page_count) { |index| (index == page) ? ACTIVE_DOT : INACTIVE_DOT }.join(" ")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -12,14 +12,17 @@ module Charming
12
12
 
13
13
  # *total* is the maximum unit count. *complete* and *incomplete* are the characters used
14
14
  # for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
15
- # future format variants. *label* is an optional suffix shown after the bar.
16
- def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
15
+ # future format variants. *label* is an optional suffix shown after the bar. *gradient*
16
+ # is an optional ["#rrggbb", "#rrggbb"] pair that colors the filled region with a sweep
17
+ # across the full bar width.
18
+ def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil, gradient: nil)
17
19
  super()
18
20
  @total = [total.to_i, 0].max
19
21
  @complete = complete.to_s
20
22
  @incomplete = incomplete.to_s
21
23
  @bar_format = bar_format.to_sym
22
24
  @label = label
25
+ @gradient = gradient
23
26
  @current = 0
24
27
  end
25
28
 
@@ -45,9 +48,7 @@ module Charming
45
48
  def render
46
49
  width = [@total, 1].max
47
50
  completed = completed_width(width)
48
- incomplete = width - completed
49
- incomplete -= 1 if @current.zero?
50
- bar = (@complete * completed) + (@incomplete * incomplete)
51
+ bar = filled_cells(completed, width) + (@incomplete * (width - completed))
51
52
  result = "[" + bar + "]"
52
53
 
53
54
  return result unless @label
@@ -55,8 +56,28 @@ module Charming
55
56
  "#{result} #{@label}"
56
57
  end
57
58
 
59
+ # The current completion as an integer percentage (0-100).
60
+ def percent
61
+ return 0 unless @total.positive?
62
+
63
+ ((@current * 100) / @total.to_f).round
64
+ end
65
+
58
66
  private
59
67
 
68
+ # The filled portion of the bar: plain characters, or — with a gradient —
69
+ # each cell colored along a sweep spanning the full bar width, so the
70
+ # visible colors stay stable as the bar fills.
71
+ def filled_cells(completed, width)
72
+ return @complete * completed unless @gradient
73
+
74
+ span = [width - 1, 1].max
75
+ Array.new(completed) do |index|
76
+ color = UI::Gradient.blend(@gradient.first, @gradient.last, index.to_f / span)
77
+ UI::Style.new(foreground: color).render(@complete)
78
+ end.join
79
+ end
80
+
60
81
  # Returns the number of `complete` characters to draw, rounded to the nearest integer.
61
82
  def completed_width(width)
62
83
  return 0 unless @total.positive?
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Sparkline renders a series of numbers as a compact one-line bar graph using the eighth-block
6
+ # glyphs `▁▂▃▄▅▆▇█` — one cell per value, scaled between the series' min and max. Pure text, so it
7
+ # works on every terminal. Pass an optional `style:` ({Charming::UI::Style}) to colour it.
8
+ class Sparkline < Component
9
+ # The eight bar heights, shortest to tallest.
10
+ BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
11
+
12
+ # *values* is the numeric series. *style* optionally paints the result. *theme* is forwarded.
13
+ def initialize(values:, style: nil, theme: nil)
14
+ super(theme: theme)
15
+ @values = values
16
+ @style = style
17
+ end
18
+
19
+ # Renders one bar glyph per value (empty string for an empty series).
20
+ def render
21
+ return "" if @values.empty?
22
+
23
+ glyphs = @values.map { |value| BARS[level(value)] }.join
24
+ @style ? @style.render(glyphs) : glyphs
25
+ end
26
+
27
+ private
28
+
29
+ # The 0..7 bar index for *value*, scaled across the series range (flat series → lowest bar).
30
+ def level(value)
31
+ min, max = @values.minmax
32
+ return 0 if max == min
33
+
34
+ ((value - min).to_f / (max - min) * (BARS.length - 1)).round
35
+ end
36
+ end
37
+ end
38
+ end
@@ -9,13 +9,32 @@ module Charming
9
9
  # The default frame set: a 4-frame ASCII spinner.
10
10
  DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
11
11
 
12
+ # Named frame presets, mirroring the roster popularized by charm.sh's bubbles.
13
+ STYLES = {
14
+ line: DEFAULT_FRAMES,
15
+ dots: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze,
16
+ mini_dot: %w[⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈].freeze,
17
+ jump: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠].freeze,
18
+ pulse: %w[█ ▓ ▒ ░].freeze,
19
+ points: ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"].freeze,
20
+ globe: %w[🌍 🌎 🌏].freeze,
21
+ moon: %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘].freeze,
22
+ meter: %w[▱▱▱ ▰▱▱ ▰▰▱ ▰▰▰ ▰▰▱ ▰▱▱].freeze,
23
+ hamburger: %w[☱ ☲ ☴ ☲].freeze,
24
+ ellipsis: [" ", ". ", ".. ", "..."].freeze
25
+ }.freeze
26
+
12
27
  # The current frame list, frame index, and optional label string.
13
28
  attr_reader :frames, :index, :label
14
29
 
15
- # *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
16
- # *index* is the starting frame index. *label* is an optional suffix shown after the frame.
17
- def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
30
+ # *style* picks a named preset from STYLES (default :line). *frames* overrides the
31
+ # preset with any array of frame strings. *index* is the starting frame index.
32
+ # *label* is an optional suffix shown after the frame.
33
+ def initialize(style: :line, frames: nil, index: 0, label: nil)
18
34
  super()
35
+ frames ||= STYLES.fetch(style.to_sym) do
36
+ raise ArgumentError, "unknown spinner style: #{style.inspect} (available: #{STYLES.keys.join(", ")})"
37
+ end
19
38
  raise ArgumentError, "frames cannot be empty" if frames.empty?
20
39
 
21
40
  @frames = frames
@@ -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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Stopwatch is a count-up display. Drive it from a controller timer: call
6
+ # `tick` per interval; elapsed time accumulates only while running.
7
+ class Stopwatch < Component
8
+ attr_reader :elapsed, :label
9
+
10
+ # *label* is an optional suffix shown after the time.
11
+ def initialize(label: nil, theme: nil)
12
+ super(theme: theme)
13
+ @elapsed = 0
14
+ @running = false
15
+ @label = label
16
+ end
17
+
18
+ # Starts accumulating time. Returns self.
19
+ def start
20
+ @running = true
21
+ self
22
+ end
23
+
24
+ # Pauses accumulation. Returns self.
25
+ def stop
26
+ @running = false
27
+ self
28
+ end
29
+
30
+ # True while the stopwatch is accumulating time.
31
+ def running?
32
+ @running
33
+ end
34
+
35
+ # Adds *seconds* (default 1) when running; a no-op otherwise. Returns self.
36
+ def tick(seconds = 1)
37
+ @elapsed += seconds.to_i if running?
38
+ self
39
+ end
40
+
41
+ # Stops and zeroes the elapsed time. Returns self.
42
+ def reset
43
+ @elapsed = 0
44
+ @running = false
45
+ self
46
+ end
47
+
48
+ # Renders the elapsed time (with the label appended when present).
49
+ def render
50
+ clock = TimeDisplay.clock(elapsed)
51
+ label ? "#{clock} #{label}" : clock
52
+ end
53
+ end
54
+ end
55
+ 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