charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module UI
8
+ class Theme
9
+ BUILT_IN_ROOT = File.expand_path("themes", __dir__)
10
+
11
+ DEFAULT_TOKENS = {
12
+ text: {foreground: :bright_white},
13
+ title: {foreground: :bright_cyan, bold: true},
14
+ muted: {foreground: :bright_black},
15
+ border: {foreground: :bright_magenta},
16
+ selected: {reverse: true},
17
+ info: {foreground: :bright_cyan},
18
+ warn: {foreground: :yellow}
19
+ }.freeze
20
+
21
+ def self.default
22
+ @default ||= load_builtin("phosphor")
23
+ end
24
+
25
+ def self.load_file(path)
26
+ from_hash(JSON.parse(File.read(path)))
27
+ end
28
+
29
+ def self.load_builtin(name)
30
+ load_file(built_in_path(name))
31
+ end
32
+
33
+ def self.built_in_names
34
+ Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
35
+ end
36
+
37
+ def self.from_hash(value)
38
+ raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
39
+
40
+ styles = value.fetch("styles") do
41
+ raise ArgumentError, "theme file must contain styles"
42
+ end
43
+
44
+ palette = value.fetch("palette", {})
45
+ new(
46
+ resolve_palette_references(styles, palette),
47
+ background: resolve_background(value["background"], palette)
48
+ )
49
+ end
50
+
51
+ def self.resolve_background(value, palette)
52
+ return unless value
53
+
54
+ deep_resolve_colors(value, normalize_colors(palette))
55
+ end
56
+
57
+ def self.built_in_path(name)
58
+ slug = name.to_s
59
+ raise ArgumentError, "unknown built-in theme: #{name.inspect}" unless built_in_names.include?(slug)
60
+
61
+ File.join(BUILT_IN_ROOT, "#{slug}.json")
62
+ end
63
+
64
+ def self.resolve_palette_references(styles, palette)
65
+ palette = normalize_colors(palette)
66
+ deep_resolve_colors(styles, palette)
67
+ end
68
+
69
+ def self.deep_resolve_colors(value, palette)
70
+ case value
71
+ when Hash
72
+ value.transform_values { |item| deep_resolve_colors(item, palette) }
73
+ when Array
74
+ value.map { |item| deep_resolve_colors(item, palette) }
75
+ when String
76
+ palette.fetch(value, normalize_color(value) || value)
77
+ else
78
+ value
79
+ end
80
+ end
81
+
82
+ def self.normalize_colors(values)
83
+ values.transform_values { |value| normalize_color(value) }.compact
84
+ end
85
+
86
+ def self.normalize_color(value)
87
+ return unless value.is_a?(String)
88
+
89
+ case value
90
+ when /\A#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])(?:[0-9a-fA-F])?\z/
91
+ "#{$1 * 2}#{$2 * 2}#{$3 * 2}".prepend("#")
92
+ when /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/
93
+ value[0, 7]
94
+ end
95
+ end
96
+
97
+ attr_reader :background
98
+
99
+ def initialize(tokens = {}, background: nil)
100
+ @tokens = symbolize_keys(tokens)
101
+ @background = background
102
+ end
103
+
104
+ def style(name)
105
+ spec = @tokens.fetch(name.to_sym) do
106
+ raise ArgumentError, "unknown theme token: #{name.inspect}"
107
+ end
108
+
109
+ build_style(spec)
110
+ end
111
+ alias_method :[], :style
112
+
113
+ def method_missing(name, ...)
114
+ return style(name) if @tokens.key?(name)
115
+
116
+ super
117
+ end
118
+
119
+ def respond_to_missing?(name, include_private = false)
120
+ @tokens.key?(name) || super
121
+ end
122
+
123
+ private
124
+
125
+ def build_style(spec)
126
+ return spec if spec.is_a?(Style)
127
+ return UI.style.foreground(spec) unless spec.is_a?(Hash)
128
+
129
+ apply_options(UI.style, symbolize_keys(spec))
130
+ end
131
+
132
+ def apply_options(base_style, spec)
133
+ styled = apply_colors(base_style, spec)
134
+ styled = apply_attributes(styled, spec)
135
+ apply_layout(styled, spec)
136
+ end
137
+
138
+ def apply_colors(base_style, spec)
139
+ styled = base_style
140
+ styled = styled.foreground(spec[:foreground] || spec[:fg]) if spec.key?(:foreground) || spec.key?(:fg)
141
+ styled = styled.background(spec[:background] || spec[:bg]) if spec.key?(:background) || spec.key?(:bg)
142
+ styled
143
+ end
144
+
145
+ def apply_attributes(base_style, spec)
146
+ Style::ATTRIBUTES.each_key.reduce(base_style) do |styled, attribute|
147
+ spec[attribute] ? styled.public_send(attribute) : styled
148
+ end
149
+ end
150
+
151
+ def apply_layout(base_style, spec)
152
+ styled = base_style
153
+ styled = styled.padding(*Array(spec[:padding])) if spec.key?(:padding)
154
+ styled = apply_border(styled, spec[:border]) if spec.key?(:border)
155
+ styled = styled.width(spec[:width]) if spec.key?(:width)
156
+ styled = styled.height(spec[:height]) if spec.key?(:height)
157
+ styled = styled.align(spec[:align].to_sym) if spec.key?(:align)
158
+ styled
159
+ end
160
+
161
+ def apply_border(base_style, border_spec)
162
+ return base_style.border(border_spec) unless border_spec.is_a?(Hash)
163
+
164
+ border_spec = symbolize_keys(border_spec)
165
+ base_style.border(
166
+ border_spec.fetch(:style, :normal),
167
+ sides: border_spec[:sides],
168
+ foreground: border_spec[:foreground] || border_spec[:fg]
169
+ )
170
+ end
171
+
172
+ def symbolize_keys(value)
173
+ value.each_with_object({}) do |(key, item), result|
174
+ result[key.to_sym] = item.is_a?(Hash) ? symbolize_keys(item) : item
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -4,11 +4,11 @@
4
4
  "background": "background",
