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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -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 +27 -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 +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- 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/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- 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 +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- 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/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- 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/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- 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
|
-
# *
|
|
12
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# *
|
|
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,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
|