charming 0.1.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. metadata +225 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
5
+ MOUSE_BUTTON_MAP = {
6
+ 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
7
+ 64 => :scroll_up, 65 => :scroll_down,
8
+ 66 => :scroll_up, 67 => :scroll_down
9
+ }.freeze
10
+ private_constant :MOUSE_BUTTON_MAP
11
+
12
+ # MouseEvent represents a mouse input event. *button* encodes which button or action was triggered (left,
13
+ # right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
14
+ # capture key state at the time of the event.
15
+ MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
16
+ def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
17
+ super
18
+ end
19
+
20
+ # Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
21
+ def button_name
22
+ MOUSE_BUTTON_MAP.fetch(button, :unknown)
23
+ end
24
+
25
+ # Returns `true` when the current event is a click (left, middle, or right button).
26
+ def click?
27
+ %i[left middle right].include?(button_name)
28
+ end
29
+
30
+ # Returns `true` when the button name maps to either direction of scroll.
31
+ def scroll?
32
+ %i[scroll_up scroll_down].include?(button_name)
33
+ end
34
+
35
+ # Returns `true` when the current event is a mouse release action.
36
+ def release?
37
+ button_name == :release
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
5
+ # in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
6
+ ResizeEvent = Data.define(:width, :height)
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Response encapsulates a controller's dispatch outcome — one of render text, navigate to another route, or quit.
5
+ # Rails-style factories (`render`, `navigate`, `quit`) serve as the public API and map to :kind values
6
+ # that the Runtime interprets at the end of each event loop iteration.
7
+ Response = Data.define(:kind, :body, :path) do
8
+ # Factory constructing a Render response for displaying *body* text on the current screen.
9
+ def self.render(body)
10
+ new(kind: :render, body: body, path: nil)
11
+ end
12
+
13
+ # Factory constructing a NavigateResponse routing to the named *path* (string).
14
+ def self.navigate(path)
15
+ new(kind: :navigate, body: "", path: path)
16
+ end
17
+
18
+ # Factory constructing a QuitResponse signalling termination of the top-level event loop.
19
+ def self.quit
20
+ new(kind: :quit, body: "", path: nil)
21
+ end
22
+
23
+ # Returns `true` when this response is navigating to another screen or route.
24
+ def navigate?
25
+ kind == :navigate
26
+ end
27
+
28
+ # Returns `true` when this response requests quitting the application.
29
+ def quit?
30
+ kind == :quit
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Charming
6
+ # Router manages an application's route table and provides a Rails-inspired DSL for defining routes.
7
+ # Each route maps a URL path to a controller, action (implicitly :show), and title (for sidebar display).
8
+ class Router
9
+ # Route is a Data object holding a route's path template, target controller/action, title, and resolved params.
10
+ Route = Data.define(:path, :controller_class, :action, :title, :params) do
11
+ def with_params(params)
12
+ self.class.new(
13
+ path: path,
14
+ controller_class: controller_class,
15
+ action: action,
16
+ title: title,
17
+ params: params
18
+ )
19
+ end
20
+ end
21
+
22
+ DynamicRoute = Data.define(:route, :pattern, :param_names)
23
+
24
+ # Initializes a new router with an optional namespace prefix for controller constant lookups.
25
+ def initialize(namespace: nil)
26
+ @namespace = namespace
27
+ @routes = {}
28
+ @dynamic_routes = []
29
+ end
30
+
31
+ # Evaluates a block in the context of this Router instance using instance_eval, allowing DSL calls like screen and root to register routes.
32
+ # This is how `routes.draw { screen "/", to: "HomeController", title: "Home" }` works.
33
+ def draw(&)
34
+ instance_eval(&)
35
+ end
36
+
37
+ # Registers the home screen at "/" with a given title. Shorthand for `screen path, to: target`.
38
+ # Example: `root "HomeController"` maps `/` → HomeController#show with title "Home".
39
+ def root(target, title: "Home")
40
+ screen("/", to: target, title: title)
41
+ end
42
+
43
+ # Maps a URL path to a controller and action (e.g. "HomeController" for HomeController#show).
44
+ # Builds a Route object from the path, resolved controller constant, parsed action, and an optional or derived title.
45
+ def screen(path, to:, title: nil)
46
+ controller_name, action = to.split("#", 2)
47
+ route = Route.new(
48
+ path: path,
49
+ controller_class: constantize(controller_constant_name(controller_name)),
50
+ action: action.to_sym,
51
+ title: title || derive_title(path),
52
+ params: {}
53
+ )
54
+ @routes[path] = route
55
+ @dynamic_routes.reject! { |dynamic_route| dynamic_route.route.path == path }
56
+ @dynamic_routes << compile_dynamic_route(route) if dynamic_path?(path)
57
+ end
58
+
59
+ # Resolves a route by path from the router's table. Exact routes win over dynamic routes.
60
+ # Raises KeyError if no route matches.
61
+ # Used at runtime to look up the controller class and action for incoming requests.
62
+ def resolve(path = "/")
63
+ @routes[path] || resolve_dynamic(path) || raise(KeyError, "key not found: #{path.inspect}")
64
+ end
65
+
66
+ # Returns all registered routes as Route objects, ordered by insertion.
67
+ # Consumed by the application loop to populate the sidebar and by controllers for navigation context.
68
+ def all
69
+ @routes.values
70
+ end
71
+
72
+ private
73
+
74
+ # The namespace prefix from initialization — used to scope controller constant lookups.
75
+ # For example, namespace "Admin" means HomeController resolves as Admin::HomeController.
76
+ attr_reader :namespace
77
+
78
+ def dynamic_path?(path)
79
+ path.split("/").any? { |segment| segment.start_with?(":") && segment.length > 1 }
80
+ end
81
+
82
+ def compile_dynamic_route(route)
83
+ param_names = []
84
+ segments = route.path.split("/", -1).map do |segment|
85
+ if segment.start_with?(":") && segment.length > 1
86
+ param_names << segment.delete_prefix(":").to_sym
87
+ "([^/]+)"
88
+ else
89
+ Regexp.escape(segment)
90
+ end
91
+ end
92
+
93
+ DynamicRoute.new(route: route, pattern: /\A#{segments.join("/")}\z/, param_names: param_names)
94
+ end
95
+
96
+ def resolve_dynamic(path)
97
+ @dynamic_routes.each do |dynamic_route|
98
+ match = dynamic_route.pattern.match(path)
99
+ return dynamic_route.route.with_params(extract_params(dynamic_route.param_names, match.captures)) if match
100
+ end
101
+
102
+ nil
103
+ end
104
+
105
+ def extract_params(names, values)
106
+ names.zip(values).to_h do |name, value|
107
+ [name, URI.decode_www_form_component(value)]
108
+ end
109
+ end
110
+
111
+ # Splits a camel-case string into words for title derivation (e.g., "my_route" → ["my", "route"]).
112
+ def camelize(value)
113
+ value.split("_").map(&:capitalize).join
114
+ end
115
+
116
+ # Looks up a constant by name in Object. Used to resolve controller strings from route definitions.
117
+ def constantize(name)
118
+ Object.const_get(name)
119
+ end
120
+
121
+ # Builds the full controller constant name, prepending the namespace if present.
122
+ # For example: "HomeController" with namespace "Admin" → "Admin::HomeController".
123
+ def controller_constant_name(controller_name)
124
+ name = "#{camelize(controller_name)}Controller"
125
+ @namespace.to_s.empty? ? name : "#{@namespace}::#{name}"
126
+ end
127
+
128
+ # Derives a human-readable title from a URL path by stripping the leading slash,
129
+ # splitting on underscores/hyphens/slashes, capitalizing each segment, and joining with spaces.
130
+ # Examples: "/projects" → "Projects", "/projects/list" → "Projects List".
131
+ def derive_title(path)
132
+ return "Home" if path == "/"
133
+
134
+ path.delete_prefix("/").split(%r{[_\-/]}).map(&:capitalize).join(" ")
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Runtime manages a terminal UI application's lifecycle: setting up an
5
+ # alternative-screen terminal with cursor hiding, running an event loop that
6
+ # reads keyboard, mouse, timer, and task events, dispatching them to
7
+ # controllers, rendering responses, and tearing down cleanly on exit.
8
+ class Runtime
9
+ DEFAULT_READ_TIMEOUT = 0.05
10
+
11
+ def initialize(application, backend: nil, renderer: nil, clock: nil, task_executor: nil)
12
+ @application = application
13
+ @backend = backend || Internal::Terminal::TTYBackend.new
14
+ @renderer = renderer || Internal::Renderer::Differential.new(@backend)
15
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
16
+ @task_queue = Thread::Queue.new
17
+ @task_executor = build_task_executor(task_executor)
18
+ @application.task_executor = @task_executor
19
+ @route = @application.routes.resolve("/")
20
+ @screen = backend_screen
21
+ @timers = build_timers
22
+ end
23
+
24
+ # Runs the event loop: enters alt-screen, dispatches incoming events
25
+ # (key, mouse, timer, async task), renders controller responses, and
26
+ # restores terminal state on exit.
27
+ def run
28
+ setup_terminal
29
+ render(resolve_response(dispatch(@route.action)))
30
+ loop do
31
+ event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
32
+ next unless event
33
+
34
+ response = dispatch_event(event)
35
+ next unless response
36
+ break if response.quit?
37
+
38
+ response = resolve_response(response)
39
+ break if response.quit?
40
+
41
+ render(response)
42
+ end
43
+ ensure
44
+ @task_executor&.shutdown(timeout: 0.0)
45
+ restore_terminal
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :screen
51
+
52
+ # Dispatches an action on the current route's controller with an optional event.
53
+ # Entry point from the event loop into controllers.
54
+ def dispatch(action, event: nil)
55
+ controller(event: event).dispatch(action)
56
+ end
57
+
58
+ # Dispatches a key press to the current route's controller.
59
+ def dispatch_key(event)
60
+ controller(event: event).dispatch_key
61
+ end
62
+
63
+ # Dispatches a timer tick to the current route's controller.
64
+ def dispatch_timer(event)
65
+ controller(event: event).dispatch_timer
66
+ end
67
+
68
+ # Dispatches an async task result to the current route's controller.
69
+ def dispatch_task(event)
70
+ controller(event: event).dispatch_task
71
+ end
72
+
73
+ # Dispatches a mouse action (click, drag, scroll) to the current route's controller.
74
+ def dispatch_mouse(event)
75
+ controller(event: event).dispatch_mouse
76
+ end
77
+
78
+ def controller(event: nil)
79
+ @route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen)
80
+ end
81
+
82
+ # Type-based dispatcher: routes resize, task, timer, mouse, and key events
83
+ # to the appropriate handler. Falls back to key dispatch for unclassified events.
84
+ 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)
89
+
90
+ dispatch_key(event)
91
+ end
92
+
93
+ # Dispatches a resize event: updates screen dimensions and re-renders the current action.
94
+ def dispatch_resize(event)
95
+ @screen = Screen.new(width: event.width, height: event.height)
96
+ dispatch(@route.action, event: event)
97
+ end
98
+
99
+ # Follows navigation responses: resolves the new route from the router,
100
+ # resets timers for the new route, and dispatches that route's action.
101
+ def resolve_response(response)
102
+ return response unless response.navigate?
103
+
104
+ @route = @application.routes.resolve(response.path)
105
+ @timers = build_timers
106
+ dispatch(@route.action)
107
+ end
108
+
109
+ # Derives Screen dimensions (width, height) from the terminal backend.
110
+ def backend_screen
111
+ width, height = @backend.size
112
+ Screen.new(width: width, height: height)
113
+ end
114
+
115
+ # Renders the body portion of a response through the renderer.
116
+ def render(response)
117
+ @renderer.render(response.body)
118
+ end
119
+
120
+ # Builds the initial set of timer states from controller bindings and the current clock time.
121
+ def build_timers
122
+ now = clock_now
123
+ @route.controller_class.timer_bindings.values.map do |binding|
124
+ {binding: binding, next_at: now + binding.interval}
125
+ end
126
+ end
127
+
128
+ # Returns a TimerEvent for the first due timer and advances its next fire time.
129
+ # Returns nil if no timers are ready or registered.
130
+ def next_timer_event
131
+ timer = due_timer
132
+ return unless timer
133
+
134
+ now = clock_now
135
+ timer[:next_at] = now + timer.fetch(:binding).interval
136
+ TimerEvent.new(name: timer.fetch(:binding).name, now: now)
137
+ end
138
+
139
+ # Pops a task event from the thread-safe queue if one is available.
140
+ # Non-blocking — returns nil immediately when the queue is empty.
141
+ def next_task_event
142
+ @task_queue.pop(true)
143
+ rescue ThreadError
144
+ nil
145
+ end
146
+
147
+ # Returns timer values due at or before `now`, sorted by next fire time.
148
+ def due_timer
149
+ now = clock_now
150
+ @timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
151
+ end
152
+
153
+ # Computes how long to block waiting for input based on when the next timer is due,
154
+ # clamped between 0 and DEFAULT_READ_TIMEOUT (0.05s). Returns DEFAULT when no timers exist.
155
+ def read_timeout
156
+ next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
157
+ return DEFAULT_READ_TIMEOUT unless next_at
158
+
159
+ (next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
160
+ end
161
+
162
+ # Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
163
+ def build_task_executor(task_executor)
164
+ return TaskExecutor::Threaded.new(@task_queue) unless task_executor
165
+ return task_executor if task_executor.respond_to?(:submit)
166
+ return task_executor.call(@task_queue) if task_executor.respond_to?(:call) && !task_executor.respond_to?(:new)
167
+
168
+ task_executor.new(@task_queue)
169
+ end
170
+
171
+ # Returns the clock proc, providing a single point of access for time in the event loop.
172
+ def clock_now
173
+ @clock.call
174
+ end
175
+
176
+ # Enters an alternative screen buffer, hides the cursor, and installs
177
+ # a terminal resize signal handler if supported by the backend.
178
+ def setup_terminal
179
+ @backend.enter_alt_screen
180
+ @backend.hide_cursor
181
+ @backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
182
+ end
183
+
184
+ # Restores terminal state: reinstalls any previous resize handler, shows
185
+ # the cursor, and leaves the alternative screen buffer.
186
+ def restore_terminal
187
+ @backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
188
+ @backend.show_cursor
189
+ @backend.leave_alt_screen
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Screen represents the terminal viewport dimensions as a simple Data class.
5
+ # The `width` and `height` values flow from the backend through the runtime
6
+ # loop into every controller dispatch for layout calculations.
7
+ Screen = Data.define(:width, :height)
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ Task = Data.define(:name, :block) do
5
+ def call = block.call
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
5
+ # the return result and *error* captures any exception raised during execution. The `error?` predicate
6
+ # simplifies error handling in controller handlers.
7
+ TaskEvent = Data.define(:name, :value, :error) do
8
+ def initialize(name:, value: nil, error: nil)
9
+ super
10
+ end
11
+
12
+ # Returns `true` when the task finished with a non-nil exception.
13
+ def error?
14
+ !error.nil?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module TaskExecutor
5
+ class Threaded
6
+ def initialize(queue)
7
+ @queue = queue
8
+ @threads = []
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def submit(name, &block)
13
+ task = Task.new(name: name.to_sym, block: block)
14
+ thread = Thread.new(task) { |t| @queue << run(t) }
15
+ @mutex.synchronize { @threads << thread }
16
+ nil
17
+ end
18
+
19
+ def shutdown(timeout: 0.0)
20
+ threads = @mutex.synchronize { @threads.dup }
21
+ threads.each { |thread| thread.join(timeout) }
22
+ threads.each do |thread|
23
+ next unless thread.alive?
24
+
25
+ thread.kill
26
+ thread.join(0)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def run(task)
33
+ TaskEvent.new(name: task.name, value: task.call)
34
+ rescue StandardError, ScriptError => e
35
+ TaskEvent.new(name: task.name, error: e)
36
+ end
37
+ end
38
+
39
+ class Inline
40
+ def initialize(queue)
41
+ @queue = queue
42
+ end
43
+
44
+ def submit(name, &block)
45
+ task = Task.new(name: name.to_sym, block: block)
46
+ @queue << run(task)
47
+ nil
48
+ end
49
+
50
+ def shutdown(timeout: 0.0)
51
+ end
52
+
53
+ private
54
+
55
+ def run(task)
56
+ TaskEvent.new(name: task.name, value: task.call)
57
+ rescue StandardError, ScriptError => e
58
+ TaskEvent.new(name: task.name, error: e)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
5
+ # *now* is the monotonically rising clock value at emission for throttle comparisons.
6
+ TimerEvent = Data.define(:name, :now)
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ class Border
6
+ attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
7
+
8
+ def initialize(corners:, edges:)
9
+ @top_left, @top_right, @bottom_left, @bottom_right = corners
10
+ @horizontal, @vertical = edges
11
+ end
12
+
13
+ def self.fetch(name)
14
+ STYLES.fetch(name.to_sym)
15
+ end
16
+ end
17
+
18
+ Border::STYLES = {
19
+ normal: Border.new(
20
+ corners: ["+", "+", "+", "+"], edges: ["-", "|"]
21
+ ),
22
+ rounded: Border.new(
23
+ corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
24
+ ),
25
+ thick: Border.new(
26
+ corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
27
+ ),
28
+ double: Border.new(
29
+ corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
30
+ )
31
+ }.freeze
32
+ end
33
+ end