5
5
  "palette": {
6
6
  "bright": "#9FE8B0",
7
- "muted": "#5A8A68",
7
+ "muted": "#7FB98C",
8
8
  "subtle": "#788E80",
9
9
  "background": "#111A2C",
10
10
  "selected": "#18233D",
11
- "divider": "#2A3752",
11
+ "divider": "#536B91",
12
12
  "amber": "#FFB347",
13
13
  "cyan": "#6FD0E3"
14
14
  },
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module UI
8
+ # Width is a namespace for measuring and normalising the visual width of strings that may contain
9
+ # ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
10
+ # formatting codes so layout primitives can calculate exact character positions.
11
+ module Width
12
+ ANSI_PATTERN = /\e\[[0-9;]*m/
13
+
14
+ module_function
15
+
16
+ def measure(value)
17
+ Unicode::DisplayWidth.of(strip_ansi(value.to_s))
18
+ end
19
+
20
+ def strip_ansi(value)
21
+ value.to_s.gsub(ANSI_PATTERN, "")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # UI is a module of layout primitives for composing and positioning ANSI-styled
6
+ # terminal text. It provides functions to join blocks horizontally or vertically,
7
+ # place content on fixed-size canvases, overlay elements, and slice strings that
8
+ # contain ANSI escape sequences while preserving their styling.
9
+ module UI
10
+ module_function
11
+
12
+ # Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
13
+ def style
14
+ Style.new
15
+ end
16
+
17
+ # Horizontally concatenates *blocks* into a single multi-line string, padding each block's
18
+ # rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
19
+ def join_horizontal(*blocks, gap: 0)
20
+ normalized = normalize_blocks(blocks)
21
+ widths = block_widths(normalized)
22
+ separator = " " * gap
23
+
24
+ Array.new(block_height(normalized)) do |index|
25
+ horizontal_line(normalized, widths, index).join(separator)
26
+ end.join("\n")
27
+ end
28
+
29
+ # Stacks *blocks* vertically separated by one or more blank lines. A *gap* of N inserts N
30
+ # extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
31
+ def join_vertical(*blocks, gap: 0)
32
+ blocks.join("\n" * (gap + 1))
33
+ end
34
+
35
+ # Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
36
+ # and *left* (column). Non-:center values are treated as absolute positions. When *background* is
37
+ # given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
38
+ # with its own bg overrides per-cell; resets re-apply the canvas bg.
39
+ def place(block, width:, height:, top: 0, left: 0, background: nil)
40
+ Canvas.new(width, height).place(block, top: top, left: left, background: background)
41
+ end
42
+
43
+ # Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
44
+ # defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
45
+ def overlay(base, overlay, top: :center, left: :center)
46
+ Canvas.parse(base).overlay(overlay, top: top, left: left).to_s
47
+ end
48
+
49
+ # Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
50
+ def center(block, width:, height:, background: nil)
51
+ place(block, width: width, height: height, top: :center, left: :center, background: background)
52
+ end
53
+
54
+ # Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
55
+ # ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
56
+ def visible_slice(line, start_column, width)
57
+ ANSISlicer.slice(line, start_column, width)
58
+ end
59
+
60
+ # Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
61
+ def normalize_blocks(blocks)
62
+ blocks.map { |block| block.to_s.lines(chomp: true) }
63
+ end
64
+
65
+ # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
66
+ def block_widths(blocks)
67
+ blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
68
+ end
69
+
70
+ # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
71
+ # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
72
+ def block_width(lines)
73
+ lines.map { |line| Width.measure(line) }.max || 0
74
+ end
75
+
76
+ # Returns the height in rows of each normalised block, taking the maximum across all blocks.
77
+ def block_height(blocks)
78
+ blocks.map(&:length).max || 0
79
+ end
80
+
81
+ # Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
82
+ # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
83
+ def horizontal_line(blocks, widths, index)
84
+ blocks.each_with_index.map do |lines, block_index|
85
+ line = lines[index] || ""
86
+ line + (" " * (widths[block_index] - Width.measure(line)))
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # View is the base class for all screen view implementations. It provides assign injection (via `initialize`),
6
+ # rendering hooks, layout composition helpers (`row`, `column`, `render_component`, `yield_content`),
7
+ # and access to controller theme, style, and focus state from within views.
8
+ class View
9
+ # Initializes the view with named assigns injected as instance-local accessor methods via
10
+ # `define_singleton_method`. Called when a controller instantiates a view for rendering.
11
+ def initialize(**assigns)
12
+ @assigns = assigns
13
+ define_assign_readers
14
+ end
15
+
16
+ # Returns all view assigns as a hash, used by layouts to compose the full template (content + screen + controller).
17
+ def layout_assigns
18
+ assigns
19
+ end
20
+
21
+ # Renders the view's body. Default is empty — subclasses override to return visible text.
22
+ def render
23
+ ""
24
+ end
25
+
26
+ # Delegates focus checking to the controller in assigns, allowing views to determine which slot (sidebar, content) has focus.
27
+ def focused?(slot)
28
+ ctrl = assigns[:focus_controller] || assigns[:controller]
29
+ ctrl ? ctrl.focused?(slot) : false
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :assigns
35
+
36
+ # Returns the shared UI style configuration used by components and views for visual rendering (colors, borders).
37
+ def style
38
+ UI.style
39
+ end
40
+
41
+ # Returns the active theme: uses `theme` from assigns or controller, falling back to `UI::Theme.default`.
42
+ def theme
43
+ assigns[:theme] || assigns[:controller]&.theme || UI::Theme.default
44
+ end
45
+
46
+ # Outputs styled text through the view's rendering pipeline. Accepts a named `style:` for inline formatting.
47
+ # Appends the rendered value to the output buffer and returns it.
48
+ def text(value, style: nil)
49
+ rendered = apply_style(value.to_s, style)
50
+ append_to_buffer(rendered)
51
+ rendered
52
+ end
53
+
54
+ # Renders a box with optional styling. Accepts an inline block for complex content or a plain value.
55
+ # Used for bordered containers and field groups in views.
56
+ def box(value = nil, style: nil, &)
57
+ content = block_given? ? capture(&) : value.to_s
58
+ apply_style(content, style)
59
+ end
60
+
61
+ # Joins items horizontally (side-by-side) using the UI rendering engine. Supports a `gap:` parameter.
62
+ def row(*items, gap: 0)
63
+ UI.join_horizontal(*items, gap: gap)
64
+ end
65
+
66
+ # Stacks items vertically using the UI rendering engine. Supports a `gap:` parameter for spacing.
67
+ def column(*items, gap: 0)
68
+ UI.join_vertical(*items, gap: gap)
69
+ end
70
+
71
+ # Renders a component (e.g., a ProgressBar, Spinner, Modal) and returns its string output.
72
+ def render_component(component)
73
+ component.render.to_s
74
+ end
75
+
76
+ # Renders a partial view component. An alias for `render_component` used in layout templates.
77
+ def render_partial(partial)
78
+ render_component(partial)
79
+ end
80
+
81
+ # Builds a declarative layout tree for the current terminal screen and renders it.
82
+ def screen_layout(background: nil, &)
83
+ layout = Layout::Builder.build(screen: layout_screen, view: self, background: background, &)
84
+ register_layout_focus(layout)
85
+ layout.render
86
+ end
87
+
88
+ # Yields the layout's `content` slot — used by view templates to inject their body into a layout wrapper (e.g., sidebar).
89
+ def yield_content
90
+ assigns.fetch(:content, "")
91
+ end
92
+
93
+ # Evaluates a block in the view's context with a clean output buffer. Captures text written via `text`/`box`
94
+ # and returns joined content. Resets buffer afterward for parent rendering.
95
+ def capture(&)
96
+ previous_buffer = @output_buffer
97
+ @output_buffer = []
98
+ result = instance_eval(&)
99
+ @output_buffer.empty? ? result.to_s : @output_buffer.join("\n")
100
+ ensure
101
+ @output_buffer = previous_buffer
102
+ end
103
+
104
+ # Appends a value to the current output buffer (if one is active). Used by rendering helpers.
105
+ def append_to_buffer(value)
106
+ @output_buffer << value if @output_buffer
107
+ end
108
+
109
+ # Applies a style object's `render` method to a string, returning styled output or raw text when style is nil.
110
+ def apply_style(value, style_object)
111
+ style_object ? style_object.render(value) : value
112
+ end
113
+
114
+ # Dynamically defines read-only accessor methods for each assign key as singleton methods on self.
115
+ # Skips keys where the view already responds (controller methods take precedence).
116
+ def define_assign_readers
117
+ assigns.each_key do |name|
118
+ next if respond_to?(name, true)
119
+
120
+ define_singleton_method(name) { assigns.fetch(name) }
121
+ end
122
+ end
123
+
124
+ def layout_screen
125
+ assigns[:screen] || assigns[:controller]&.screen || Charming::Screen.new(width: 80, height: 24)
126
+ end
127
+
128
+ def register_layout_focus(layout)
129
+ return unless assigns[:controller]
130
+
131
+ assigns[:controller].focus.define_layout(layout.focusable_names)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -75,17 +75,19 @@ module Charming
75
75
  controller(event: event).dispatch_mouse
76
76
  end
77
77
 
78
+ # Instantiates a fresh controller for the active route, passing the application, current *event*,
79
+ # route params, screen dimensions, and route object. Called by every dispatch path.
78
80
  def controller(event: nil)
79
- @route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen)
81
+ @route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
80
82
  end
