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,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# ColorSupport detects the terminal's color capability and downconverts colors so
|
|
6
|
+
# themes written in truecolor degrade gracefully on less-capable terminals.
|
|
7
|
+
#
|
|
8
|
+
# Levels (best to worst): :truecolor, :color256, :color16, :none.
|
|
9
|
+
#
|
|
10
|
+
# Detection honors NO_COLOR, then COLORTERM (truecolor/24bit), then TERM.
|
|
11
|
+
# `UI::ColorSupport.level = :color256` overrides detection (useful in tests and
|
|
12
|
+
# for user preference).
|
|
13
|
+
module ColorSupport
|
|
14
|
+
LEVELS = %i[none color16 color256 truecolor].freeze
|
|
15
|
+
|
|
16
|
+
# The 6-level RGB ramp used by the xterm 256-color cube (indices 16-231).
|
|
17
|
+
CUBE_LEVELS = [0, 95, 135, 175, 215, 255].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The active color level: the explicit override or the detected level (memoized).
|
|
22
|
+
def level
|
|
23
|
+
@level ||= detect(ENV)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Overrides the detected level (nil resets to auto-detection on next access).
|
|
27
|
+
def level=(value)
|
|
28
|
+
raise ArgumentError, "unknown color level: #{value.inspect}" if value && !LEVELS.include?(value)
|
|
29
|
+
|
|
30
|
+
@level = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Detects the color capability from an environment hash.
|
|
34
|
+
def detect(env)
|
|
35
|
+
return :none if env["NO_COLOR"] && !env["NO_COLOR"].empty?
|
|
36
|
+
return :truecolor if %w[truecolor 24bit].include?(env["COLORTERM"])
|
|
37
|
+
|
|
38
|
+
term = env["TERM"].to_s
|
|
39
|
+
return :none if term.empty? || term == "dumb"
|
|
40
|
+
return :truecolor if term.include?("direct")
|
|
41
|
+
return :color256 if term.include?("256color")
|
|
42
|
+
|
|
43
|
+
:color16
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the active level is at least *required* (e.g., `at_least?(:color256)`).
|
|
47
|
+
def at_least?(required)
|
|
48
|
+
LEVELS.index(level) >= LEVELS.index(required)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Converts "#rrggbb" to the nearest xterm 256-color index (cube or grayscale ramp).
|
|
52
|
+
def hex_to_256(hex)
|
|
53
|
+
r, g, b = hex_components(hex)
|
|
54
|
+
cube = cube_index(r, g, b)
|
|
55
|
+
gray = gray_index(r, g, b)
|
|
56
|
+
(color_distance([r, g, b], index_to_rgb(cube)) <= color_distance([r, g, b], index_to_rgb(gray))) ? cube : gray
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Converts "#rrggbb" to the nearest of the 16 basic ANSI colors, returned as the
|
|
60
|
+
# SGR foreground code (30-37 or 90-97).
|
|
61
|
+
def hex_to_16(hex)
|
|
62
|
+
rgb = hex_components(hex)
|
|
63
|
+
best = basic_palette.min_by { |_code, basic_rgb| color_distance(rgb, basic_rgb) }
|
|
64
|
+
best.first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Converts a 256-color index to the nearest basic ANSI SGR foreground code.
|
|
68
|
+
def index_to_16(index)
|
|
69
|
+
rgb = index_to_rgb(index)
|
|
70
|
+
best = basic_palette.min_by { |_code, basic_rgb| color_distance(rgb, basic_rgb) }
|
|
71
|
+
best.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -- conversion internals ----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def hex_components(hex)
|
|
77
|
+
digits = hex.to_s.delete_prefix("#")
|
|
78
|
+
[digits[0..1].to_i(16), digits[2..3].to_i(16), digits[4..5].to_i(16)]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Index in the 6x6x6 cube (16-231) closest to the RGB triple.
|
|
82
|
+
def cube_index(r, g, b)
|
|
83
|
+
16 + (36 * nearest_cube_level(r)) + (6 * nearest_cube_level(g)) + nearest_cube_level(b)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Index in the grayscale ramp (232-255) closest to the RGB triple's luminance.
|
|
87
|
+
def gray_index(r, g, b)
|
|
88
|
+
gray = ((r + g + b) / 3.0).round
|
|
89
|
+
step = ((gray - 8) / 10.0).round.clamp(0, 23)
|
|
90
|
+
232 + step
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def nearest_cube_level(component)
|
|
94
|
+
CUBE_LEVELS.each_index.min_by { |index| (CUBE_LEVELS[index] - component).abs }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The RGB triple a 256-color *index* renders as.
|
|
98
|
+
def index_to_rgb(index)
|
|
99
|
+
if index >= 232
|
|
100
|
+
gray = 8 + (index - 232) * 10
|
|
101
|
+
[gray, gray, gray]
|
|
102
|
+
elsif index >= 16
|
|
103
|
+
offset = index - 16
|
|
104
|
+
[CUBE_LEVELS[offset / 36], CUBE_LEVELS[(offset / 6) % 6], CUBE_LEVELS[offset % 6]]
|
|
105
|
+
else
|
|
106
|
+
basic_palette.find { |code, _| basic_index_for_code(code) == index }&.last || [0, 0, 0]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def color_distance(a, b)
|
|
111
|
+
(a[0] - b[0])**2 + (a[1] - b[1])**2 + (a[2] - b[2])**2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The 16 basic ANSI colors as [SGR foreground code, RGB] pairs.
|
|
115
|
+
def basic_palette
|
|
116
|
+
@basic_palette ||= {
|
|
117
|
+
30 => [0, 0, 0], 31 => [205, 49, 49], 32 => [13, 188, 121], 33 => [229, 229, 16],
|
|
118
|
+
34 => [36, 114, 200], 35 => [188, 63, 188], 36 => [17, 168, 205], 37 => [229, 229, 229],
|
|
119
|
+
90 => [102, 102, 102], 91 => [241, 76, 76], 92 => [35, 209, 139], 93 => [245, 245, 67],
|
|
120
|
+
94 => [59, 142, 234], 95 => [214, 112, 214], 96 => [41, 184, 219], 97 => [255, 255, 255]
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def basic_index_for_code(code)
|
|
125
|
+
(code < 90) ? code - 30 : code - 90 + 8
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -109,6 +109,13 @@ module Charming
|
|
|
109
109
|
end
|
|
110
110
|
alias_method :[], :style
|
|
111
111
|
|
|
112
|
+
# Returns a new Theme with *overrides* (token name → style spec hash) merged over
|
|
113
|
+
# this theme's tokens. Override colors are literal (hex strings, named symbols, or
|
|
114
|
+
# 256 indexes) — the parent's palette names are already resolved.
|
|
115
|
+
def merge(overrides, background: @background)
|
|
116
|
+
self.class.new(@tokens.merge(symbolize_keys(overrides.to_h)), background: background)
|
|
117
|
+
end
|
|
118
|
+
|
|
112
119
|
def method_missing(name, ...)
|
|
113
120
|
return style(name) if @tokens.key?(name)
|
|
114
121
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Catppuccin Latte",
|
|
3
|
+
"id": "catppuccin-latte",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"text": "#4C4F69",
|
|
7
|
+
"subtext": "#6C6F85",
|
|
8
|
+
"overlay": "#9CA0B0",
|
|
9
|
+
"background": "#EFF1F5",
|
|
10
|
+
"surface": "#CCD0DA",
|
|
11
|
+
"mauve": "#8839EF",
|
|
12
|
+
"peach": "#FE640B",
|
|
13
|
+
"sky": "#04A5E5",
|
|
14
|
+
"lavender": "#7287FD",
|
|
15
|
+
"green": "#40A02B",
|
|
16
|
+
"yellow": "#DF8E1D",
|
|
17
|
+
"red": "#D20F39"
|
|
18
|
+
},
|
|
19
|
+
"styles": {
|
|
20
|
+
"text": {"foreground": "text", "background": "background"},
|
|
21
|
+
"muted": {"foreground": "subtext", "background": "background"},
|
|
22
|
+
"title": {"foreground": "mauve", "background": "background", "bold": true},
|
|
23
|
+
"selected": {"foreground": "text", "background": "surface", "bold": true},
|
|
24
|
+
"header": {"foreground": "text", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
25
|
+
"header_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
26
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "overlay"}, "padding": [1, 1]},
|
|
27
|
+
"main": {"foreground": "text", "background": "background", "padding": [1, 2]},
|
|
28
|
+
"footer": {"foreground": "overlay", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
29
|
+
"modal": {"foreground": "text", "background": "background", "border": {"style": "rounded", "foreground": "lavender"}, "padding": [1, 2]},
|
|
30
|
+
"palette_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
31
|
+
"border": {"foreground": "overlay", "background": "background"},
|
|
32
|
+
"info": {"foreground": "sky", "background": "background"},
|
|
33
|
+
"warn": {"foreground": "peach", "background": "background"}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Catppuccin Mocha",
|
|
3
|
+
"id": "catppuccin-mocha",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"text": "#CDD6F4",
|
|
7
|
+
"subtext": "#A6ADC8",
|
|
8
|
+
"overlay": "#6C7086",
|
|
9
|
+
"background": "#1E1E2E",
|
|
10
|
+
"surface": "#313244",
|
|
11
|
+
"mauve": "#CBA6F7",
|
|
12
|
+
"peach": "#FAB387",
|
|
13
|
+
"sky": "#89DCEB",
|
|
14
|
+
"lavender": "#B4BEFE",
|
|
15
|
+
"green": "#A6E3A1",
|
|
16
|
+
"yellow": "#F9E2AF",
|
|
17
|
+
"red": "#F38BA8"
|
|
18
|
+
},
|
|
19
|
+
"styles": {
|
|
20
|
+
"text": {"foreground": "text", "background": "background"},
|
|
21
|
+
"muted": {"foreground": "subtext", "background": "background"},
|
|
22
|
+
"title": {"foreground": "mauve", "background": "background", "bold": true},
|
|
23
|
+
"selected": {"foreground": "text", "background": "surface", "bold": true},
|
|
24
|
+
"header": {"foreground": "text", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
25
|
+
"header_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
26
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "overlay"}, "padding": [1, 1]},
|
|
27
|
+
"main": {"foreground": "text", "background": "background", "padding": [1, 2]},
|
|
28
|
+
"footer": {"foreground": "overlay", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
29
|
+
"modal": {"foreground": "text", "background": "background", "border": {"style": "rounded", "foreground": "lavender"}, "padding": [1, 2]},
|
|
30
|
+
"palette_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
31
|
+
"border": {"foreground": "overlay", "background": "background"},
|
|
32
|
+
"info": {"foreground": "sky", "background": "background"},
|
|
33
|
+
"warn": {"foreground": "peach", "background": "background"}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Gruvbox Dark",
|
|
3
|
+
"id": "gruvbox-dark",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"fg": "#EBDBB2",
|
|
7
|
+
"gray": "#928374",
|
|
8
|
+
"background": "#282828",
|
|
9
|
+
"surface": "#3C3836",
|
|
10
|
+
"yellow": "#FABD2F",
|
|
11
|
+
"orange": "#FE8019",
|
|
12
|
+
"aqua": "#8EC07C",
|
|
13
|
+
"blue": "#83A598",
|
|
14
|
+
"green": "#B8BB26",
|
|
15
|
+
"red": "#FB4934"
|
|
16
|
+
},
|
|
17
|
+
"styles": {
|
|
18
|
+
"text": {"foreground": "fg", "background": "background"},
|
|
19
|
+
"muted": {"foreground": "gray", "background": "background"},
|
|
20
|
+
"title": {"foreground": "yellow", "background": "background", "bold": true},
|
|
21
|
+
"selected": {"foreground": "fg", "background": "surface", "bold": true},
|
|
22
|
+
"header": {"foreground": "fg", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "gray"}, "padding": [0, 1]},
|
|
23
|
+
"header_accent": {"foreground": "orange", "background": "background", "bold": true},
|
|
24
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "gray"}, "padding": [1, 1]},
|
|
25
|
+
"main": {"foreground": "fg", "background": "background", "padding": [1, 2]},
|
|
26
|
+
"footer": {"foreground": "gray", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "gray"}, "padding": [0, 1]},
|
|
27
|
+
"modal": {"foreground": "fg", "background": "background", "border": {"style": "rounded", "foreground": "yellow"}, "padding": [1, 2]},
|
|
28
|
+
"palette_accent": {"foreground": "orange", "background": "background", "bold": true},
|
|
29
|
+
"border": {"foreground": "gray", "background": "background"},
|
|
30
|
+
"info": {"foreground": "blue", "background": "background"},
|
|
31
|
+
"warn": {"foreground": "orange", "background": "background"}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Nord",
|
|
3
|
+
"id": "nord",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"snow": "#D8DEE9",
|
|
7
|
+
"comment": "#616E88",
|
|
8
|
+
"background": "#2E3440",
|
|
9
|
+
"surface": "#3B4252",
|
|
10
|
+
"frost_blue": "#88C0D0",
|
|
11
|
+
"frost_deep": "#81A1C1",
|
|
12
|
+
"aurora_yellow": "#EBCB8B",
|
|
13
|
+
"aurora_green": "#A3BE8C",
|
|
14
|
+
"aurora_red": "#BF616A"
|
|
15
|
+
},
|
|
16
|
+
"styles": {
|
|
17
|
+
"text": {"foreground": "snow", "background": "background"},
|
|
18
|
+
"muted": {"foreground": "comment", "background": "background"},
|
|
19
|
+
"title": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
20
|
+
"selected": {"foreground": "snow", "background": "surface", "bold": true},
|
|
21
|
+
"header": {"foreground": "snow", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "comment"}, "padding": [0, 1]},
|
|
22
|
+
"header_accent": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
23
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "comment"}, "padding": [1, 1]},
|
|
24
|
+
"main": {"foreground": "snow", "background": "background", "padding": [1, 2]},
|
|
25
|
+
"footer": {"foreground": "comment", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "comment"}, "padding": [0, 1]},
|
|
26
|
+
"modal": {"foreground": "snow", "background": "background", "border": {"style": "rounded", "foreground": "frost_blue"}, "padding": [1, 2]},
|
|
27
|
+
"palette_accent": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
28
|
+
"border": {"foreground": "comment", "background": "background"},
|
|
29
|
+
"info": {"foreground": "frost_deep", "background": "background"},
|
|
30
|
+
"warn": {"foreground": "aurora_yellow", "background": "background"}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Tokyo Night",
|
|
3
|
+
"id": "tokyonight",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"fg": "#C0CAF5",
|
|
7
|
+
"comment": "#565F89",
|
|
8
|
+
"background": "#1A1B26",
|
|
9
|
+
"surface": "#292E42",
|
|
10
|
+
"blue": "#7AA2F7",
|
|
11
|
+
"cyan": "#7DCFFF",
|
|
12
|
+
"yellow": "#E0AF68",
|
|
13
|
+
"magenta": "#BB9AF7",
|
|
14
|
+
"green": "#9ECE6A",
|
|
15
|
+
"red": "#F7768E",
|
|
16
|
+
"orange": "#FF9E64"
|
|
17
|
+
},
|
|
18
|
+
"styles": {
|
|
19
|
+
"text": {"foreground": "fg", "background": "background"},
|
|
20
|
+
"muted": {"foreground": "comment", "background": "background"},
|
|
21
|
+
"title": {"foreground": "blue", "background": "background", "bold": true},
|
|
22
|
+
"selected": {"foreground": "fg", "background": "surface", "bold": true},
|
|
23
|
+
"header": {"foreground": "fg", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "comment"}, "padding": [0, 1]},
|
|
24
|
+
"header_accent": {"foreground": "magenta", "background": "background", "bold": true},
|
|
25
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "comment"}, "padding": [1, 1]},
|
|
26
|
+
"main": {"foreground": "fg", "background": "background", "padding": [1, 2]},
|
|
27
|
+
"footer": {"foreground": "comment", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "comment"}, "padding": [0, 1]},
|
|
28
|
+
"modal": {"foreground": "fg", "background": "background", "border": {"style": "rounded", "foreground": "magenta"}, "padding": [1, 2]},
|
|
29
|
+
"palette_accent": {"foreground": "magenta", "background": "background", "bold": true},
|
|
30
|
+
"border": {"foreground": "comment", "background": "background"},
|
|
31
|
+
"info": {"foreground": "cyan", "background": "background"},
|
|
32
|
+
"warn": {"foreground": "yellow", "background": "background"}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -8,12 +8,37 @@ module Charming
|
|
|
8
8
|
# ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
|
|
9
9
|
# formatting codes so layout primitives can calculate exact character positions.
|
|
10
10
|
module Width
|
|
11
|
-
|
|
11
|
+
# Matches OSC sequences (e.g. OSC 8 hyperlinks, terminated by BEL or ST),
|
|
12
|
+
# CSI sequences (SGR colors/attributes, cursor movement), and single-character
|
|
13
|
+
# Fe escapes. The OSC branch must come first and the Fe class must exclude
|
|
14
|
+
# "[" and "]", or "\e]" would match as a bare Fe escape and leave the OSC
|
|
15
|
+
# payload counted as visible text.
|
|
16
|
+
ANSI_PATTERN = /\e(?:\][^\a]*?(?:\a|\e\\)|\[[0-9;?]*[@-~]|[@-Z\\^_])/
|
|
17
|
+
|
|
18
|
+
# A grapheme cluster containing either codepoint renders as a single emoji
|
|
19
|
+
# (double-width) cell in a terminal: U+200D ZWJ joins a multi-glyph emoji
|
|
20
|
+
# sequence, and U+FE0F (VS16) requests emoji presentation. The
|
|
21
|
+
# unicode-display_width tables disagree with terminals here — e.g. "⚔️"
|
|
22
|
+
# measures 1 and "🧙♂️" measures 3 — so we pin such clusters to 2.
|
|
23
|
+
EMOJI_PRESENTATION = /[\u200D\uFE0F]/
|
|
24
|
+
|
|
25
|
+
# Onig's \X matches one extended grapheme cluster, keeping multi-codepoint
|
|
26
|
+
# emoji together so each is measured (and later sliced) as one unit.
|
|
27
|
+
GRAPHEME = /\X/
|
|
12
28
|
|
|
13
29
|
module_function
|
|
14
30
|
|
|
15
31
|
def measure(value)
|
|
16
|
-
|
|
32
|
+
stripped = strip_ansi(value.to_s)
|
|
33
|
+
return Unicode::DisplayWidth.of(stripped) unless stripped.match?(EMOJI_PRESENTATION)
|
|
34
|
+
|
|
35
|
+
stripped.scan(GRAPHEME).sum { |cluster| cluster_width(cluster) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cluster_width(cluster)
|
|
39
|
+
return 2 if cluster.match?(EMOJI_PRESENTATION)
|
|
40
|
+
|
|
41
|
+
Unicode::DisplayWidth.of(cluster)
|
|
17
42
|
end
|
|
18
43
|
|
|
19
44
|
def strip_ansi(value)
|
data/lib/charming/router.rb
CHANGED
data/lib/charming/runtime.rb
CHANGED
|
@@ -23,27 +23,28 @@ module Charming
|
|
|
23
23
|
|
|
24
24
|
# Runs the event loop: enters alt-screen, dispatches incoming events
|
|
25
25
|
# (key, mouse, timer, async task), renders controller responses, and
|
|
26
|
-
# restores terminal state on exit.
|
|
26
|
+
# restores terminal state on exit. Unhandled exceptions from controller
|
|
27
|
+
# actions render an ErrorScreen instead of crashing the terminal.
|
|
27
28
|
def run
|
|
28
29
|
setup_terminal
|
|
30
|
+
install_interrupt_handler
|
|
29
31
|
with_raw_input do
|
|
30
|
-
render(
|
|
32
|
+
render(initial_response)
|
|
31
33
|
loop do
|
|
32
|
-
|
|
33
|
-
next unless event
|
|
34
|
-
|
|
35
|
-
response = dispatch_event(event)
|
|
36
|
-
next unless response
|
|
37
|
-
break if response.quit?
|
|
38
|
-
|
|
39
|
-
response = resolve_response(response)
|
|
40
|
-
break if response.quit?
|
|
34
|
+
break if @interrupted
|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
|
|
37
|
+
unless event
|
|
38
|
+
break if backend_exhausted?
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
break if process(event) == :quit
|
|
43
42
|
end
|
|
44
43
|
end
|
|
45
44
|
ensure
|
|
46
|
-
|
|
45
|
+
restore_interrupt_handler
|
|
46
|
+
@task_executor&.shutdown(timeout: 2.0)
|
|
47
|
+
@application.save_session if @application.respond_to?(:save_session)
|
|
47
48
|
restore_terminal
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -51,6 +52,86 @@ module Charming
|
|
|
51
52
|
|
|
52
53
|
attr_reader :screen
|
|
53
54
|
|
|
55
|
+
# The first frame's response — the root route's action, with errors caught.
|
|
56
|
+
def initial_response
|
|
57
|
+
resolve_response(dispatch(@route.action))
|
|
58
|
+
rescue => e
|
|
59
|
+
error_response(e)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Handles a single event. Returns :quit to stop the loop, nil otherwise.
|
|
63
|
+
# While an error screen is showing, only key events are honored: q quits,
|
|
64
|
+
# any other key dismisses and re-renders the current route.
|
|
65
|
+
def process(event)
|
|
66
|
+
return process_error_event(event) if @error
|
|
67
|
+
return :quit if unbound_interrupt?(event)
|
|
68
|
+
|
|
69
|
+
response = dispatch_event(event)
|
|
70
|
+
return unless response
|
|
71
|
+
return :quit if response.quit?
|
|
72
|
+
|
|
73
|
+
response = resolve_response(response)
|
|
74
|
+
return :quit if response.quit?
|
|
75
|
+
|
|
76
|
+
render(response)
|
|
77
|
+
nil
|
|
78
|
+
rescue => e
|
|
79
|
+
render(error_response(e))
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Error-mode event handling: q quits, any other key dismisses the error
|
|
84
|
+
# and re-dispatches the current route's action. Timer/task events are ignored.
|
|
85
|
+
def process_error_event(event)
|
|
86
|
+
return unless event.is_a?(Events::KeyEvent)
|
|
87
|
+
return :quit if Charming.key_of(event) == :q
|
|
88
|
+
|
|
89
|
+
@error = nil
|
|
90
|
+
render(initial_response)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# True when the backend reports it has no more events to deliver (test backends
|
|
95
|
+
# only — the TTY backend never exhausts). Prevents the loop from spinning forever
|
|
96
|
+
# in tests that forget a trailing quit event.
|
|
97
|
+
def backend_exhausted?
|
|
98
|
+
@backend.respond_to?(:exhausted?) && @backend.exhausted?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# True for a Ctrl+C key press that the current controller has no binding for —
|
|
102
|
+
# the runtime treats it as quit so apps always have an escape hatch. Controllers
|
|
103
|
+
# can take over by binding "ctrl+c" themselves.
|
|
104
|
+
def unbound_interrupt?(event)
|
|
105
|
+
return false unless event.is_a?(Events::KeyEvent)
|
|
106
|
+
return false unless event.ctrl && event.key == :c
|
|
107
|
+
|
|
108
|
+
@route.controller_class.key_bindings[:"ctrl+c"].nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Traps SIGINT so Ctrl+C (when delivered as a signal rather than a key) exits the
|
|
112
|
+
# loop cleanly through the ensure block instead of crashing mid-frame.
|
|
113
|
+
def install_interrupt_handler
|
|
114
|
+
@interrupted = false
|
|
115
|
+
@previous_int_handler = Signal.trap("INT") { @interrupted = true }
|
|
116
|
+
rescue ArgumentError
|
|
117
|
+
@previous_int_handler = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Restores the previous SIGINT handler installed before the runtime started.
|
|
121
|
+
def restore_interrupt_handler
|
|
122
|
+
Signal.trap("INT", @previous_int_handler || "DEFAULT")
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Records *error*, logs its backtrace, and builds a centered ErrorScreen response.
|
|
128
|
+
def error_response(error)
|
|
129
|
+
@error = error
|
|
130
|
+
@application.logger.error("#{error.class}: #{error.message}\n#{Array(error.backtrace).join("\n")}")
|
|
131
|
+
panel = Components::ErrorScreen.new(error: error, theme: @application.theme).render
|
|
132
|
+
Response.render(UI.center(panel, width: screen.width, height: screen.height))
|
|
133
|
+
end
|
|
134
|
+
|
|
54
135
|
# Dispatches an action on the current route's controller with an optional event.
|
|
55
136
|
# Entry point from the event loop into controllers.
|
|
56
137
|
def dispatch(action, event: nil)
|
|
@@ -72,6 +153,11 @@ module Charming
|
|
|
72
153
|
controller(event: event).dispatch_task
|
|
73
154
|
end
|
|
74
155
|
|
|
156
|
+
# Dispatches a task progress report to the current route's controller.
|
|
157
|
+
def dispatch_task_progress(event)
|
|
158
|
+
controller(event: event).dispatch_task_progress
|
|
159
|
+
end
|
|
160
|
+
|
|
75
161
|
# Dispatches a mouse action (click, drag, scroll) to the current route's controller.
|
|
76
162
|
def dispatch_mouse(event)
|
|
77
163
|
controller(event: event).dispatch_mouse
|
|
@@ -83,17 +169,34 @@ module Charming
|
|
|
83
169
|
@route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
|
|
84
170
|
end
|
|
85
171
|
|
|
86
|
-
# Type-based dispatcher: routes resize, task, timer, mouse, and key
|
|
87
|
-
# to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
172
|
+
# Type-based dispatcher: routes resize, task, progress, timer, mouse, paste, and key
|
|
173
|
+
# events to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
88
174
|
def dispatch_event(event)
|
|
89
175
|
return dispatch_resize(event) if event.is_a?(Events::ResizeEvent)
|
|
90
176
|
return dispatch_task(event) if event.is_a?(Events::TaskEvent)
|
|
177
|
+
return dispatch_task_progress(event) if event.is_a?(Events::TaskProgressEvent)
|
|
91
178
|
return dispatch_timer(event) if event.is_a?(Events::TimerEvent)
|
|
92
179
|
return dispatch_mouse(event) if event.is_a?(Events::MouseEvent)
|
|
180
|
+
return dispatch_paste(event) if event.is_a?(Events::PasteEvent)
|
|
181
|
+
return dispatch_focus_change(event) if event.is_a?(Events::FocusEvent)
|
|
93
182
|
|
|
94
183
|
dispatch_key(event)
|
|
95
184
|
end
|
|
96
185
|
|
|
186
|
+
# Dispatches a terminal focus change to the controller's optional `focus_changed`
|
|
187
|
+
# action. Ignored when the controller doesn't define one.
|
|
188
|
+
def dispatch_focus_change(event)
|
|
189
|
+
ctrl = controller(event: event)
|
|
190
|
+
return nil unless ctrl.respond_to?(:focus_changed)
|
|
191
|
+
|
|
192
|
+
ctrl.dispatch(:focus_changed)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Dispatches pasted text to the current route's controller.
|
|
196
|
+
def dispatch_paste(event)
|
|
197
|
+
controller(event: event).dispatch_paste
|
|
198
|
+
end
|
|
199
|
+
|
|
97
200
|
# Dispatches a resize event: updates screen dimensions and re-renders the current action.
|
|
98
201
|
# The renderer's cached previous frame is invalidated and the backend is cleared so the
|
|
99
202
|
# new-dimension frame paints onto a clean alt-screen instead of overlaying stale rows.
|
|
@@ -187,6 +290,8 @@ module Charming
|
|
|
187
290
|
@backend.enter_alt_screen
|
|
188
291
|
@backend.hide_cursor
|
|
189
292
|
@backend.enable_mouse_tracking if @backend.respond_to?(:enable_mouse_tracking)
|
|
293
|
+
@backend.enable_bracketed_paste if @backend.respond_to?(:enable_bracketed_paste)
|
|
294
|
+
@backend.enable_focus_reporting if @backend.respond_to?(:enable_focus_reporting)
|
|
190
295
|
@backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
|
|
191
296
|
end
|
|
192
297
|
|
|
@@ -201,6 +306,8 @@ module Charming
|
|
|
201
306
|
# the cursor, and leaves the alternative screen buffer.
|
|
202
307
|
def restore_terminal
|
|
203
308
|
@backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
|
|
309
|
+
@backend.disable_focus_reporting if @backend.respond_to?(:disable_focus_reporting)
|
|
310
|
+
@backend.disable_bracketed_paste if @backend.respond_to?(:disable_bracketed_paste)
|
|
204
311
|
@backend.disable_mouse_tracking if @backend.respond_to?(:disable_mouse_tracking)
|
|
205
312
|
@backend.show_cursor
|
|
206
313
|
@backend.leave_alt_screen
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# Cancelled is raised inside a task thread when the controller calls
|
|
6
|
+
# `cancel_task(name)` or the task exceeds its `timeout:`. The task completes with
|
|
7
|
+
# a TaskEvent whose error is this exception, so `on_task` handlers can detect it
|
|
8
|
+
# via `event.error.is_a?(Charming::Tasks::Cancelled)`.
|
|
9
|
+
class Cancelled < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -13,13 +13,19 @@ module Charming
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Wraps *block* in a Task, invokes it immediately, and pushes the resulting
|
|
16
|
-
# TaskEvent (value or error) onto the queue.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
# TaskEvent (value or error) onto the queue. Blocks that accept an argument
|
|
17
|
+
# receive a Progress reporter (its events are queued before the completion event).
|
|
18
|
+
# Returns nil.
|
|
19
|
+
def submit(name, timeout: nil, &block)
|
|
20
|
+
task = Task.new(name: name.to_sym, block: block, timeout: timeout)
|
|
19
21
|
@queue << run(task)
|
|
20
22
|
nil
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# No-op: inline tasks have always finished by the time cancel could be called.
|
|
26
|
+
def cancel(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
# No-op stub for the shutdown contract; nothing to join since tasks run on the caller.
|
|
24
30
|
def shutdown(timeout: 0.0)
|
|
25
31
|
end
|
|
@@ -28,7 +34,7 @@ module Charming
|
|
|
28
34
|
|
|
29
35
|
# Invokes the task's block and wraps the result (or raised exception) in a TaskEvent.
|
|
30
36
|
def run(task)
|
|
31
|
-
Events::TaskEvent.new(name: task.name, value: task.call)
|
|
37
|
+
Events::TaskEvent.new(name: task.name, value: task.call(Progress.new(@queue, task.name)))
|
|
32
38
|
rescue StandardError, ScriptError => e
|
|
33
39
|
Events::TaskEvent.new(name: task.name, error: e)
|
|
34
40
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# Progress is the reporter handed to task blocks that accept an argument:
|
|
6
|
+
#
|
|
7
|
+
# run_task(:import) do |progress|
|
|
8
|
+
# rows.each_with_index do |row, i|
|
|
9
|
+
# import(row)
|
|
10
|
+
# progress.report(i + 1, of: rows.length, message: row.name)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Each `report` pushes a TaskProgressEvent onto the runtime queue, which dispatches
|
|
15
|
+
# it to the controller's matching `on_task_progress` handler.
|
|
16
|
+
class Progress
|
|
17
|
+
def initialize(queue, name)
|
|
18
|
+
@queue = queue
|
|
19
|
+
@name = name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Reports progress: *current* units done, optionally *of:* a total and with a
|
|
23
|
+
# human-readable *message:*.
|
|
24
|
+
def report(current, of: nil, message: nil)
|
|
25
|
+
@queue << Events::TaskProgressEvent.new(name: @name, current: current, total: of, message: message)
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|