charming 0.2.1 → 0.2.3

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. data/lib/charming/generators/templates/app/view.template +0 -21
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Image displays a {Charming::Image::Source} inline in the terminal, sized to *rows*×*cols*
6
+ # character cells. On a graphics-capable terminal (Phase 1: Ghostty/Kitty) it returns a block of
7
+ # Unicode placeholder cells and registers the image's one-time out-of-band transmission; on other
8
+ # terminals it returns *fallback* so layouts degrade gracefully.
9
+ #
10
+ # Like {Charming::Components::Audio}, the view itself is thin: the {Charming::Image::Source}
11
+ # (held in `session`) owns the bytes and transmit state. The placeholder block is a normal
12
+ # width-`cols` string that composes with `row`/`column`/`box` like any other view output.
13
+ class Image < Component
14
+ # *source* is the {Charming::Image::Source} to display. *rows*/*cols* size the image in
15
+ # character cells. *fallback* is shown when the terminal lacks graphics support. *theme* is
16
+ # forwarded to the view layer.
17
+ def initialize(source:, rows:, cols:, fallback: "", theme: nil)
18
+ super(theme: theme)
19
+ @source = source
20
+ @rows = rows
21
+ @cols = cols
22
+ @fallback = fallback
23
+ end
24
+
25
+ # Returns the placeholder block (registering the transmit once) on a graphics-capable terminal,
26
+ # otherwise the fallback string.
27
+ def render
28
+ return @fallback.to_s unless @source.supports_graphics?
29
+
30
+ unless @source.transmitted?
31
+ Charming::Escape.register(@source.transmit(rows: @rows, cols: @cols))
32
+ @source.mark_transmitted
33
+ end
34
+ @source.placement(rows: @rows, cols: @cols)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -19,20 +19,38 @@ module Charming
19
19
  end: :move_end
20
20
  }.freeze
21
21
 
22
- # The item array and the currently selected index within it.
23
- attr_reader :items, :selected_index
22
+ # The currently selected index (within the filtered view) and active filter query.
23
+ attr_reader :selected_index, :filter
24
24
 
25
25
  # *items* is the array of selectable objects. *selected_index* defaults to 0.
26
26
  # *height* optionally constrains the visible window; *label* is a callable that
27
27
  # extracts the display string from an item (defaults to `to_s`).
28
28
  # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
29
- def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
29
+ # *filter* optionally narrows items by fuzzy-matching the label (see FuzzyMatcher);
30
+ # navigation, rendering, and selection all operate on the filtered view.
31
+ def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim, filter: nil)
30
32
  super(theme: theme)
31
- @items = items
33
+ @source_items = items
32
34
  @selected_index = selected_index
33
35
  @height = height
34
36
  @label = label || :to_s.to_proc
35
37
  @keymap = keymap
38
+ @filter = filter
39
+ clamp_position
40
+ end
41
+
42
+ # The visible items: the source list narrowed by the active filter (best
43
+ # fuzzy match first), or the full source list when no filter is set.
44
+ def items
45
+ return @source_items if filter.nil? || filter.to_s.empty?
46
+
47
+ FuzzyMatcher.filter(filter, @source_items, &@label)
48
+ end
49
+
50
+ # Replaces the filter query (nil clears it) and reclamps the selection to
51
+ # the narrowed view.
52
+ def filter=(query)
53
+ @filter = query
36
54
  clamp_position
37
55
  end
38
56
 
@@ -39,8 +39,8 @@ module Charming
39
39
  result
40
40
  end
41
41
 
42
- # Renders the modal as a bordered, padded string with the title and help lines stacked
43
- # above the content.
42
+ # Renders the modal as a bordered, padded string with the title above the content
43
+ # and the help footer below it.
44
44
  def render
45
45
  box(column(*lines, gap: 1), style: modal_style)
46
46
  end
@@ -49,9 +49,9 @@ module Charming
49
49
 
50
50
  attr_reader :content, :title, :help, :width
51
51
 
52
- # Returns the array of non-nil lines: title, help, content.
52
+ # Returns the array of non-nil lines: title, content, help footer.
53
53
  def lines
54
- [title_line, help_line, body_content].compact
54
+ [title_line, body_content, help_line].compact
55
55
  end
