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
@@ -2,20 +2,31 @@
2
2
 
3
3
  module Charming
4
4
  module Presentation
5
+ # Templates resolves and renders view templates by name. Template handlers are registered
6
+ # for file extensions (e.g., `.tui.erb`) and the resolver searches `app/views/<name><ext>`
7
+ # under the application root, falling back through registered extensions when the first
8
+ # match is not found.
5
9
  module Templates
10
+ # A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
6
11
  ResolvedTemplate = Data.define(:path, :handler) do
12
+ # Renders the template against *view* by delegating to the registered handler.
7
13
  def render(view)
8
14
  handler.render(path, view)
9
15
  end
10
16
  end
11
17
 
18
+ # Raised when no template file matches the given name under the application root.
12
19
  MissingTemplateError = Class.new(Error)
13
20
 
14
21
  class << self
22
+ # Registers a template *handler* for a file *extension* (e.g., ".tui.erb" => ErbHandler).
23
+ # The handler responds to `.render(path, view)`.
15
24
  def register(extension, handler)
16
25
  handlers[extension] = handler
17
26
  end
18
27
 
28
+ # Resolves a template by *name* under `app/views` of *root* (defaults to the current
29
+ # working directory). Raises MissingTemplateError when no matching file exists.
19
30
  def resolve(name, root: nil)
20
31
  views_root = File.join(root || Dir.pwd, "app", "views")
21
32
  searched_paths = candidate_paths(views_root, name.to_s)
@@ -29,12 +40,16 @@ module Charming
29
40
  raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
30
41
  end
31
42
 
43
+ # Hash of registered handlers keyed by extension. Populated by `register`.
32
44
  def handlers
33
45
  @handlers ||= {}
34
46
  end
35
47
 
36
48
  private
37
49
 
50
+ # Returns candidate paths under *views_root* for *name*. When the bare path has a known
51
+ # extension, returns it directly; otherwise returns the path with each registered extension
52
+ # appended (in registration order).
38
53
  def candidate_paths(views_root, name)
39
54
  path = File.expand_path(name, views_root)
40
55
  return [path] if handler_for(path)
@@ -42,6 +57,8 @@ module Charming
42
57
  handlers.keys.map { |extension| "#{path}#{extension}" }
43
58
  end
44
59
 
60
+ # Looks up the handler whose registered extension matches the end of *path*. Returns nil
61
+ # when no handler matches.
45
62
  def handler_for(path)
46
63
  handlers.find { |extension, _handler| path.end_with?(extension) }&.last
