charming 0.1.4 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -16
  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 +11 -2
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +35 -109
  59. data/lib/charming/presentation/layout/pane_behavior.rb +39 -0
  60. data/lib/charming/presentation/layout/pane_geometry.rb +79 -0
  61. data/lib/charming/presentation/layout/pane_style.rb +39 -0
  62. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  63. data/lib/charming/presentation/layout/split.rb +37 -3
  64. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  65. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  66. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  67. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  68. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  69. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  70. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  71. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  72. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  73. data/lib/charming/presentation/ui/color_support.rb +129 -0
  74. data/lib/charming/presentation/ui/theme.rb +7 -0
  75. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  76. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  77. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  78. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  79. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  80. data/lib/charming/presentation/ui/width.rb +27 -2
  81. data/lib/charming/router.rb +1 -1
  82. data/lib/charming/runtime.rb +122 -15
  83. data/lib/charming/tasks/cancelled.rb +11 -0
  84. data/lib/charming/tasks/inline_executor.rb +10 -4
  85. data/lib/charming/tasks/progress.rb +30 -0
  86. data/lib/charming/tasks/task.rb +24 -4
  87. data/lib/charming/tasks/threaded_executor.rb +35 -11
  88. data/lib/charming/test_helper.rb +120 -0
  89. data/lib/charming/version.rb +1 -1
  90. data/lib/charming.rb +43 -1
  91. metadata +39 -49
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "unicode/display_width"
4
-
5
3
  module Charming
6
4
  module Components
7
5
  # Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
@@ -12,9 +10,6 @@ module Charming
12
10
  class Viewport < Component
13
11
  include KeyboardHandler
14
12
 