56
56
 
57
57
  # The body: windowed through a Viewport when scrollable, otherwise rendered directly.
@@ -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,8 +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
- bar = (@complete * completed) + (@incomplete * incomplete)
51
+ bar = filled_cells(completed, width) + (@incomplete * (width - completed))
50
52
  result = "[" + bar + "]"
51
53
 
52
54
  return result unless @label
@@ -54,8 +56,28 @@ module Charming
54
56
  "#{result} #{@label}"
55
57
  end
56
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
+
57
66
  private
58
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
+
59
81
  # Returns the number of `complete` characters to draw, rounded to the nearest integer.
60
82
  def completed_width(width)
61
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,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 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
@@ -72,12 +72,30 @@ module Charming
72
72
  rows[selected_index]
73
73
  end
74
74
 
75
+ # Sorts the body rows by *column* (a header label or 0-based index).
76
+ # Numeric-looking cells compare numerically; everything else as strings.
77
+ # The sorted column is marked ▲/▼ in the rendered header. Returns self.
78
+ def sort_by!(column, direction: :asc)
79
+ @sort_column = column_index(column)
80
+ @sort_direction = direction
81
+ sorted = @rows.sort_by { |row| sort_key(row) }
82
+ @rows = (direction == :desc) ? sorted.reverse : sorted
83
+ self
84
+ end
85
+
86
+ # Sorts by *column*, flipping the direction on repeated calls for the same
87
+ # column (ascending first). Returns self.
88
+ def toggle_sort(column)
89
+ flipping = @sort_column == column_index(column) && @sort_direction == :asc
90
+ sort_by!(column, direction: flipping ? :desc : :asc)
91
+ end
92
+
75
93
  # Renders the table to a string. Returns a placeholder when both header and rows are empty.
76
94
  def render
77
95
  return "(empty table)" if header.empty? && rows.empty?
78
96
 
79
97
  normalized = rows.map { |row| normalize_row(row) }
80
- lines = TTY::Table.new(header: header, rows: normalized)
98
+ lines = TTY::Table.new(header: sort_marked_header, rows: normalized)
81
99
  .render(:unicode)
82
100
  .lines(chomp: true)
83
101
 
@@ -161,6 +179,29 @@ module Charming
161
179
  @selected_index = rows.length - 1
162
180
  end
163
181
 
182
+ # Resolves *column* (label or 0-based index) to a column index.
183
+ def column_index(column)
184
+ return column if column.is_a?(Integer) && column.between?(0, header.length - 1)
185
+
186
+ header.index(column.to_s) ||
187
+ raise(ArgumentError, "unknown column: #{column.inspect} (columns: #{header.join(", ")})")
188
+ end
189
+
190
+ # The comparable key for *row* at the active sort column: [0, number] for
191
+ # numeric-looking cells, [1, string] otherwise, so mixed columns still sort.
192
+ def sort_key(row)
193
+ cell = normalize_row(row)[@sort_column].to_s
194
+ cell.match?(/\A-?\d+(\.\d+)?\z/) ? [0, cell.to_f] : [1, cell]
195
+ end
196
+
197
+ # The header with the sorted column marked ▲ (ascending) or ▼ (descending).
198
+ def sort_marked_header
199
+ return header unless @sort_column
200
+
201
+ marker = (@sort_direction == :desc) ? "▼" : "▲"
202
+ header.each_with_index.map { |label, index| (index == @sort_column) ? "#{label} #{marker}" : label }
203
+ end
204
+
164
205
  # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
165
206
  def clamp_index(value)
166
207
  return 0 if rows.empty?
@@ -8,7 +8,7 @@ module Charming
8
8
  # for long buffers. Vertical movement preserves a "preferred column" so left/right
9
9
  # navigation feels stable.
10
10
  class TextArea < Component
11
- # The current text value, cursor byte offset, top-visible row offset, and remembered
11
+ # The current text value, cursor character offset, top-visible row offset, and remembered
12
12
  # column for vertical navigation, respectively.
13
13
  attr_reader :value, :cursor, :offset, :preferred_column
14
14
 
@@ -212,7 +212,7 @@ module Charming
212
212
  [row, column]
213
213
  end
214
214
 
