charming 0.1.3 → 0.2.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -14
  3. data/lib/charming/application.rb +19 -2
  4. data/lib/charming/cli.rb +3 -3
  5. data/lib/charming/controller/component_dispatching.rb +47 -3
  6. data/lib/charming/controller/focus.rb +123 -0
  7. data/lib/charming/controller/focus_management.rb +1 -1
  8. data/lib/charming/controller/rendering.rb +4 -15
  9. data/lib/charming/controller/session_state.rb +11 -0
  10. data/lib/charming/controller.rb +11 -2
  11. data/lib/charming/database/commands.rb +106 -0
  12. data/lib/charming/generators/database_installer.rb +154 -0
  13. data/lib/charming/generators/model_generator.rb +2 -10
  14. data/lib/charming/generators/name.rb +1 -1
  15. data/lib/charming/generators/view_generator.rb +1 -1
  16. data/lib/charming/presentation/components/form/field.rb +1 -1
  17. data/lib/charming/presentation/components/markdown.rb +7 -7
  18. data/lib/charming/presentation/layout/builder.rb +8 -2
  19. data/lib/charming/presentation/layout/pane.rb +40 -108
  20. data/lib/charming/presentation/layout/pane_behavior.rb +39 -0
  21. data/lib/charming/presentation/layout/pane_geometry.rb +71 -0
  22. data/lib/charming/presentation/layout/pane_style.rb +39 -0
  23. data/lib/charming/presentation/layout/rect.rb +5 -0
  24. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  25. data/lib/charming/presentation/layout/split.rb +7 -0
  26. data/lib/charming/presentation/markdown/render_context.rb +28 -10
  27. data/lib/charming/presentation/markdown/renderer.rb +264 -39
  28. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  29. data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
  30. data/lib/charming/presentation/markdown.rb +2 -2
  31. data/lib/charming/presentation/view.rb +7 -0
  32. data/lib/charming/router.rb +3 -8
  33. data/lib/charming/runtime.rb +2 -0
  34. data/lib/charming/version.rb +1 -1
  35. data/lib/charming.rb +2 -2
  36. metadata +45 -9
  37. data/lib/charming/database_commands.rb +0 -103
  38. data/lib/charming/database_installer.rb +0 -152
  39. data/lib/charming/focus.rb +0 -121
  40. data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
  41. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
42
  def action_class_name
43
- action.split("_").map(&:capitalize).join
43
+ ActiveSupport::Inflector.camelize(action)
44
44
  end
45
45
  end
46
46
  end
@@ -111,7 +111,7 @@ module Charming
111
111
 
112
112
  # Converts a snake_case symbol/string to a humanized "Capitalized" string.
113
113
  def humanize(value)
114
- value.to_s.tr("_", " ").capitalize
114
+ ActiveSupport::Inflector.humanize(value)
115
115
  end
116
116
  end
117
117
  end
@@ -2,17 +2,15 @@
2
2
 
3
3
  module Charming
4
4
  module Components
5
- # Markdown renders Markdown source as ANSI-styled terminal text. Parsing is delegated to
6
- # `Charming::Markdown::Renderer`; set *syntax_highlighting* to false to disable
7
- # Rouge-backed code block highlighting.
5
+ # Markdown renders CommonMark/GFM source as ANSI-styled terminal text.
8
6
  class Markdown < Component
9
- # *content* is the Markdown source string. *width* optionally sets the wrap width.
10
- # *syntax_highlighting* enables Rouge for code blocks (defaults to true).
11
- def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
7
+ def initialize(content:, width: nil, theme: nil, syntax_highlighting: true, style: :dark, base_url: nil)
12
8
  super(theme: theme)
13
9
  @content = content
14
10
  @width = width
15
11
  @syntax_highlighting = syntax_highlighting
12
+ @style = style
13
+ @base_url = base_url
16
14
  end
17
15
 
18
16
  # Renders the Markdown body to a styled, terminal-safe string.
@@ -21,7 +19,9 @@ module Charming
21
19
  content: @content,
22
20
  width: @width,
23
21
  theme: theme,
24
- syntax_highlighting: @syntax_highlighting
22
+ syntax_highlighting: @syntax_highlighting,
23
+ style: @style,
24
+ base_url: @base_url
25
25
  ).render
26
26
  end
27
27
  end