15
- # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
16
- ANSI_PATTERN = /\e\[[0-9;]*m/
17
-
18
13
  # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
19
14
  KEY_ACTIONS = {
20
15
  up: :scroll_up,
@@ -27,9 +22,6 @@ module Charming
27
22
  right: :scroll_right
28
23
  }.freeze
29
24
 
30
- # The current top-visible row and left-visible column, respectively.
31
- attr_reader :offset, :column
32
-
33
25
  # *content* may be a string, an array of lines, or any object responding to `render`.
34
26
  # *width* and *height* constrain the visible window; *offset* is the top-visible row
35
27
  # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
@@ -38,11 +30,10 @@ module Charming
38
30
  @content = content
39
31
  @width = width
40
32
  @height = height
41
- @offset = offset
42
- @column = column
33
+ @position = Position.new(offset: offset, column: column)
43
34
  @wrap = wrap
44
35
  @keymap = keymap
45
- clamp_position
36
+ position.clamp(bounds)
46
37
  end
47
38
 
48
39
  # Renders the visible window of content as a multi-line string.
@@ -50,6 +41,16 @@ module Charming
50
41
  visible_lines.map { |line| render_line(line) }.join("\n")
51
42
  end
52
43
 
44
+ # The current top-visible row.
45
+ def offset
46
+ @position.offset
47
+ end
48
+
49
+ # The current left-visible column.
50
+ def column
51
+ @position.column
52
+ end
53
+
53
54
  # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
54
55
  # visible row to the clicked position. Returns :handled on success.
55
56
  def handle_mouse(event)
@@ -57,77 +58,61 @@ module Charming
57
58
 
58
59
  if event.scroll?
59
60
  scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
60
- @offset += scroll_delta
61
- clamp_position
61
+ position.move_to(offset + scroll_delta, bounds)
62
62
  return :handled
63
63
  end
64
64
 
65
65
  return nil unless event.click?
66
66
 
67
67
  clicked_row = event.y
68
- return nil if clicked_row < offset || clicked_row >= offset + viewport_height
68
+ return nil if clicked_row < 0 || clicked_row >= viewport_height
69
69
 
70
- @offset = clicked_row
71
- clamp_position
70
+ position.move_to(offset + clicked_row, bounds)
72
71
  :handled
73
72
  end
74
73
 
75
74
  private
76
75
 
77
- attr_reader :content, :width, :height
76
+ attr_reader :content, :width, :height, :position
78
77
 
79
78
  # Scrolls the viewport up by one row.
80
79
  def scroll_up
81
- @offset -= 1
82
- clamp_position
80
+ position.scroll_up(bounds)
83
81
  end
84
82
 
85
83
  # Scrolls the viewport down by one row.
86
84
  def scroll_down
87
- @offset += 1
88
- clamp_position
85
+ position.scroll_down(bounds)
89
86
  end
90
87
 
91
88
  # Scrolls up by one viewport page.
92
89
  def page_up
93
- @offset -= page_size
94
- clamp_position
90
+ position.page_up(page_size, bounds)
95
91
  end
96
92
 
97
93
  # Scrolls down by one viewport page.
98
94
  def page_down
99
- @offset += page_size
100
- clamp_position
95
+ position.page_down(page_size, bounds)
101
96
  end
102
97
 
103
98
  # Scrolls to the top-left of the content.
104
99
  def scroll_home
105
- @offset = 0
106
- @column = 0
100
+ position.home
107
101
  end
108
102
 
109
103
  # Scrolls to the bottom-right of the content.
110
104
  def scroll_end
111
- @offset = max_offset
112
- @column = max_column
105
+ position.end_at(bounds)
113
106
  end
114
107
 
115
108
  # Scrolls one column left.
116
109
  def scroll_left
117
- @column -= 1
118
- clamp_position
110
+ position.scroll_left(bounds)
119
111
  end
120
112
 
121
113
  # Scrolls one column right.
122
114
  def scroll_right
123
- @column += 1
124
- clamp_position
125
- end
126
-
127
- # Clamps both the row offset and the column to their valid ranges.
128
- def clamp_position
129
- @offset = offset.clamp(0, max_offset)
130
- @column = column.clamp(0, max_column)
115
+ position.scroll_right(bounds)
131
116
  end
132
117
 
133
118
  # Returns the slice of content lines visible in the current viewport, padded to *height*.
@@ -141,89 +126,12 @@ module Charming
141
126
  # Renders a single line according to the configured width and wrap mode: clips to the
142
127
  # visible column window when not wrapping, otherwise wraps the line to the width.
143
128
  def render_line(line)
144
- return line unless width
145
- return pad_line(line, width) if wrap?
146
-
147
- pad_line(clip_line(line), width)
148
- end
149
-
150
- # Clips *line* to the visible column window while preserving active ANSI styling.
151
- def clip_line(line)
152
- clipped = clip_tokens(line.to_s)
153
- needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
154
- end
155
-
156
- # Walks *line* token-by-token, copying ANSI escapes through and emitting only the
157
- # characters that fall inside the visible column window.
158
- def clip_tokens(line)
159
- state = {cursor: 0, output: +""}
160
- line.scan(/#{ANSI_PATTERN}|./mo) do |token|
161
- ansi?(token) ? append_ansi(state, token) : append_character(state, token)
162
- end
163
- state.fetch(:output)
164
- end
165
-
166
- # Appends an ANSI escape token to the output buffer unchanged.
167
- def append_ansi(state, token)
168
- state.fetch(:output) << token
169
- end
170
-
171
- # Appends a single character token to the output buffer when it falls inside the
172
- # visible column window, advancing the visual cursor.
173
- def append_character(state, char)
174
- char_width = Unicode::DisplayWidth.of(char)
175
- cursor = state.fetch(:cursor)
176
- state.fetch(:output) << char if visible?(cursor, char_width)
177
- state[:cursor] = cursor + char_width
178
- end
179
-
180
- # True when the character at *cursor* (with the given display *char_width*) is within
181
- # the visible column window.
182
- def visible?(cursor, char_width)
183
- cursor >= column && cursor + char_width <= column + width
184
- end
185
-
186
- # True when *value* contains ANSI codes but does not end with a reset — needed because
187
- # the clip may truncate styling in the middle of a styled run.
188
- def needs_reset?(value)
189
- value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
190
- end
191
-
192
- # Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
193
- def pad_line(line, target_width)
194
- line + (" " * [target_width - UI::Width.measure(line), 0].max)
129
+ line_window.render(line)
195
130
  end
196
131
 
197
132
  # Returns the content lines, wrapped to *width* when wrap is enabled.
198
133
  def content_lines
199
- return wrapped_content_lines if wrap?
200
-
201
- rendered_content.lines(chomp: true)
202
- end
203
-
204
- # Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
205
- def wrapped_content_lines
206
- rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
207
- end
208
-
209
- # Wraps a single *line* into chunks of *width* display columns.
210
- def wrap_line(line)
211
- line_width = UI::Width.measure(line)
212
- return [""] if line_width.zero?
213
-
214
- start_column = 0
215
- out = []
216
- while start_column < line_width
217
- out << UI.visible_slice(line, start_column, width)
218
- start_column += width
219
- end
220
- out
221
- end
222
-
223
- # Returns the rendered content string, calling `render.to_s` on the content object when
224
- # it responds to render.
225
- def rendered_content
226
- content.respond_to?(:render) ? content.render.to_s : content.to_s
134
+ content_source.lines
227
135
  end
228
136
 
229
137
  # Returns the visible row count (the configured *height* or the content's line count).
@@ -253,18 +161,25 @@ module Charming
253
161
 
254
162
  # Returns the maximum display width across all content lines.
255
163
  def content_width
256
- content_lines.map { |line| UI::Width.measure(line) }.max || 0
164
+ content_source.display_width
257
165
  end
258
166
 
259
- # True when *token* is an ANSI escape sequence.
260
- def ansi?(token)
261
- token.match?(ANSI_PATTERN)
167
+ def bounds
168
+ {max_offset: max_offset, max_column: max_column}
262
169
  end
263
170
 
264
171
  # True when soft-wrapping is enabled and a positive width is configured.
265
172
  def wrap?
266
173
  @wrap && width&.positive?
267
174
  end
175
+
176
+ def line_window
177
+ LineWindow.new(width: width, column: column, wrap: wrap?)
178
+ end
179
+
180
+ def content_source
181
+ ContentLines.new(content: content, width: width, wrap: @wrap)
182
+ end
268
183
  end
269
184
  end
270
185
  end
@@ -36,9 +36,18 @@ module Charming
36
36
  end
37
37
 
38
38
  # Adds a Pane leaf node to the current scope. *name* (optional) is the focus slot name;
39
- # *content* (or a *block*) is the body. *options* are forwarded to Pane.
39
+ # *content* (or a *block*) is the body. *options* are split into PaneGeometry,
40
+ # PaneStyle, and PaneBehavior keyword args.
40
41
  def pane(name = nil, content = nil, **options, &block)
41
- node = Pane.new(name: name, content: content, block: block, view: view, **options)
42
+ node = Pane.new(
43
+ name: name, content: content, block: block, view: view,
44
+ geometry: PaneGeometry.build(**options.slice(
45
+ :width, :height, :grow, :border, :padding,
46
+ :min_width, :max_width, :min_height, :max_height
47
+ )),
48
+ style: PaneStyle.build(**options.slice(:style, :focused_style)),
49
+ behavior: PaneBehavior.build(**options.slice(:focus, :scroll, :clip, :wrap))
50
+ )
42
51
  append(node)
43
52
  node
44
53
  end
@@ -8,13 +8,14 @@ module Charming
8
8
  # with an outer *style*.
9
9
  class Overlay
10
10
  # The vertical and horizontal offset (cell count or `:center`) of the overlay
11
- # within the parent canvas.
12
- attr_reader :top, :left
11
+ # within the parent canvas, and its stacking order (higher paints later/on top).
12
+ attr_reader :top, :left, :z_index
13
13
 
14
14
  # *content* (or a *block*) provides the body. *top*/*left* default to :center.
15
15
  # *width*/*height* fix the overlay's dimensions; when unset, the content's natural
16
- # size is used. *style* wraps the rendered content in a UI::Style.
17
- def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil)
16
+ # size is used. *style* wraps the rendered content in a UI::Style. *z_index*
17
+ # controls stacking: higher values composite on top (ties keep registration order).
18
+ def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil, z_index: 0)
18
19
  @content = content