215
- # Returns the byte offset where line *row* begins in the value.
215
+ # Returns the character offset where line *row* begins in the value.
216
216
  def line_start(row)
217
217
  lines.first(row).sum(&:length) + row
218
218
  end
@@ -259,8 +259,7 @@ module Charming
259
259
  def render_line(line)
260
260
  return line unless width
261
261
 
262
- clipped = UI.visible_slice(line, 0, width)
263
- clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
262
+ UI::Width.pad_to(UI.visible_slice(line, 0, width), width)
264
263
  end
265
264
 
266
265
  # Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
@@ -25,7 +25,7 @@ module Charming
25
25
  delete: :delete_at_cursor
26
26
  }.freeze
27
27
 
28
- # The current input string and the byte offset of the cursor within it.
28
+ # The current input string and the character offset of the cursor within it.
29
29
  attr_reader :value, :cursor
30
30
 
31
31
  # *value* is the initial text. *placeholder* is shown when the value is empty.
@@ -50,12 +50,17 @@ module Charming
50
50
  true
51
51
  end
52
52
 
53
- # Handles key events. Inserts printable characters, recalls history on up/down
54
- # (when enabled), otherwise dispatches via KEY_ACTIONS.
55
- # Returns :handled when the event was consumed, nil otherwise.
53
+ # Handles key events. Inserts printable characters, submits on Enter
54
+ # (returning `[:submitted, value]` so a focused slot dispatches
55
+ # `<slot>_submitted(value)`), recalls history on up/down (when enabled),
56
+ # otherwise dispatches via KEY_ACTIONS.
57
+ # Returns :handled or `[:submitted, value]` when the event was consumed, nil otherwise.
56
58
  def handle_key(event)
57
59
  return :handled if character_event?(event) && insert(event.char)
58
- return :handled if history_event(Charming.key_of(event))
60
+
61
+ key = Charming.key_of(event)
62
+ return [:submitted, value] if key == :enter
63
+ return :handled if history_event(key)
59
64
 
60
65
  super
61
66
  end
@@ -89,7 +94,7 @@ module Charming
89
94
  !char.match?(/[[:cntrl:]]/)
90
95
  end
91
96
 
92
- # Inserts *char* at the cursor and advances the cursor by its byte length.
97
+ # Inserts *char* at the cursor and advances the cursor by its character length.
93
98
  def insert(char)
94
99
  @value = value[0...cursor] + char + value[cursor..]
95
100
  @cursor += char.length
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # TimeDisplay formats whole-second durations as clock strings: "mm:ss", or
6
+ # "h:mm:ss" once an hour is reached. Shared by Timer and Stopwatch.
7
+ module TimeDisplay
8
+ module_function
9
+
10
+ def clock(total_seconds)
11
+ seconds = [total_seconds.to_i, 0].max
12
+ hours, remainder = seconds.divmod(3600)
13
+ minutes, secs = remainder.divmod(60)
14
+ return format("%d:%02d:%02d", hours, minutes, secs) if hours.positive?
15
+
16
+ format("%02d:%02d", minutes, secs)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Timer is a countdown display. Drive it from a controller timer: call
6
+ # `tick` per interval and check `expired?` to act when time runs out.
7
+ class Timer < Component
8
+ attr_reader :duration, :remaining, :label
9
+
10
+ # *duration* is the countdown length in seconds. *label* is an optional
11
+ # suffix shown after the time.
12
+ def initialize(duration:, label: nil, theme: nil)
13
+ super(theme: theme)
14
+ @duration = [duration.to_i, 0].max
15
+ @remaining = @duration
16
+ @label = label
17
+ end
18
+
19
+ # Counts down by *seconds* (default 1), clamping at zero. Returns self.
20
+ def tick(seconds = 1)
21
+ @remaining = [@remaining - seconds, 0].max
22
+ self
23
+ end
24
+
25
+ # True once the countdown has reached zero.
26
+ def expired?
27
+ remaining.zero?
28
+ end
29
+
30
+ # Restores the full duration. Returns self.
31
+ def reset
32
+ @remaining = duration
33
+ self
34
+ end
35
+
36
+ # Renders the remaining time (with the label appended when present).
37
+ def render
38
+ clock = TimeDisplay.clock(remaining)
39
+ label ? "#{clock} #{label}" : clock
40
+ end
41
+ end
42
+ end
43
+ end
@@ -3,9 +3,10 @@
3
3
  module Charming