@@ -36,9 +36,15 @@ 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(:width, :height, :grow, :border, :padding)),
45
+ style: PaneStyle.build(**options.slice(:style, :focused_style)),
46
+ behavior: PaneBehavior.build(**options.slice(:focus, :scroll, :clip, :wrap))
47
+ )
42
48
  append(node)
43
49
  node
44
50
  end
@@ -4,34 +4,20 @@ 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, to: :geometry
12
+
13
+ # *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
14
+ # is the view used for instance_exec when the block is given. *geometry*, *style*, and
15
+ # *behavior* are value objects that own sizing, styling, and render-time flags.
16
+ def initialize(name: nil, content: nil, block: nil, view: nil,
17
+ geometry: PaneGeometry.new, style: PaneStyle.new,
18
+ behavior: PaneBehavior.new)
19
+ @name, @content, @block, @view = name, content, block, view
20
+ @geometry, @style, @behavior = geometry, style, behavior
35
21
  end
36
22
 
37
23
  # Raises ArgumentError — panes are leaves and cannot contain layout children.
@@ -39,110 +25,56 @@ module Charming
39
25
  raise ArgumentError, "pane cannot contain layout children"
40
26
  end
41
27
 
42
- # Returns [name] when the pane is marked focusable and has a name, otherwise [].
28
+ # Returns [name] when the pane is focusable and named, otherwise [].
43
29
  def focusable_names
44
- (focus && name) ? [name] : []
30
+ (@behavior.focus && name) ? [name] : []
31
+ end
32
+
33
+ # Returns the mouse target represented by this pane, if it has a name.
34
+ def mouse_targets(rect)
35
+ return [] unless name
36
+
37
+ [{name: name, rect: rect, inner_rect: @geometry.inset(rect)}]
45
38
  end
46
39
 
47
40
  # Renders the pane into *rect*, applying the configured style, border, and padding
48
41
  # around the evaluated content.
49
42
  def render(rect)
50
- outer_style(rect).render(rendered_content(rect))
43
+ inner = @geometry.inset(rect)
44
+ outer_style(inner).render(rendered_content(inner))
51
45
  end
52
46
 
53
47
  private
54
48
 
55
- # The raw content, the body block, the view used for instance_exec, and styling options.
56
- attr_reader :content, :block, :view, :border, :padding, :style, :focused_style, :focus, :scroll, :clip, :wrap
57
-
58
- # Returns the content string for *rect*, optionally clipped/scrolled by an embedded Viewport.
59
- def rendered_content(rect)
60
- content_rect = inner_rect(rect)
61
- value = evaluate_content(content_rect)
62
- return value unless clip || scroll
63
-
64
- Components::Viewport.new(content: value, width: content_rect.width, height: content_rect.height, wrap: wrap).render
65
- end
49
+ attr_reader :content, :block, :view, :geometry, :style, :behavior
66
50
 
67
- # Evaluates the configured content (block or constant) and renders it to a string.
68
- # Pane blocks may accept the inner content Rect when they need exact available dimensions.
69
- def evaluate_content(content_rect)
51
+ # Returns the content string for *content_rect*, optionally clipped/scrolled by an
52
+ # embedded Viewport when *behavior.should_viewport?* is true.
53
+ def rendered_content(content_rect)
70
54
  value = if block
71
55
  block.arity.zero? ? view.instance_exec(&block) : view.instance_exec(content_rect, &block)
72
56
  else
73
57
  content
74
58
  end
75
- value.respond_to?(:render) ? value.render.to_s : value.to_s
59
+ return value.to_s unless value.respond_to?(:render)
60
+ return value.render.to_s unless @behavior.should_viewport?
61
+
62
+ Components::Viewport.new(content: value.render, width: content_rect.width,
63
+ height: content_rect.height, wrap: @behavior.wrap).render.to_s
76
64
  end
77
65
 
78
66
  # Builds the outer style object with optional border and padding, sized to the
79
67
  # inner rect of the pane.