19
20
  @block = block
20
21
  @view = view
@@ -23,6 +24,7 @@ module Charming
23
24
  @width = width
24
25
  @height = height
25
26
  @style = style
27
+ @z_index = z_index
26
28
  end
27
29
 
28
30
  # Renders the overlay's content; when *width* or *height* is set, places the rendered
@@ -4,34 +4,21 @@ module Charming
4
4
  module Layout
5
5
  # Pane is a leaf layout node: a single rectangle with optional border, padding, and
6
6
  # styling, containing a piece of content (a string, a View, or a block evaluated in the
7
- # view's context). Panes with a `name` and `focus: true` are registered as focusable
8
- # slots in the controller's focus ring.
7
+ # view's context). Panes with a `name` and `behavior.focus: true` are registered as
8
+ # focusable slots in the controller's focus ring.
9
9
  class Pane
10
- # The pane's focus slot name, fixed width, fixed height, and grow weight.
11
- attr_reader :name, :width, :height, :grow
12
-
13
- # *name* is the focus slot identifier (optional). *content* or *block* provides the body.
14
- # *width*/*height*/*grow* control sizing. *border* may be `true` (normal border) or a
15
- # border name symbol. *padding* may be 1, 2, or 4 values (CSS-style shorthand).
16
- # *style* sets the base style; *focused_style* overrides it when the pane is focused.
17
- # *focus: true* marks the pane as focusable. *scroll*/*clip*/*wrap* control how
18
- # overflow content is rendered (via the embedded Viewport).
19
- def initialize(name: nil, content: nil, block: nil, view: nil, width: nil, height: nil, grow: nil, border: nil, padding: nil, style: nil, focused_style: nil, focus: false, scroll: false, clip: true, wrap: false)
20
- @name = name
21
- @content = content
22
- @block = block
23
- @view = view
24
- @width = width
25
- @height = height
26
- @grow = grow
27
- @border = border
28
- @padding = padding
29
- @style = style
30
- @focused_style = focused_style
31
- @focus = focus
32
- @scroll = scroll
33
- @clip = clip
34
- @wrap = wrap
10
+ attr_reader :name
11
+ delegate :width, :height, :grow,
12
+ :min_width, :max_width, :min_height, :max_height, to: :geometry
13
+
14
+ # *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
15
+ # is the view used for instance_exec when the block is given. *geometry*, *style*, and
16
+ # *behavior* are value objects that own sizing, styling, and render-time flags.
17
+ def initialize(name: nil, content: nil, block: nil, view: nil,
18
+ geometry: PaneGeometry.new, style: PaneStyle.new,
19
+ behavior: PaneBehavior.new)
20
+ @name, @content, @block, @view = name, content, block, view
21
+ @geometry, @style, @behavior = geometry, style, behavior
35
22
  end