4
4
  module Components
5
5
  # Toast is a small auto-dismissing notification panel, usually composited as an
6
- # overlay anchored to a screen corner. Controllers manage its lifetime with the
7
- # `show_toast` / `dismiss_toast` helpers (which pair it with a timer); the component
8
- # itself just renders the styled box.
6
+ # overlay anchored to a screen corner. The component just renders the styled box;
7
+ # apps manage its lifetime themselves typically a session-backed hash with an
8
+ # expiry deadline plus a controller timer that clears it (see the journal example's
9
+ # ApplicationController#show_toast / #expire_toast).
9
10
  #
10
11
  # Toast.new(message: "Saved!", kind: :success)
11
12
  #
@@ -18,7 +18,7 @@ module Charming
18
18
  end
19
19
 
20
20
  def display_width
21
- lines.map { |line| UI::Width.measure(line) }.max || 0
21
+ UI::Width.widest(lines)
22
22
  end
23
23
 
24
24
  private
@@ -59,7 +59,7 @@ module Charming
59
59
  end
60
60
 
61
61
  def pad_line(line, target_width)
62
- line + (" " * [target_width - UI::Width.measure(line), 0].max)
62
+ UI::Width.pad_to(line, target_width)
63
63
  end
64
64
 
65
65
  def ansi?(token)
@@ -25,7 +25,10 @@ module Charming
25
25
  # *content* may be a string, an array of lines, or any object responding to `render`.
26
26
  # *width* and *height* constrain the visible window; *offset* is the top-visible row
27
27
  # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
28
- def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
28
+ # *follow* pins the viewport to the bottom of the content regardless of *offset*, so
29
+ # callers that rebuild each event stick to new content; pair with `at_bottom?` to
30
+ # decide whether to keep following after the user scrolls.
31
+ def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim, follow: false)
29
32
  super()
30
33
  @content = content
31
34
  @width = width
@@ -34,6 +37,7 @@ module Charming
34
37
  @wrap = wrap
35
38
  @keymap = keymap
36
39
  position.clamp(bounds)
40
+ position.move_to(max_offset, bounds) if follow
37
41
  end
38
42
 
39
43
  # Renders the visible window of content as a multi-line string.
@@ -51,6 +55,12 @@ module Charming
51
55
  @position.column
52
56
  end
53
57
 
58
+ # True when the last content row is visible. Callers persisting follow mode
59
+ # store this after key dispatch to decide whether the next build follows.
60
+ def at_bottom?
61
+ offset >= max_offset
62
+ end
63
+
54
64
  # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
55
65
  # visible row to the clicked position. Returns :handled on success.
56
66
  def handle_mouse(event)
@@ -45,7 +45,7 @@ module Charming
45
45
  end
46
46
 
47
47
  def wrap(value, width:)
48
- TextWrapper.new(width: width).wrap(value)
48
+ UI::TextWrapper.new(width: width).wrap(value)
49
49
  end
50
50
 
51
51
  def style_for(name, fallback:)
@@ -185,6 +185,7 @@ module Charming
185
185
 
186
186
  def self.builtin(name)
187
187
  key = name.to_s.tr("-", "_").to_sym
188
+ key = UI::Background.dark? ? :dark : :light if key == :auto
188
189
  raise ArgumentError, "unknown markdown style: #{name.inspect}" unless BUILT_INS.key?(key)
189
190
 
190
191
  from_hash(BUILT_INS.fetch(key))
@@ -30,8 +30,7 @@ module Charming
30
30
  end
31
31
 
32
32
  def table_cell(row, width, index)
33
- value = row[index].to_s
34
- " #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
33
+ " #{UI::Width.pad_to(row[index].to_s, width)} "
35
34
  end
36
35
 
37
36
  def table_separator
@@ -40,7 +39,7 @@ module Charming
40
39
 
41
40
  def widths
42
41
  @widths ||= Array.new(column_count) do |index|
43
- rows.map { |row| UI::Width.measure(row[index].to_s) }.max || 0
42
+ UI::Width.widest(rows.map { |row| row[index].to_s })
44
43
  end
45
44
  end
46
45