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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. 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
- ANSI_PATTERN = /\e\[[0-9;]*m/
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
- Unicode::DisplayWidth.of(strip_ansi(value.to_s))
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)
@@ -47,7 +47,7 @@ module Charming
47
47
  route = Route.new(
48
48
  path: path,
49
49
  controller_class: constantize(controller_constant_name(controller_name)),
50
- action: action.to_sym,
50
+ action: (action || "show").to_sym,
51
51
  title: title || derive_title(path),
52
52
  params: {}
53
53
  )
@@ -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(resolve_response(dispatch(@route.action)))
32
+ render(initial_response)
31
33
  loop do
32
- event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
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
- render(response)
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
- @task_executor&.shutdown(timeout: 0.0)
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 events
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. Returns nil.
17
- def submit(name, &block)
18
- task = Task.new(name: name.to_sym, block: block)
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