47
64
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class ANSICodes
7
+ ATTRIBUTES = {
8
+ bold: 1,
9
+ faint: 2,
10
+ italic: 3,
11
+ underline: 4,
12
+ reverse: 7,
13
+ strikethrough: 9
14
+ }.freeze
15
+
16
+ COLORS = {
17
+ black: 30,
18
+ red: 31,
19
+ green: 32,
20
+ yellow: 33,
21
+ blue: 34,
22
+ magenta: 35,
23
+ cyan: 36,
24
+ white: 37,
25
+ bright_black: 90,
26
+ bright_red: 91,
27
+ bright_green: 92,
28
+ bright_yellow: 93,
29
+ bright_blue: 94,
30
+ bright_magenta: 95,
31
+ bright_cyan: 96,
32
+ bright_white: 97
33
+ }.freeze
34
+
35
+ def initialize(attributes:, foreground:, background:)
36
+ @attributes = attributes
37
+ @foreground = foreground
38
+ @background = background
39
+ end
40
+
41
+ def codes
42
+ @codes ||= attribute_codes +
43
+ color_codes(@foreground, foreground: true) +
44
+ color_codes(@background, foreground: false)
45
+ end
46
+
47
+ def apply(value)
48
+ return value if codes.empty?
49
+
50
+ start = "\e[#{codes.join(";")}m"
51
+ value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
52
+ end
53
+
54
+ private
55
+
56
+ def attribute_codes
57
+ @attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
58
+ end
59
+
60
+ def color_codes(color, foreground:)
61
+ return [] unless color
62
+ return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
63
+ return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
64
+ return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
65
+
66
+ raise ArgumentError, "unknown color: #{color.inspect}"
67
+ end
68
+
69
+ def named_color_code(color, foreground:)
70
+ code = COLORS.fetch(color.to_sym)
71
+ [foreground ? code : code + 10]
72
+ end
73
+
74
+ def indexed_color_code(color, foreground:)
75
+ raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
76
+
77
+ [foreground ? 38 : 48, 5, color]
78
+ end
79
+
80
+ def truecolor_codes(color, foreground:)
81
+ hex = color.to_s.delete_prefix("#")
82
+ raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
83
+
84
+ [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ # ANSISlicer extracts a visible substring from a string that may contain ANSI
7
+ # escape sequences, preserving the styling that is active at the start of
8
+ # the slice and emitting a trailing reset if any styled content was copied.
9
+ class ANSISlicer
10
+ def self.slice(line, start_column, width)
11
+ return "" unless width.positive?
12
+
13
+ slice_range(line.to_s, start_column, start_column + width)
14
+ end
15
+
16
+ def self.slice_range(line, start_column, end_column)
17
+ state = {column: 0, output: +"", active: [], started: false, styled: false}
18
+
19
+ each_ansi_or_char(line) do |token, ansi|
20
+ if ansi
21
+ slice_ansi_token(token, state, start_column, end_column)
22
+ else
23
+ slice_char(token, state, start_column, end_column)
24
+ end
25
+ end
26
+
27
+ terminate_slice(state)
28
+ end
29
+
30
+ def self.each_ansi_or_char(line)
31
+ index = 0
32
+ while index < line.length
33
+ match = line.match(Width::ANSI_PATTERN, index)
34
+ if match&.begin(0) == index
35
+ yield match[0], true
36
+ index = match.end(0)
37
+ else
38
+ yield line[index], false
39
+ index += 1
40
+ end
41
+ end
42
+ end
43
+
44
+ def self.slice_ansi_token(token, state, start_column, end_column)
45
+ started = state[:started]
46
+ update_active_styles(state[:active], token)
47
+ return unless state[:column].between?(start_column, end_column - 1)
48
+
49
+ start_slice(state)
50
+ if started
51
+ state[:output] << token
52
+ state[:styled] = !token.include?("[0m")
53
+ end
54
+ end
55
+
56
+ def self.slice_char(char, state, start_column, end_column)
57
+ char_width = Width.measure(char)
58
+ char_start = state[:column]
59
+ char_end = char_start + char_width
60
+ state[:column] = char_end
61
+ return unless char_end > start_column && char_start < end_column
62
+
63
+ start_slice(state)
64
+ state[:output] << char
65
+ end
66
+
67
+ def self.start_slice(state)
68
+ return if state[:started]
69
+
70
+ state[:output] << state[:active].join
71
+ state[:styled] = true unless state[:active].empty?
72
+ state[:started] = true
73
+ end
74
+
75
+ def self.terminate_slice(state)
76
+ return state[:output] if !state[:styled] || state[:output].empty?
77
+
78
+ "#{state[:output]}\e[0m"
79
+ end
80
+
81
+ def self.update_active_styles(active, token)
82
+ if token.include?("[0m")
83
+ active.clear
84
+ else
85
+ active << token
86
+ end
87
+ end
88
+
89
+ private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
90
+ :start_slice, :terminate_slice, :update_active_styles
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class BorderPainter
7
+ DEFAULT_SIDES = %i[top right bottom left].freeze
8
+
9
+ def initialize(border:, sides: nil, foreground: nil, background: nil)
10
+ @border = border
11
+ @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
12
+ @foreground = foreground
13
+ @background = background
14
+ end
15
+
16
+ def paint(lines, inner_width)
17
+ horizontal = @border.horizontal * inner_width
18
+ body = lines.map { |line| border_line(line, inner_width) }
19
+
20
+ [top_border(horizontal), *body, bottom_border(horizontal)].compact
21
+ end
22
+
23
+ private
24
+
25
+ def border_line(line, width)
26
+ left = @sides.include?(:left) ? render_border(@border.vertical) : ""
27
+ right = @sides.include?(:right) ? render_border(@border.vertical) : ""
28
+
29
+ "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
30
+ end
31
+
32
+ def top_border(horizontal)
33
+ return unless @sides.include?(:top)
34
+ return render_border(horizontal) unless full_horizontal?
35
+
36
+ render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
37
+ end
38
+
39
+ def bottom_border(horizontal)
40
+ return unless @sides.include?(:bottom)
41
+ return render_border(horizontal) unless full_horizontal?
42
+
43
+ render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
44
+ end
45
+
46
+ def full_horizontal?
47
+ @sides.include?(:left) && @sides.include?(:right)
48
+ end
49
+
50
+ def render_border(value)
51
+ return value unless @foreground
52
+
53
+ Style.new(foreground: @foreground, background: @background).render(value)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ # Canvas is a 2D character grid of fixed width and height that supports
7
+ # placing content at (row, column) coordinates and overlaying one block
8
+ # on top of another. Construct via .new(width, height) for a blank grid
9
+ # or .parse(string) to reconstruct from rendered output.
10
+ class Canvas
11
+ def initialize(width, height)
12
+ @width = width
13
+ @height = height
14
+ @grid = Array.new(height) { " " * width }
15
+ end
16
+
17
+ def self.parse(string)
18
+ lines = string.to_s.lines(chomp: true)
19
+ width = UI.block_width(lines)
20
+ canvas = new(width, lines.length)
21
+ lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
22
+ canvas
23
+ end
24
+
25
+ def to_s
26
+ @grid.join("\n")
27
+ end
28
+
29
+ def place(block, top: 0, left: 0, background: nil)
30
+ lines = block.to_s.lines(chomp: true)
31
+ row = Canvas.offset(top, @height, lines.length)
32
+ column = Canvas.offset(left, @width, UI.block_width(lines))
33
+ draw_lines(lines, row: row, column: column, onto: @grid)
34
+ rendered = to_s
35
+ background ? UI::Style.new.background(background).render(rendered) : rendered
36
+ end
37
+
38
+ def overlay(other, top: :center, left: :center)
39
+ overlay_lines = other.to_s.lines(chomp: true)
40
+ row = Canvas.offset(top, @grid.length, overlay_lines.length)
41
+ column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
42
+ draw_lines(overlay_lines, row: row, column: column, onto: @grid)
43
+ self
44
+ end
45
+
46
+ def self.offset(value, available, size)
47
+ return [(available - size) / 2, 0].max if value == :center
48
+
49
+ value
50
+ end
51
+
52
+ private
53
+
54
+ def draw_lines(lines, row:, column:, onto:)
55
+ lines.each_with_index do |line, index|
56
+ line_index = row + index
57
+ next if line_index.negative? || line_index >= onto.length
58
+
59
+ onto[line_index] = compose_line(onto[line_index], line, column)
60
+ end
61
+ end
62
+
63
+ def compose_line(base_line, overlay_line, column)
64
+ return ANSISlicer.slice(base_line, 0, @width) if column >= @width
65
+ return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
66
+
67
+ target_column = [column, 0].max
68
+ overlay_start = [0 - column, 0].max
69
+ overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
70
+ overlay_width = Width.measure(overlay)
71
+ return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
72
+
73
+ right_column = target_column + overlay_width
74
+
75
+ ANSISlicer.slice(base_line, 0, target_column) +
76
+ overlay +
77
+ ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -3,35 +3,18 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module UI
6
+ # Style is an immutable builder for terminal text styling. Every method returns a new
7
+ # Style instance with the requested attribute added, so styles can be safely chained and
8
+ # shared across views. `render(value)` applies the accumulated style to a string.
6
9
  class Style
7
- ATTRIBUTES = {
8
- bold: 1,
9
- faint: 2,
10
- italic: 3,
11
- underline: 4,
12
- reverse: 7,
13
- strikethrough: 9
14
- }.freeze
15
-
16
- COLORS = {
17
- black: 30,
18
- red: 31,
19
- green: 32,
20
- yellow: 33,
21
- blue: 34,
22
- magenta: 35,
23
- cyan: 36,
24
- white: 37,
25
- bright_black: 90,
26
- bright_red: 91,
27
- bright_green: 92,
28
- bright_yellow: 93,
29
- bright_blue: 94,
30
- bright_magenta: 95,
31
- bright_cyan: 96,
32
- bright_white: 97
33
- }.freeze
10
+ ATTRIBUTES = ANSICodes::ATTRIBUTES
34
11
 
12
+ COLORS = ANSICodes::COLORS
13
+
14
+ # Initializes a new style with an optional options hash. Recognized keys: `:attributes`
15
+ # (array of attribute symbols), `:padding` ([top, right, bottom, left]), `:align`
16
+ # (`:left`/`:right`/`:center`), and any of `:foreground`, `:background`, `:border`,
17
+ # `:border_sides`, `:border_foreground`, `:width`, `:height`.
35
18
  def initialize(options = {})
36
19
  @options = {
37
20
  attributes: [],
@@ -40,42 +23,58 @@ module Charming
40
23
  }.merge(options)
41
24
  end
42
25
 
26
+ # Returns a new Style with the foreground *color* set. *color* is a color name (":red"),
27
+ # 256-color index (integer), or hex string ("#rrggbb").
43
28
  def foreground(color)
44
29
  with(foreground: color)
45
30
  end
46
31
  alias_method :fg, :foreground
47
32
 
33
+ # Returns a new Style with the background *color* set.
48
34
  def background(color)
49
35
  with(background: color)
50
36
  end
51
37
  alias_method :bg, :background
52
38
 
39
+ # Attribute methods (bold, italic, underline, …) are defined dynamically by the
40
+ # metaprogramming loop below. Each toggles a single text attribute on the style.
53
41
  ATTRIBUTES.each_key do |attribute|
54
42
  define_method(attribute) do
55
43
  with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
56
44
  end
57
45
  end
58
46
 
47
+ # Returns a new Style with the padding set. Accepts 1, 2, or 4 values following CSS-style
48
+ # shorthand: 1 → all sides, 2 → [vertical, horizontal], 4 → [top, right, bottom, left].
59
49
  def padding(*values)
60
50
  with(padding: expand_box_values(values))
61
51
  end
62
52
 
53
+ # Returns a new Style with the border set. *style* is a border name (e.g., :normal,
54
+ # :rounded). *sides* optionally restricts the border to specific sides. *foreground*
55
+ # sets the border color.
63
56
  def border(style = :normal, sides: nil, foreground: nil)
64
57
  with(border: style, border_sides: sides, border_foreground: foreground)
65
58
  end
66
59
 
60
+ # Returns a new Style that fixes the rendered width to *value* (in display columns).
67
61
  def width(value)
68
62
  with(width: value)
69
63
  end
70
64
 
65
+ # Returns a new Style that fixes the rendered height to *value* (in rows).
71
66
  def height(value)
72
67
  with(height: value)
73
68
  end
74
69
 
70
+ # Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
75
71
  def align(value)
76
72
  with(align: value)
77
73
  end
78
74
 
75
+ # Applies the configured style to *value* and returns the styled string. Steps:
76
+ # 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`, 4. apply padding,
77
+ # 5. paint border, 6. emit ANSI attribute/foreground/background escapes.
79
78
  def render(value)
80
79
  lines = apply_dimensions(value.to_s.lines(chomp: true))
81
80
  lines = apply_padding(lines)
@@ -85,28 +84,35 @@ module Charming
85
84
 
86
85
  private
87
86
 
87
+ # Returns a copy of self with *changes* merged into the options hash.
88
88
  def with(changes)
89
89
  self.class.new(@options.merge(changes))
90
90
  end
91
91
 
92
+ # Wraps each line to the target width and applies horizontal alignment, then expands
93
+ # to the target height.
92
94
  def apply_dimensions(lines)
93
95
  content_width = target_content_width(lines)
94
96
  dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
95
97
  apply_height(dimensioned, content_width)
96
98
  end
97
99
 
100
+ # Returns the target content width: the explicit :width if set, otherwise the natural
101
+ # max display width of the lines.
98
102
  def target_content_width(lines)
99
103
  explicit_width = @options[:width]
100
104
  natural_width = lines.map { |line| Width.measure(line) }.max || 0
101
105
  explicit_width || natural_width
102
106
  end
103
107
 
108
+ # Clips *line* to *width* display columns, preserving ANSI styling where possible.
104
109
  def fit_line(line, width)
105
110
  return line if Width.measure(line) <= width
106
111
 
107
112
  UI.visible_slice(line, 0, width)
108
113
  end
109
114
 
115
+ # Truncates or pads the lines array to *height* rows, filling with blank rows.
110
116
  def apply_height(lines, width)
111
117
  height = @options[:height]
112
118
  return lines unless height
@@ -115,6 +121,8 @@ module Charming
115
121
  visible + Array.new([height - visible.length, 0].max) { " " * width }
116
122
  end
117
123
 
124
+ # Applies padding by prepending/appending blank rows (vertical) and indenting each
125
+ # line (horizontal).
118
126
  def apply_padding(lines)
119
127
  top, right, bottom, left = @options.fetch(:padding)
120
128
  inner_width = lines.map { |line| Width.measure(line) }.max || 0
@@ -126,96 +134,54 @@ module Charming
126
134
  Array.new(top, empty) + padded + Array.new(bottom, empty)
127
135
  end
128
136
 
137
+ # Paints the configured border around the lines, when :border is set.
129
138
  def apply_border(lines)
130
139
  border_name = @options[:border]
131
140
  return lines unless border_name
132
141
 
133
- border = Border.fetch(border_name)
134
- sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
135
- width = lines.map { |line| Width.measure(line) }.max || 0
136
- horizontal = border.horizontal * width
137
- body = lines.map { |line| border_line(line, width, border, sides) }
138
-
139
- [top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
142
+ border_painter(border_name).paint(lines, content_width(lines))
140
143
  end
141
144
 
145
+ # Pads a single line to *inner_width*, with *left* and *right* padding spaces.
142
146
  def pad_line(line, inner_width, left, right)
143
147
  (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
144
148
  end
145
149
 
146
- def border_line(line, width, border, sides)
147
- left = sides.include?(:left) ? render_border(border.vertical) : ""
148
- right = sides.include?(:right) ? render_border(border.vertical) : ""
149
-
150
- "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
151
- end
152
-
153
- def top_border(border, horizontal, sides)
154
- return unless sides.include?(:top)
155
- return render_border(horizontal) unless full_horizontal_border?(sides)
156
-
157
- render_border("#{border.top_left}#{horizontal}#{border.top_right}")
158
- end
159
-
160
- def bottom_border(border, horizontal, sides)
161
- return unless sides.include?(:bottom)
162
- return render_border(horizontal) unless full_horizontal_border?(sides)
163
-
164
- render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
165
- end
166
-
167
- def full_horizontal_border?(sides)
168
- sides.include?(:left) && sides.include?(:right)
150
+ # Builds a BorderPainter configured for the current border options.
151
+ def border_painter(border_name)
152
+ BorderPainter.new(
153
+ border: Border.fetch(border_name),
154
+ sides: @options[:border_sides],
155
+ foreground: @options[:border_foreground],
156
+ background: @options[:background]
157
+ )
169
158
  end
170
159
 
171
- def render_border(value)
172
- border_foreground = @options[:border_foreground]
173
- return value unless border_foreground
174
-
175
- Style.new(foreground: border_foreground, background: @options[:background]).render(value)
160
+ # Returns the natural display width of the longest line in *lines*.
161
+ def content_width(lines)
162
+ lines.map { |line| Width.measure(line) }.max || 0
176
163
  end
177
164
 
165
+ # Applies the active ANSI attribute/foreground/background codes to *value*.
178
166
  def apply_ansi(value)
179
- codes = ansi_codes
180
- return value if codes.empty?
181
-
182
- start = "\e[#{codes.join(";")}m"
183
- value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
167
+ ansi_codes_obj.apply(value)
184
168
  end
185
169
 
170
+ # The list of active ANSI escape sequence strings (attribute + foreground + background).
186
171
  def ansi_codes
187
- @options.fetch(:attributes).map { |attribute| ATTRIBUTES.fetch(attribute) } +
188
- color_codes(@options[:foreground], foreground: true) +
189
- color_codes(@options[:background], foreground: false)
190
- end
191
-
192
- def color_codes(color, foreground:)
193
- return [] unless color
194
- return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
195
- return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
196
- return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
197
-
198
- raise ArgumentError, "unknown color: #{color.inspect}"
172
+ ansi_codes_obj.codes
199
173
  end
200
174
 
201
- def named_color_code(color, foreground:)
202
- code = COLORS.fetch(color.to_sym)
203
- [foreground ? code : code + 10]
204
- end
205
-
206
- def indexed_color_code(color, foreground:)
207
- raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
208
-
209
- [foreground ? 38 : 48, 5, color]
210
- end
211
-
212
- def truecolor_codes(color, foreground:)
213
- hex = color.to_s.delete_prefix("#")
214
- raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
215
-
216
- [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
175
+ # Builds an ANSICodes object from the active attributes, foreground, and background.
176
+ def ansi_codes_obj
177
+ ANSICodes.new(
178
+ attributes: @options.fetch(:attributes),
179
+ foreground: @options[:foreground],
180
+ background: @options[:background]
181
+ )
217
182
  end
218
183
 
184
+ # Pads *line* on the left or right (or both, for :center) according to :align.
219
185
  def align_line(line, width)
220
186
  remaining = width - Width.measure(line)
221
187
  return line if remaining <= 0
@@ -231,6 +197,7 @@ module Charming
231
197
  end
232
198
  end
233
199
 
200
+ # Normalizes 1/2/4 padding value arguments into a [top, right, bottom, left] array.
234
201
  def expand_box_values(values)
235
202
  case values.length
236
203
  when 1 then [values[0], values[0], values[0], values[0]]