80
- def outer_style(rect)
81
- styled = current_style
82
- styled = styled.border(border_style) if border
83
- styled = styled.padding(*padding_values) if padding
84
- styled.width(inner_rect(rect).width).height(inner_rect(rect).height)
85
- end
86
-
87
- # Returns the active style: the focused variant when the pane is focused, otherwise
88
- # the configured style or a default UI::Style.
89
- def current_style
90
- return focused_pane_style if focused?
91
-
92
- style || UI.style
93
- end
94
-
95
- # Returns the focused-pane style: the focused_style override, or the theme's title style.
96
- def focused_pane_style
97
- focused_style || view.__send__(:theme).title
68
+ def outer_style(inner)
69
+ styled = @style.resolve(view, focused: focused?)
70
+ styled = styled.border(@geometry.border_style) if @geometry.border
71
+ styled = styled.padding(*@geometry.padding_values) if @geometry.padding
72
+ styled.width(inner.width).height(inner.height)
98
73
  end
99
74
 
100
- # True when the pane is configured for focus and the view reports it as currently focused.
75
+ # True when the pane is configured for focus and the view reports it as focused.
101
76
  def focused?
102
- focus && name && view.focused?(name)
103
- end
104
-
105
- # Returns the inner Rect after border and padding insets are applied.
106
- def inner_rect(rect)
107
- rect.inset(
108
- top: border_top + padding_top,
109
- right: border_right + padding_right,
110
- bottom: border_bottom + padding_bottom,
111
- left: border_left + padding_left
112
- )
113
- end
114
-
115
- # Resolves the border style symbol: :normal when border is `true`, otherwise the configured value.
116
- def border_style
117
- (border == true) ? :normal : border
118
- end
119
-
120
- # Border thickness on each side (1 when a border is configured, 0 otherwise).
121
- def border_top = border ? 1 : 0
122
- def border_right = border ? 1 : 0
123
- def border_bottom = border ? 1 : 0
124
- def border_left = border ? 1 : 0
125
-
126
- # The padding values normalized to [top, right, bottom, left] form.
127
- def padding_values
128
- @padding_values ||= expand_padding(Array(padding))
129
- end
130
-
131
- # Per-side padding values (0 when no padding is configured).
132
- def padding_top = padding ? padding_values[0] : 0
133
- def padding_right = padding ? padding_values[1] : 0
134
- def padding_bottom = padding ? padding_values[2] : 0
135
- def padding_left = padding ? padding_values[3] : 0
136
-
137
- # Normalizes 1/2/4 padding arguments to [top, right, bottom, left].
138
- def expand_padding(values)
139
- case values.length
140
- when 1 then [values[0], values[0], values[0], values[0]]
141
- when 2 then [values[0], values[1], values[0], values[1]]
142
- when 4 then values
143
- else
144
- raise ArgumentError, "padding expects 1, 2, or 4 values"
145
- end
77
+ @behavior.focus && name && view.focused?(name)
146
78
  end
147
79
  end
148
80
  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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Layout
5
+ # PaneGeometry holds a Pane's sizing (width, height, grow) and inset
6
+ # 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
+
11
+ def self.build(width: nil, height: nil, grow: nil, border: nil, padding: nil)
12
+ new(width: width, height: height, grow: grow,
13
+ border: (border == true) ? :normal : border, padding: padding)
14
+ end
15
+
16
+ def initialize(width:, height:, grow:, border:, padding:)
17
+ @width, @height, @grow, @border, @padding = width, height, grow, border, padding
18
+ @padding_values = padding ? expand_padding(Array(padding)) : [0, 0, 0, 0]
19
+ freeze
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(PaneGeometry) &&
24
+ width == other.width && height == other.height && grow == other.grow &&
25
+ border == other.border && padding == other.padding
26
+ end
27
+ alias_method :eql?, :==
28
+
29
+ def hash
30
+ [width, height, grow, border, padding].hash
31
+ end
32
+
33
+ def border_top = border ? 1 : 0
34
+ def border_right = border ? 1 : 0
35
+ def border_bottom = border ? 1 : 0
36
+ def border_left = border ? 1 : 0
37
+
38
+ attr_reader :padding_values
39
+
40
+ def padding_top = padding ? @padding_values[0] : 0
41
+ def padding_right = padding ? @padding_values[1] : 0
42
+ def padding_bottom = padding ? @padding_values[2] : 0
43
+ def padding_left = padding ? @padding_values[3] : 0
44
+
45
+ def inset(rect)
46
+ rect.inset(
47
+ top: border_top + padding_top,
48
+ right: border_right + padding_right,
49
+ bottom: border_bottom + padding_bottom,
50
+ left: border_left + padding_left
51
+ )
52
+ end
53
+
54
+ def border_style
55
+ (border == true) ? :normal : border
56
+ end
57
+
58
+ private
59
+
60
+ def expand_padding(values)
61
+ case values.length
62
+ when 1 then [values[0], values[0], values[0], values[0]]
63
+ when 2 then [values[0], values[1], values[0], values[1]]
64
+ when 4 then values
65
+ else
66
+ raise ArgumentError, "padding expects 1, 2, or 4 values"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ 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
@@ -6,6 +6,11 @@ module Charming
6
6
  # (width, height). Layout operations produce new Rect instances rather than mutating