36
23
 
37
24
  # Raises ArgumentError — panes are leaves and cannot contain layout children.
@@ -39,117 +26,56 @@ module Charming
39
26
  raise ArgumentError, "pane cannot contain layout children"
40
27
  end
41
28
 
42
- # Returns [name] when the pane is marked focusable and has a name, otherwise [].
29
+ # Returns [name] when the pane is focusable and named, otherwise [].
43
30
  def focusable_names
44
- (focus && name) ? [name] : []
31
+ (@behavior.focus && name) ? [name] : []
45
32
  end
46
33
 
47
34
  # Returns the mouse target represented by this pane, if it has a name.
48
35
  def mouse_targets(rect)
49
36
  return [] unless name
50
37
 
51
- [{name: name, rect: rect, inner_rect: inner_rect(rect)}]
38
+ [{name: name, rect: rect, inner_rect: @geometry.inset(rect)}]
52
39
  end
53
40
 
54
41
  # Renders the pane into *rect*, applying the configured style, border, and padding
55
42
  # around the evaluated content.
56
43
  def render(rect)
57
- outer_style(rect).render(rendered_content(rect))
44
+ inner = @geometry.inset(rect)
45
+ outer_style(inner).render(rendered_content(inner))
58
46
  end
59
47
 
60
48
  private
61
49
 
62
- # The raw content, the body block, the view used for instance_exec, and styling options.
63
- attr_reader :content, :block, :view, :border, :padding, :style, :focused_style, :focus, :scroll, :clip, :wrap
64
-
65
- # Returns the content string for *rect*, optionally clipped/scrolled by an embedded Viewport.
66
- def rendered_content(rect)
67
- content_rect = inner_rect(rect)
68
- value = evaluate_content(content_rect)
69
- return value unless clip || scroll
70
-
71
- Components::Viewport.new(content: value, width: content_rect.width, height: content_rect.height, wrap: wrap).render
72
- end
50
+ attr_reader :content, :block, :view, :geometry, :style, :behavior
73
51
 
74
- # Evaluates the configured content (block or constant) and renders it to a string.
75
- # Pane blocks may accept the inner content Rect when they need exact available dimensions.
76
- def evaluate_content(content_rect)
52
+ # Returns the content string for *content_rect*, optionally clipped/scrolled by an
53
+ # embedded Viewport when *behavior.should_viewport?* is true.
54
+ def rendered_content(content_rect)
77
55
  value = if block
78
56
  block.arity.zero? ? view.instance_exec(&block) : view.instance_exec(content_rect, &block)
