charming 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Rendering pipeline mixed into Controller. Resolves view classes, template paths, layouts,
|
|
6
|
+
# and assigns. Most helpers are private — only `render`/`render_view`/`render_template` are
|
|
7
|
+
# part of the public controller API and live in `controller.rb` itself.
|
|
8
|
+
module Rendering
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Returns the string body for *body* — if it responds to `render` (e.g., a View or Component),
|
|
12
|
+
# delegates to that; otherwise calls `to_s`.
|
|
13
|
+
def render_body(body)
|
|
14
|
+
body.respond_to?(:render) ? body.render.to_s : body.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Wraps *body* (a string) in the controller's configured layout, if any. When no layout is set
|
|
18
|
+
# the body is returned as-is.
|
|
19
|
+
def render_with_layout(body)
|
|
20
|
+
rendered = render_body(body)
|
|
21
|
+
layout = self.class.layout
|
|
22
|
+
return rendered unless layout
|
|
23
|
+
|
|
24
|
+
render_body(layout_body(layout, body, rendered))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builds the layout wrapper for *body* / *rendered* content. String/Symbol layouts are
|
|
28
|
+
# resolved as templates; other values are treated as layout view classes.
|
|
29
|
+
def layout_body(layout, body, rendered)
|
|
30
|
+
assigns = layout_assigns(body, rendered)
|
|
31
|
+
return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
|
|
32
|
+
|
|
33
|
+
layout.new(**assigns)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns a view object for *name* — a conventional view class when one exists under the
|
|
37
|
+
# application namespace, otherwise a TemplateView rendered from `app/views`.
|
|
38
|
+
def view_body(name, **assigns)
|
|
39
|
+
view_class = conventional_view_class(name)
|
|
40
|
+
return view_class.new(**template_assigns(assigns)) if view_class
|
|
41
|
+
|
|
42
|
+
template_body(name, **assigns)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds the assigns hash passed to layout view constructors: view's own assigns, plus
|
|
46
|
+
# `content:`, `screen:`, `controller:`, and `theme:`.
|
|
47
|
+
def layout_assigns(body, rendered)
|
|
48
|
+
view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the assigns hash from *body* (a View), or an empty hash when *body* doesn't expose them.
|
|
52
|
+
def view_assigns(body)
|
|
53
|
+
body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolves a template by *name* and returns a TemplateView bound to the application's namespace.
|
|
57
|
+
def template_body(name, **assigns)
|
|
58
|
+
Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Looks up the template file under `app/views` relative to the application root.
|
|
62
|
+
def resolve_template(name)
|
|
63
|
+
Presentation::Templates.resolve(name, root: application.class.root)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the assigns hash passed to templates: `screen:`, `controller:`, `theme:` plus user *assigns*.
|
|
67
|
+
def template_assigns(assigns)
|
|
68
|
+
{screen: screen, controller: self, theme: theme}.merge(assigns)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the application namespace constant (e.g., `MyApp`) used for view-class lookup,
|
|
72
|
+
# or nil when the application has no namespace.
|
|
73
|
+
def template_namespace
|
|
74
|
+
namespace_name = application.class.namespace
|
|
75
|
+
return nil if namespace_name.to_s.empty?
|
|
76
|
+
|
|
77
|
+
Object.const_get(namespace_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the conventional view class for *name* (e.g., `MyApp::Home::ShowView`) when defined
|
|
81
|
+
# under the application namespace. Returns nil when no such class exists.
|
|
82
|
+
def conventional_view_class(name)
|
|
83
|
+
namespace = template_namespace
|
|
84
|
+
return unless namespace
|
|
85
|
+
|
|
86
|
+
constant_path = conventional_view_constant_path(name)
|
|
87
|
+
constant_path.reduce(namespace) do |scope, constant_name|
|
|
88
|
+
break unless scope.const_defined?(constant_name, false)
|
|
89
|
+
|
|
90
|
+
scope.const_get(constant_name, false)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Builds the constant lookup path (array of strings) for a conventional view class from *name*.
|
|
95
|
+
# Splits "home/show" → ["Home", "ShowView"].
|
|
96
|
+
def conventional_view_constant_path(name)
|
|
97
|
+
parts = name.to_s.split("/")
|
|
98
|
+
action = parts.pop
|
|
99
|
+
parts.map { |part| camelize(part) } + ["#{camelize(action)}View"]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Converts a snake_case string to CamelCase. Used to build conventional view constant names.
|
|
103
|
+
def camelize(value)
|
|
104
|
+
value.to_s.split("_").map(&:capitalize).join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
|
|
108
|
+
def default_template_name(action)
|
|
109
|
+
"#{controller_template_path}/#{action}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
|
|
113
|
+
def controller_template_path
|
|
114
|
+
underscore(self.class.name.split("::").last.delete_suffix("Controller"))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts CamelCase to snake_case.
|
|
118
|
+
def underscore(value)
|
|
119
|
+
value
|
|
120
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
121
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
122
|
+
.tr("-", "_")
|
|
123
|
+
.downcase
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Session-state helpers mixed into Controller: accessing the application session hash, lazy
|
|
6
|
+
# state-object lookup by name/class, form builder invocation, and async task submission.
|
|
7
|
+
module SessionState
|
|
8
|
+
# Returns the application session hash for this controller. All persistent state (focus,
|
|
9
|
+
# sidebar index, command palette, user state objects) lives here.
|
|
10
|
+
def session
|
|
11
|
+
application.session
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the named session-backed state object, creating it on first access. *name* is a
|
|
15
|
+
# symbol key under `session[:states]`. *state_class* is an ApplicationState subclass whose
|
|
16
|
+
# constructor receives *attributes* on first creation. Subsequent calls return the same object.
|
|
17
|
+
def state(name, state_class, **attributes)
|
|
18
|
+
session[:states] ||= {}
|
|
19
|
+
session[:states][name.to_sym] ||= state_class.new(**attributes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Builds a Form component scoped to the named form slot in `session[:forms]`. The block is
|
|
23
|
+
# evaluated against a Form::Builder (or invoked with the builder as its argument for arity-1 blocks)
|
|
24
|
+
# and returns a Form component pre-bound to the per-form mutable state hash.
|
|
25
|
+
def form(name, &block)
|
|
26
|
+
session[:forms] ||= {}
|
|
27
|
+
form_state = session[:forms][name.to_sym] ||= {}
|
|
28
|
+
builder = Presentation::Components::Form::Builder.new(theme: theme)
|
|
29
|
+
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
30
|
+
builder.build(state: form_state, theme: theme)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Submits a background task with the given *name*. The block is executed by the configured
|
|
34
|
+
# task executor; its return value (or any raised exception) is delivered to the controller
|
|
35
|
+
# as a TaskEvent dispatched to the matching `on_task` handler.
|
|
36
|
+
def run_task(name, &block)
|
|
37
|
+
application.task_executor.submit(name, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
|
|
6
|
+
# routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
|
|
7
|
+
module SidebarNavigation
|
|
8
|
+
# Moves focus to the sidebar. When the controller declared a focus ring, the focus object
|
|
9
|
+
# is updated; otherwise a fallback session key tracks focus.
|
|
10
|
+
def focus_sidebar
|
|
11
|
+
if focus_ring_slot?(:sidebar)
|
|
12
|
+
focus.focus(:sidebar)
|
|
13
|
+
else
|
|
14
|
+
session[:focus] = :sidebar
|
|
15
|
+
end
|
|
16
|
+
session[:sidebar_index] ||= current_route_index
|
|
17
|
+
render_default_action
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Moves focus to the content pane (the inverse of `focus_sidebar`).
|
|
21
|
+
def focus_content
|
|
22
|
+
if focus_ring_slot?(:content)
|
|
23
|
+
focus.focus(:content)
|
|
24
|
+
else
|
|
25
|
+
session[:focus] = :content
|
|
26
|
+
end
|
|
27
|
+
render_default_action
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True when the sidebar is the current focus target. Uses the focus ring when defined.
|
|
31
|
+
def sidebar_focused?
|
|
32
|
+
return focused?(:sidebar) if focus_ring_slot?(:sidebar)
|
|
33
|
+
|
|
34
|
+
session[:focus] == :sidebar
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# True when the content pane is the current focus target. Uses the focus ring when defined.
|
|
38
|
+
def content_focused?
|
|
39
|
+
return focused?(:content) if focus_ring_slot?(:content)
|
|
40
|
+
|
|
41
|
+
session[:focus] == :content
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
|
|
45
|
+
# active route when the session index is unset.
|
|
46
|
+
def sidebar_index
|
|
47
|
+
session[:sidebar_index] || current_route_index
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns all routes from the application's router, in registration order.
|
|
51
|
+
def sidebar_routes
|
|
52
|
+
application.routes.all
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when *candidate* route matches the controller's currently active route (used to
|
|
56
|
+
# highlight the current row in the sidebar).
|
|
57
|
+
def current_route?(candidate)
|
|
58
|
+
return candidate.controller_class == self.class && candidate.action == :show unless route
|
|
59
|
+
|
|
60
|
+
candidate.path == route.path &&
|
|
61
|
+
candidate.controller_class == route.controller_class &&
|
|
62
|
+
candidate.action == route.action
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Returns the index of the route that matches `current_route?`, defaulting to 0.
|
|
68
|
+
def current_route_index
|
|
69
|
+
sidebar_routes.index { |candidate| current_route?(candidate) } || 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Dispatches j/k/enter/tab/escape to sidebar movement and selection; falls through to
|
|
73
|
+
# a default render for any other key.
|
|
74
|
+
def dispatch_sidebar_key
|
|
75
|
+
case key_name
|
|
76
|
+
when :j, :down then sidebar_move(+1)
|
|
77
|
+
when :k, :up then sidebar_move(-1)
|
|
78
|
+
when :enter then sidebar_select
|
|
79
|
+
when :escape, :tab then focus_content
|
|
80
|
+
else render_default_action
|
|
81
|
+
end
|
|
82
|
+
response
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Mouse dispatch for the sidebar. Reserved for future use; returns nil.
|
|
86
|
+
def dispatch_sidebar_mouse
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Moves the sidebar cursor by *delta* positions, clamped to the route list bounds.
|
|
91
|
+
def sidebar_move(delta)
|
|
92
|
+
count = sidebar_routes.length
|
|
93
|
+
return render_default_action if count.zero?
|
|
94
|
+
|
|
95
|
+
session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
|
|
96
|
+
render_default_action
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Selects the route currently highlighted in the sidebar and navigates to it.
|
|
100
|
+
def sidebar_select
|
|
101
|
+
route = sidebar_routes[sidebar_index]
|
|
102
|
+
if focus_ring_slot?(:content)
|
|
103
|
+
focus.focus(:content)
|
|
104
|
+
else
|
|
105
|
+
session[:focus] = :content
|
|
106
|
+
end
|
|
107
|
+
route ? navigate_to(route.path) : render_default_action
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|