7
7
  # existing ones.
8
8
  Rect = Data.define(:x, :y, :width, :height) do
9
+ # Returns true when the zero-based cell coordinate falls within this rectangle.
10
+ def cover?(point_x, point_y)
11
+ point_x >= x && point_x < x + width && point_y >= y && point_y < y + height
12
+ end
13
+
9
14
  # Returns a new Rect inset by *top*/*right*/*bottom*/*left* cells. The result is
10
15
  # clamped to a minimum width/height of 0.
11
16
  def inset(top: 0, right: 0, bottom: 0, left: 0)
@@ -32,6 +32,13 @@ module Charming
32
32
  child ? child.focusable_names : []
33
33
  end
34
34
 
35
+ # Returns all named pane mouse targets for the full screen layout.
36
+ def mouse_targets
37
+ return [] unless child
38
+
39
+ child.mouse_targets(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
40
+ end
41
+
35
42
  # Renders the child into the full-screen rect, then overlays each registered overlay
36
43
  # on top in order.
37
44
  def render
@@ -32,6 +32,13 @@ module Charming
32
32
  children.flat_map(&:focusable_names)
33
33
  end
34
34
 
35
+ # Returns all named pane mouse targets in this split, preserving declaration order.
36
+ def mouse_targets(rect)
37
+ child_rects(rect).zip(children).flat_map do |child_rect, child|
38
+ child.respond_to?(:mouse_targets) ? child.mouse_targets(child_rect) : []
39
+ end
40
+ end
41
+
35
42
  # Renders each child into its own sub-rect, then overlays them on a blank canvas
36
43
  # of the parent's dimensions.
37
44
  def render(rect)
@@ -2,18 +2,36 @@
2
2
 
3
3
  module Charming
4
4
  module Markdown
5
- # RenderContext carries the state needed to render nested Markdown blocks: the current
6
- # list nesting depth (used for indentation) and the wrap width.
7
- RenderContext = Data.define(:list_depth, :width) do
8
- # Builds a new RenderContext with the given *width* and optional starting *list_depth*.
9
- def self.from(width:, list_depth: 0)
10
- new(list_depth: list_depth, width: width)
5
+ # RenderContext carries render-time state that needs to flow down the Markdown AST.
6
+ RenderContext = Data.define(:width, :list_depth, :style, :current_style, :base_url, :source_lines) do
7
+ def self.from(width:, style:, base_url: nil, source_lines: [], list_depth: 0, current_style: nil)
8
+ new(
9
+ width: width,
10
+ list_depth: list_depth,
11
+ style: style,
12
+ current_style: current_style || style[:document],
13
+ base_url: base_url,
14
+ source_lines: source_lines
15
+ )
11
16
  end
12
17
 
13
- # Returns a derived context with the list depth incremented by *depth_increment*
14
- # and the wrap width overridden to *width* (defaults to the current width).
15
- def nested(depth_increment: 0, width: self.width)
16
- self.class.new(list_depth: list_depth + depth_increment, width: width)
18
+ def with(width: self.width, list_depth: self.list_depth, current_style: self.current_style)
19
+ self.class.new(
20
+ width: width,
21
+ list_depth: list_depth,
22
+ style: style,
23
+ current_style: current_style,
24
+ base_url: base_url,
25
+ source_lines: source_lines
26
+ )
27
+ end
28
+
29
+ def nested_list(width: self.width)
30
+ with(width: width, list_depth: list_depth + 1)
31
+ end
32
+
33
+ def inherit(style_name)
34
+ current_style.inherit_visual(style[style_name])
17
35
  end
18
36
  end
19
37
  end