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.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/lib/charming/application.rb +48 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +13 -40
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +13 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +133 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +95 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +21 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +3 -4
- data/lib/charming/presentation/components/text_input.rb +11 -6
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +4 -3
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/runtime.rb +167 -90
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- metadata +34 -9
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- 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
|
|
23
|
-
attr_reader :
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
43
|
-
#
|
|
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,
|
|
52
|
+
# Returns the array of non-nil lines: title, content, help footer.
|
|
53
53
|
def lines
|
|
54
|
-
[title_line,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# *
|
|
16
|
-
#
|
|
17
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
54
|
-
# (
|
|
55
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
#
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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|
|
|
42
|
+
UI::Width.widest(rows.map { |row| row[index].to_s })
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
45
|
|