79
57
  else
80
58
  content
81
59
  end
82
- value.respond_to?(:render) ? value.render.to_s : value.to_s
60
+ return value.to_s unless value.respond_to?(:render)
61
+ return value.render.to_s unless @behavior.should_viewport?
62
+
63
+ Components::Viewport.new(content: value.render, width: content_rect.width,
64
+ height: content_rect.height, wrap: @behavior.wrap).render.to_s
83
65
  end
84
66
 
85
67
  # Builds the outer style object with optional border and padding, sized to the
86
68
  # inner rect of the pane.
87
- def outer_style(rect)
88
- styled = current_style
89
- styled = styled.border(border_style) if border
90
- styled = styled.padding(*padding_values) if padding
91
- styled.width(inner_rect(rect).width).height(inner_rect(rect).height)
69
+ def outer_style(inner)
70
+ styled = @style.resolve(view, focused: focused?)
71
+ styled = styled.border(@geometry.border_style) if @geometry.border
72
+ styled = styled.padding(*@geometry.padding_values) if @geometry.padding
73
+ styled.width(inner.width).height(inner.height)
92
74
  end
93
75
 
94
- # Returns the active style: the focused variant when the pane is focused, otherwise
95
- # the configured style or a default UI::Style.
96
- def current_style
97
- return focused_pane_style if focused?
98
-
99
- style || UI.style
100
- end
101
-
102
- # Returns the focused-pane style: the focused_style override, or the theme's title style.
103
- def focused_pane_style
104
- focused_style || view.__send__(:theme).title
105
- end
106
-
107
- # True when the pane is configured for focus and the view reports it as currently focused.
76
+ # True when the pane is configured for focus and the view reports it as focused.
108
77
  def focused?
109
- focus && name && view.focused?(name)
110
- end
111
-
112
- # Returns the inner Rect after border and padding insets are applied.
113
- def inner_rect(rect)
114
- rect.inset(
115
- top: border_top + padding_top,
116
- right: border_right + padding_right,
117
- bottom: border_bottom + padding_bottom,
118
- left: border_left + padding_left
119
- )
120
- end
121
-
122
- # Resolves the border style symbol: :normal when border is `true`, otherwise the configured value.
123
- def border_style
124
- (border == true) ? :normal : border
125
- end
126
-
127
- # Border thickness on each side (1 when a border is configured, 0 otherwise).
128
- def border_top = border ? 1 : 0
129
- def border_right = border ? 1 : 0
130
- def border_bottom = border ? 1 : 0
131
- def border_left = border ? 1 : 0
132
-
133
- # The padding values normalized to [top, right, bottom, left] form.
134
- def padding_values
135
- @padding_values ||= expand_padding(Array(padding))
136
- end
137
-
138
- # Per-side padding values (0 when no padding is configured).
139
- def padding_top = padding ? padding_values[0] : 0
140
- def padding_right = padding ? padding_values[1] : 0
141
- def padding_bottom = padding ? padding_values[2] : 0
142
- def padding_left = padding ? padding_values[3] : 0
143
-
144
- # Normalizes 1/2/4 padding arguments to [top, right, bottom, left].
145
- def expand_padding(values)
146
- case values.length
147
- when 1 then [values[0], values[0], values[0], values[0]]
148
- when 2 then [values[0], values[1], values[0], values[1]]
149
- when 4 then values
150
- else
151
- raise ArgumentError, "padding expects 1, 2, or 4 values"
152
- end
78
+ @behavior.focus && name && view.focused?(name)
153
79
  end
154
80
  end