81
83
 
82
84
  # Type-based dispatcher: routes resize, task, timer, mouse, and key events
83
85
  # to the appropriate handler. Falls back to key dispatch for unclassified events.
84
86
  def dispatch_event(event)
85
- return dispatch_resize(event) if event.is_a?(ResizeEvent)
86
- return dispatch_task(event) if event.is_a?(TaskEvent)
87
- return dispatch_timer(event) if event.is_a?(TimerEvent)
88
- return dispatch_mouse(event) if event.is_a?(MouseEvent)
87
+ return dispatch_resize(event) if event.is_a?(Events::ResizeEvent)
88
+ return dispatch_task(event) if event.is_a?(Events::TaskEvent)
89
+ return dispatch_timer(event) if event.is_a?(Events::TimerEvent)
90
+ return dispatch_mouse(event) if event.is_a?(Events::MouseEvent)
89
91
 
90
92
  dispatch_key(event)
91
93
  end
@@ -133,7 +135,7 @@ module Charming
133
135
 
134
136
  now = clock_now
135
137
  timer[:next_at] = now + timer.fetch(:binding).interval
136
- TimerEvent.new(name: timer.fetch(:binding).name, now: now)
138
+ Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
137
139
  end
138
140
 
139
141
  # Pops a task event from the thread-safe queue if one is available.
