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