155
81
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Layout
5
+ # PaneBehavior holds the render-time options that control a Pane's
6
+ # interaction with the focus ring and the embedded Viewport.
7
+ class PaneBehavior
8
+ attr_reader :focus, :scroll, :clip, :wrap
9
+
10
+ def self.build(focus: false, scroll: false, clip: true, wrap: false)
11
+ new(focus: focus, scroll: scroll, clip: clip, wrap: wrap)
12
+ end
13
+
14
+ def initialize(focus:, scroll:, clip:, wrap:)
15
+ @focus, @scroll, @clip, @wrap = focus, scroll, clip, wrap
16
+ freeze
17
+ end
18
+
19
+ def ==(other)
20
+ other.is_a?(PaneBehavior) &&
21
+ focus == other.focus && scroll == other.scroll &&
22
+ clip == other.clip && wrap == other.wrap
23
+ end
24
+ alias_method :eql?, :==
25
+
26
+ def hash
27
+ [focus, scroll, clip, wrap].hash
28
+ end
29
+
30
+ def focusable?
31
+ focus
32
+ end
33
+
34
+ def should_viewport?
35
+ clip || scroll
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Layout
5
+ # PaneGeometry holds a Pane's sizing (width, height, grow, min/max constraints)
6
+ # and inset configuration (border + padding). It knows how to inset a Rect for the
7
+ # content area and how to expand CSS-style 1/2/4-value padding.
8
+ class PaneGeometry
9
+ attr_reader :width, :height, :grow, :border, :padding,
10
+ :min_width, :max_width, :min_height, :max_height
11
+
12
+ def self.build(width: nil, height: nil, grow: nil, border: nil, padding: nil,
13
+ min_width: nil, max_width: nil, min_height: nil, max_height: nil)
14
+ new(width: width, height: height, grow: grow,
15
+ border: (border == true) ? :normal : border, padding: padding,
16
+ min_width: min_width, max_width: max_width,
17
+ min_height: min_height, max_height: max_height)
18
+ end
19
+
20
+ def initialize(width:, height:, grow:, border:, padding:,
21
+ min_width: nil, max_width: nil, min_height: nil, max_height: nil)
22
+ @width, @height, @grow, @border, @padding = width, height, grow, border, padding
23
+ @min_width, @max_width, @min_height, @max_height = min_width, max_width, min_height, max_height
24
+ @padding_values = padding ? expand_padding(Array(padding)) : [0, 0, 0, 0]
25
+ freeze
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(PaneGeometry) &&
30
+ width == other.width && height == other.height && grow == other.grow &&
31
+ border == other.border && padding == other.padding &&
32
+ min_width == other.min_width && max_width == other.max_width &&
33
+ min_height == other.min_height && max_height == other.max_height
34
+ end
35
+ alias_method :eql?, :==
36
+
37
+ def hash
38
+ [width, height, grow, border, padding, min_width, max_width, min_height, max_height].hash
39
+ end
40
+
41
+ def border_top = border ? 1 : 0
42
+ def border_right = border ? 1 : 0
43
+ def border_bottom = border ? 1 : 0
44
+ def border_left = border ? 1 : 0
45
+
46
+ attr_reader :padding_values
47
+
48
+ def padding_top = padding ? @padding_values[0] : 0
49
+ def padding_right = padding ? @padding_values[1] : 0
50
+ def padding_bottom = padding ? @padding_values[2] : 0
51
+ def padding_left = padding ? @padding_values[3] : 0
52
+
53
+ def inset(rect)
54
+ rect.inset(
55
+ top: border_top + padding_top,
56
+ right: border_right + padding_right,
57
+ bottom: border_bottom + padding_bottom,
58
+ left: border_left + padding_left
59
+ )
60
+ end
61
+
62
+ def border_style
63
+ (border == true) ? :normal : border
64
+ end
65
+
66
+ private
67
+
68
+ def expand_padding(values)
69
+ case values.length
70
+ when 1 then [values[0], values[0], values[0], values[0]]
71
+ when 2 then [values[0], values[1], values[0], values[1]]
72
+ when 4 then values
73
+ else
74
+ raise ArgumentError, "padding expects 1, 2, or 4 values"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Layout
5
+ # PaneStyle holds a Pane's base style and the focused-state override.
6
+ # It resolves which style to use at render time given the pane's current
7
+ # focus state and the view's theme.
8
+ class PaneStyle
9
+ attr_reader :style, :focused_style
10
+
11
+ def self.build(style: nil, focused_style: nil)
12
+ new(style: style, focused_style: focused_style)
13
+ end
14
+
15
+ def initialize(style:, focused_style:)
16
+ @style, @focused_style = style, focused_style
17
+ freeze
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(PaneStyle) &&
22
+ style == other.style && focused_style == other.focused_style
23
+ end
24
+ alias_method :eql?, :==
25
+
26
+ def hash
27
+ [style, focused_style].hash
28
+ end
29
+
30
+ # Returns the active style for *focused*: the focused override when the
31
+ # pane is focused, otherwise the configured *style* or a default UI::Style.
32
+ def resolve(view, focused:)
33
+ return focused_style || view.__send__(:theme).title if focused
34
+
35
+ style || UI.style
36
+ end
37
+ end
38
+ end
39
+ end