@@ -161,7 +163,7 @@ module Charming
161
163
 
162
164
  # Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
163
165
  def build_task_executor(task_executor)
164
- return TaskExecutor::Threaded.new(@task_queue) unless task_executor
166
+ return Tasks::ThreadedExecutor.new(@task_queue) unless task_executor
165
167
  return task_executor if task_executor.respond_to?(:submit)
166
168
  return task_executor.call(@task_queue) if task_executor.respond_to?(:call) && !task_executor.respond_to?(:new)
167
169
 
@@ -4,5 +4,9 @@ module Charming
4
4
  # Screen represents the terminal viewport dimensions as a simple Data class.
5
5
  # The `width` and `height` values flow from the backend through the runtime
6
6
  # loop into every controller dispatch for layout calculations.
7
- Screen = Data.define(:width, :height)
7
+ Screen = Data.define(:width, :height) do
8
+ def narrow?(below:, min_height: nil)
9
+ width < below && (min_height.nil? || height >= min_height)
10
+ end
11
+ end
8
12
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Tasks
5
+ # InlineExecutor runs submitted tasks synchronously on the calling thread, pushing
6
+ # the resulting TaskEvent directly into the runtime's *queue*. Used for testing and
7
+ # for environments where spawning background threads is undesirable.
8
+ class InlineExecutor
9
+ # *queue* is the thread-safe Queue (typically `runtime.@task_queue`) into which
10
+ # completed TaskEvents are pushed.
11
+ def initialize(queue)
12
+ @queue = queue
13
+ end
14
+
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)
19
+ @queue << run(task)
20
+ nil
21
+ end
22
+
23
+ # No-op stub for the shutdown contract; nothing to join since tasks run on the caller.
24
+ def shutdown(timeout: 0.0)
25
+ end
26
+
27
+ private
28
+
29
+ # Invokes the task's block and wraps the result (or raised exception) in a TaskEvent.
30
+ def run(task)
31
+ Events::TaskEvent.new(name: task.name, value: task.call)
32
+ rescue StandardError, ScriptError => e
33
+ Events::TaskEvent.new(name: task.name, error: e)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Tasks
5
+ # Task is the unit of work submitted to a task executor. It pairs a *name* (used by
6
+ # `on_task` handlers to route the result) with a *block* to invoke on the executor.
7
+ Task = Data.define(:name, :block) do
8
+ # Invokes the task's block in the executor's thread and returns its value (or raises).
9
+ def call = block.call
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Tasks
5
+ # ThreadedExecutor runs submitted tasks on background Ruby threads. Each submission
6
+ # creates a new thread that invokes the block and pushes the resulting TaskEvent
7
+ # onto the shared *queue*. Threads are tracked so `shutdown` can wait (or kill)
8
+ # in-flight work.
9
+ class ThreadedExecutor
10
+ # *queue* is the thread-safe Queue (typically `runtime.@task_queue`) into which
11
+ # completed TaskEvents are pushed.
12
+ def initialize(queue)
13
+ @queue = queue
14
+ @threads = []
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Wraps *block* in a Task and spawns a new thread to invoke it. The thread's
19
+ # return value (or rescued exception) is pushed onto the queue as a TaskEvent.
20
+ # Returns nil immediately.
21
+ def submit(name, &block)
22
+ task = Task.new(name: name.to_sym, block: block)
23
+ thread = Thread.new(task) { |t| @queue << run(t) }
24
+ @mutex.synchronize { @threads << thread }
25
+ nil
26
+ end
27
+
28
+ # Waits up to *timeout* seconds for in-flight threads to finish, then kills any
29
+ # remaining live threads. Used by Runtime during teardown.
30
+ def shutdown(timeout: 0.0)
31
+ threads = @mutex.synchronize { @threads.dup }
32
+ threads.each { |thread| thread.join(timeout) }
33
+ threads.each do |thread|
34
+ next unless thread.alive?
35
+
36
+ thread.kill
37
+ thread.join(0)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Invokes the task's block and wraps the result (or rescued exception) in a TaskEvent.
44
+ def run(task)
45
+ Events::TaskEvent.new(name: task.name, value: task.call)
46
+ rescue StandardError, ScriptError => e
47
+ Events::TaskEvent.new(name: task.name, error: e)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/charming.rb CHANGED
@@ -6,19 +6,36 @@ loader = Zeitwerk::Loader.for_gem
6
6
  loader.inflector.inflect(
7
7
  "cli" => "CLI",
8
8
  "ui" => "UI",
9
+ "ansi_codes" => "ANSICodes",
10
+ "ansi_slicer" => "ANSISlicer",
11
+ "border_painter" => "BorderPainter",
12
+ "block_renderers" => "BlockRenderer",
13
+ "inline_renderers" => "InlineRenderer",
14
+ "render_context" => "RenderContext",
15
+ "erb_handler" => "ErbHandler",
16
+ "key_normalizer" => "KeyNormalizer",
17
+ "mouse_parser" => "MouseParser",
9
18
  "tty_backend" => "TTYBackend"
10
19
  )
11
20
  loader.setup
12
21
 
13
22
  module Charming
23
+ # Base error class for all Charming-specific exceptions (used by templates, generators, runtime, etc.).
14
24
  class Error < StandardError; end
15
25
 
26
+ # Entry point for running a Charming application. Instantiates a Runtime for *application* and starts
27
+ # the event loop. *backend* defaults to TTYBackend; tests pass MemoryBackend directly via `Charming::Runtime.new`.
16
28
  def self.run(application, backend: nil)
17
29
  Runtime.new(application, backend: backend).run
18
30
  end
19
31
 
32
+ # Returns the normalized key symbol for an event-like object — `event.key` when the object responds
33
+ # to it, otherwise `event.to_sym`. Lets components treat raw strings and KeyEvent objects uniformly.
20
34
  def self.key_of(event)
21
35
  key = event.respond_to?(:key) ? event.key : event
22
36
  key.to_sym
23
37
  end
24
38
  end
39
+
40
+ Charming::Presentation::Templates.register ".tui.erb", Charming::Presentation::Templates::ErbHandler
41
+ Charming::Presentation::Templates.register ".txt.erb", Charming::Presentation::Templates::ErbHandler