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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. 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