charming 0.2.1 → 0.2.3

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. data/lib/charming/generators/templates/app/view.template +0 -21
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # AdaptiveColor is a color value that resolves to its light or dark variant
6
+ # at render time, based on the detected terminal background. Build one with
7
+ # `UI.adaptive(light:, dark:)` and use it anywhere a color is accepted.
8
+ class AdaptiveColor
9
+ def initialize(light:, dark:)
10
+ @light = light
11
+ @dark = dark
12
+ end
13
+
14
+ # The variant readable on the current terminal background.
15
+ def resolve
16
+ Background.dark? ? @dark : @light
17
+ end
18
+ end
19
+ end
20
+ end
@@ -57,8 +57,10 @@ module Charming
57
57
  end
58
58
 
59
59
  # Resolves *color* to SGR codes, downconverting to the terminal's capability
60
- # (see UI::ColorSupport): truecolor → 256 → 16 → none.
60
+ # (see UI::ColorSupport): truecolor → 256 → 16 → none. Adaptive colors
61
+ # resolve against the terminal background first.
61
62
  def color_codes(color, foreground:)
63
+ color = color.resolve if color.respond_to?(:resolve)
62
64
  return [] unless color
63
65
  return [] if ColorSupport.level == :none
64
66
  return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
@@ -82,7 +84,8 @@ module Charming
82
84
 
83
85
  def truecolor_codes(color, foreground:)
84
86
  hex = color.to_s.delete_prefix("#")
85
- raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
87
+ hex = hex.chars.map { |digit| digit * 2 }.join if hex.match?(/\A[0-9a-fA-F]{3}\z/)
88
+ raise ArgumentError, "truecolor must be #rgb or #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
86
89
  return [foreground ? 38 : 48, 5, ColorSupport.hex_to_256(hex)] if ColorSupport.level == :color256
87
90
  return basic_color_code(ColorSupport.hex_to_16(hex), foreground: foreground) if ColorSupport.level == :color16
88
91
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Background tracks whether the terminal has a dark or light background so
6
+ # adaptive colors and the markdown :auto style can pick the readable variant.
7
+ #
8
+ # The runtime feeds a definitive answer from an OSC 11 query when the
9
+ # terminal replies to one (see TTYBackend#query_background_color); otherwise
10
+ # detection falls back to the COLORFGBG convention and finally assumes dark —
11
+ # the overwhelmingly common terminal default. `Background.assume = :light`
12
+ # overrides everything (useful in tests and for user preference).
13
+ module Background
14
+ MODES = %i[dark light].freeze
15
+
16
+ # ITU-R BT.601 luma threshold on 8-bit channels: below this is "dark".
17
+ LUMA_THRESHOLD = 128
18
+
19
+ module_function
20
+
21
+ # True when the terminal background is dark (the assumed mode, or detection).
22
+ def dark?
23
+ (@assumed || detect(ENV)) == :dark
24
+ end
25
+
26
+ # Overrides detection with :dark or :light; nil returns to auto-detection.
27
+ def assume=(value)
28
+ raise ArgumentError, "unknown background: #{value.inspect}" if value && !MODES.include?(value)
29
+
30
+ @assumed = value
31
+ end
32
+
33
+ # Detects the background from an environment hash via the COLORFGBG
34
+ # convention ("<fg>;<bg>", where bg 7 or 15 means a light background).
35
+ # Defaults to :dark when the environment says nothing.
36
+ def detect(env)
37
+ bg_index = env["COLORFGBG"].to_s.split(";").last
38
+ %w[7 15].include?(bg_index) ? :light : :dark
39
+ end
40
+
41
+ # Classifies an 8-bit RGB triple as :dark or :light by luma.
42
+ def classify(red, green, blue)
43
+ luma = (0.299 * red) + (0.587 * green) + (0.114 * blue)
44
+ (luma < LUMA_THRESHOLD) ? :dark : :light
45
+ end
46
+
47
+ # Parses an OSC 11 reply ("\e]11;rgb:RRRR/GGGG/BBBB" + BEL or ST) and
48
+ # classifies the reported color. Returns nil for anything unparseable.
49
+ def parse_osc11(reply)
50
+ match = reply.to_s.match(%r{\e\]11;rgb:(\h{2,4})/(\h{2,4})/(\h{2,4})})
51
+ return unless match
52
+
53
+ red, green, blue = match.captures.map { |channel| channel[0, 2].to_i(16) }
54
+ classify(red, green, blue)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -10,7 +10,11 @@ module Charming
10
10
  @horizontal, @vertical = edges
11
11
  end
12
12
 
13
+ # Resolves *name* to a Border: a Border instance passes through (custom
14
+ # borders), anything else is looked up in the built-in STYLES.
13
15
  def self.fetch(name)
16
+ return name if name.is_a?(Border)
17
+
14
18
  STYLES.fetch(name.to_sym)
15
19
  end
16
20
  end
@@ -27,7 +31,16 @@ module Charming
27
31
  ),
28
32
  double: Border.new(
29
33
  corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
34
+ ),
35
+ square: Border.new(
36
+ corners: ["┌", "┐", "└", "┘"], edges: ["─", "│"]
37
+ ),
38
+ hidden: Border.new(
39
+ corners: [" ", " ", " ", " "], edges: [" ", " "]
40
+ ),
41
+ block: Border.new(
42
+ corners: ["█", "█", "█", "█"], edges: ["█", "█"]
30
43
  )
31
- }.freeze
44
+ }.tap { |styles| styles[:ascii] = styles[:normal] }.freeze
32
45
  end
33
46
  end
@@ -2,14 +2,21 @@
2
2
 
3
3
  module Charming
4
4
  module UI
5
+ # BorderPainter draws a Border's glyphs around content lines, optionally
6
+ # coloring the border. *foreground* is a single color or a per-side hash
7
+ # (`{top:, right:, bottom:, left:}`); corners take the top/bottom row's
8
+ # color. *background* colors border cells; when it merely inherits the box
9
+ # background (background_explicit: false) it only paints alongside a
10
+ # foreground, preserving unstyled borders on unstyled boxes.
5
11
  class BorderPainter
6
12
  DEFAULT_SIDES = %i[top right bottom left].freeze
7
13
 
8
- def initialize(border:, sides: nil, foreground: nil, background: nil)
14
+ def initialize(border:, sides: nil, foreground: nil, background: nil, background_explicit: false)
9
15
  @border = border
10
16
  @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
11
17
  @foreground = foreground
12
18
  @background = background
19
+ @background_explicit = background_explicit
13
20
  end
14
21
 
15
22
  def paint(lines, inner_width)
@@ -22,34 +29,39 @@ module Charming
22
29
  private
23
30
 
24
31
  def border_line(line, width)
25
- left = @sides.include?(:left) ? render_border(@border.vertical) : ""
26
- right = @sides.include?(:right) ? render_border(@border.vertical) : ""
32
+ left = @sides.include?(:left) ? render_border(@border.vertical, :left) : ""
33
+ right = @sides.include?(:right) ? render_border(@border.vertical, :right) : ""
27
34
 
28
- "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
35
+ "#{left}#{Width.pad_to(line, width)}#{right}"
29
36
  end
30
37
 
31
38
  def top_border(horizontal)
32
39
  return unless @sides.include?(:top)
33
- return render_border(horizontal) unless full_horizontal?
40
+ return render_border(horizontal, :top) unless full_horizontal?
34
41
 
35
- render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
42
+ render_border("#{@border.top_left}#{horizontal}#{@border.top_right}", :top)
36
43
  end
37
44
 
38
45
  def bottom_border(horizontal)
39
46
  return unless @sides.include?(:bottom)
40
- return render_border(horizontal) unless full_horizontal?
47
+ return render_border(horizontal, :bottom) unless full_horizontal?
41
48
 
42
- render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
49
+ render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}", :bottom)
43
50
  end
44
51
 
45
52
  def full_horizontal?
46
53
  @sides.include?(:left) && @sides.include?(:right)
47
54
  end
48
55
 
49
- def render_border(value)
50
- return value unless @foreground
56
+ def render_border(value, side)
57
+ foreground = side_foreground(side)
58
+ return value unless foreground || @background_explicit
51
59
 
52
- Style.new(foreground: @foreground, background: @background).render(value)
60
+ Style.new(foreground: foreground, background: @background).render(value)
61
+ end
62
+
63
+ def side_foreground(side)
64
+ @foreground.is_a?(Hash) ? @foreground[side] : @foreground
53
65
  end
54
66
  end
55
67
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # BrailleCanvas is a monochrome subpixel drawing surface backed by Unicode braille glyphs
6
+ # (U+2800–U+28FF). Each character cell packs a 2×4 grid of dots, so the canvas addresses
7
+ # `width`×`height` *pixels* while rendering to `cols`×`rows` *cells* — 8× the vertical and 2× the
8
+ # horizontal resolution of plain text. It's pure text (works on every terminal) and composes via
9
+ # `row`/`column`/`Canvas` like any other block. Used by {Charming::Components::Chart}.
10
+ class BrailleCanvas
11
+ # The first braille code point; OR-ing dot bits onto it yields the glyph for a cell.
12
+ BASE = 0x2800
13
+
14
+ # Dot bit for each (x%2, y%4) position within a cell, indexed `DOTS[y % 4][x % 2]`.
15
+ DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]].freeze
16
+
17
+ # *width* and *height* are the drawable area in pixels (dots).
18
+ def initialize(width, height)
19
+ @width = width
20
+ @height = height
21
+ @cols = (width + 1) / 2
22
+ @rows = (height + 3) / 4
23
+ @cells = Array.new(@rows) { Array.new(@cols, 0) }
24
+ end
25
+
26
+ attr_reader :width, :height, :cols, :rows
27
+
28
+ # Turns the dot at pixel (*x*, *y*) on (or off when `on: false`). Out-of-range points are
29
+ # ignored. Returns self for chaining.
30
+ def set(x, y, on: true)
31
+ return self unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
32
+
33
+ bit = DOTS[y % 4][x % 2]
34
+ if on
35
+ @cells[y / 4][x / 2] |= bit
36
+ else
37
+ @cells[y / 4][x / 2] &= ~bit
38
+ end
39
+ self
40
+ end
41
+
42
+ # Turns the dot at pixel (*x*, *y*) off. Returns self.
43
+ def unset(x, y)
44
+ set(x, y, on: false)
45
+ end
46
+
47
+ # Draws a straight line between (*x0*, *y0*) and (*x1*, *y1*) with Bresenham's algorithm.
48
+ # Returns self.
49
+ def line(x0, y0, x1, y1)
50
+ dx = (x1 - x0).abs
51
+ dy = -(y1 - y0).abs
52
+ sx = (x0 < x1) ? 1 : -1
53
+ sy = (y0 < y1) ? 1 : -1
54
+ err = dx + dy
55
+ x = x0
56
+ y = y0
57
+ loop do
58
+ set(x, y)
59
+ break if x == x1 && y == y1
60
+
61
+ e2 = 2 * err
62
+ if e2 >= dy
63
+ err += dy
64
+ x += sx
65
+ end
66
+ if e2 <= dx
67
+ err += dx
68
+ y += sy
69
+ end
70
+ end
71
+ self
72
+ end
73
+
74
+ # Renders the canvas as `rows` lines of `cols` braille glyphs.
75
+ def to_s
76
+ @cells.map { |row| row.map { |bits| (BASE + bits).chr(Encoding::UTF_8) }.join }.join("\n")
77
+ end
78
+ end
79
+ end
80
+ end
@@ -44,6 +44,7 @@ module Charming
44
44
 
45
45
  def self.offset(value, available, size)
46
46
  return [(available - size) / 2, 0].max if value == :center
47
+ return [((available - size) * value).round, 0].max if value.is_a?(Float)
47
48
 
48
49
  value
49
50
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Gradient interpolates between two hex colors: blend a single point, build
6
+ # an evenly spaced ramp, or paint text with a per-character color sweep.
7
+ module Gradient
8
+ module_function
9
+
10
+ # Blends *start_hex* and *end_hex* ("#rrggbb") at fractional *amount*
11
+ # (0.0 → start, 1.0 → end), returning a "#rrggbb" string.
12
+ def blend(start_hex, end_hex, amount)
13
+ mixed = rgb(start_hex).zip(rgb(end_hex)).map do |from, to|
14
+ (from + ((to - from) * amount)).round
15
+ end
16
+ format("#%02x%02x%02x", *mixed)
17
+ end
18
+
19
+ # An evenly spaced ramp of *count* colors from *start_hex* to *end_hex*,
20
+ # endpoints included.
21
+ def steps(start_hex, end_hex, count)
22
+ return [blend(start_hex, end_hex, 0.0)] if count <= 1
23
+
24
+ Array.new(count) { |index| blend(start_hex, end_hex, index.to_f / (count - 1)) }
25
+ end
26
+
27
+ # Paints each grapheme cluster of plain-text *text* with a foreground color
28
+ # swept from *from* to *to* across its visible characters.
29
+ def colorize(text, from:, to:)
30
+ clusters = text.to_s.scan(Width::GRAPHEME)
31
+ span = [clusters.length - 1, 1].max
32
+
33
+ clusters.each_with_index.map do |cluster, index|
34
+ Style.new(foreground: blend(from, to, index.to_f / span)).render(cluster)
35
+ end.join
36
+ end
37
+
38
+ # Decomposes "#rrggbb" into [r, g, b] integers.
39
+ def rgb(hex)
40
+ value = hex.to_s.delete_prefix("#")
41
+ raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
42
+
43
+ [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -18,6 +18,7 @@ module Charming
18
18
  @options = {
19
19
  attributes: [],
20
20
  padding: [0, 0, 0, 0],
21
+ margin: [0, 0, 0, 0],
21
22
  align: :left
22
23
  }.merge(options)
23
24
  end
@@ -49,11 +50,31 @@ module Charming
49
50
  with(padding: expand_box_values(values))
50
51
  end
51
52
 
53
+ # Returns a new Style with the margin set — blank space applied outside the border and
54
+ # untouched by the style's colors. Accepts the same CSS-style shorthand as `padding`.
55
+ def margin(*values)
56
+ with(margin: expand_box_values(values))
57
+ end
58
+
59
+ # Per-side setters: padding_top/right/bottom/left and margin_top/right/bottom/left,
60
+ # each returning a new Style with just that side changed.
61
+ %i[padding margin].each do |box|
62
+ %i[top right bottom left].each_with_index do |side, index|
63
+ define_method(:"#{box}_#{side}") do |value|
64
+ values = @options.fetch(box).dup
65
+ values[index] = value
66
+ with(box => values)
67
+ end
68
+ end
69
+ end
70
+
52
71
  # Returns a new Style with the border set. *style* is a border name (e.g., :normal,
53
- # :rounded). *sides* optionally restricts the border to specific sides. *foreground*
54
- # sets the border color.
55
- def border(style = :normal, sides: nil, foreground: nil)
56
- with(border: style, border_sides: sides, border_foreground: foreground)
72
+ # :rounded, :square, :hidden, :block) or a custom Border instance. *sides* optionally
73
+ # restricts the border to specific sides. *foreground* colors the border — a single
74
+ # color, or a per-side hash like `{top: :red, left: :green}`. *background* colors the
75
+ # border independently of the box background.
76
+ def border(style = :normal, sides: nil, foreground: nil, background: nil)
77
+ with(border: style, border_sides: sides, border_foreground: foreground, border_background: background)
57
78
  end
58
79
 
59
80
  # Returns a new Style that fixes the rendered width to *value* (in display columns).
@@ -66,19 +87,50 @@ module Charming
66
87
  with(height: value)
67
88
  end
68
89
 
90
+ # Returns a new Style that caps the rendered width to *value* display columns.
91
+ # Unlike `width`, content narrower than the cap is not padded out to it.
92
+ def max_width(value)
93
+ with(max_width: value)
94
+ end
95
+
96
+ # Returns a new Style that caps the rendered height to *value* rows. Unlike
97
+ # `height`, content shorter than the cap is not filled with blank rows.
98
+ def max_height(value)
99
+ with(max_height: value)
100
+ end
101
+
102
+ # Returns a new Style that word-wraps overflowing lines at the fixed width
103
+ # instead of clipping them.
104
+ def wrap
105
+ with(fit: :wrap)
106
+ end
107
+
108
+ # Returns a new Style that marks lines clipped at the fixed width with a
109
+ # trailing *ellipsis* instead of cutting them silently.
110
+ def truncate(ellipsis: Truncate::ELLIPSIS)
111
+ with(fit: :truncate, ellipsis: ellipsis)
112
+ end
113
+
69
114
  # Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
70
115
  def align(value)
71
116
  with(align: value)
72
117
  end
73
118
 
119
+ # Returns a new Style with vertical alignment within a fixed height set
120
+ # (`:top`, `:middle`, or `:bottom`).
121
+ def align_vertical(value)
122
+ with(align_vertical: value)
123
+ end
124
+
74
125
  # Applies the configured style to *value* and returns the styled string. Steps:
75
- # 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`, 4. apply padding,
76
- # 5. paint border, 6. emit ANSI attribute/foreground/background escapes.
126
+ # 1. wrap to `:width`, 2. align horizontally and vertically, 3. expand to `:height`,
127
+ # 4. apply padding, 5. paint border, 6. emit ANSI attribute/foreground/background
128
+ # escapes, 7. surround with unstyled margin space.
77
129
  def render(value)
78
130
  lines = apply_dimensions(value.to_s.lines(chomp: true))
79
131
  lines = apply_padding(lines)
80
132
  lines = apply_border(lines)
81
- apply_ansi(lines.join("\n"))
133
+ apply_margin(apply_ansi(lines.join("\n")))
82
134
  end
83
135
 
84
136
  private
@@ -91,40 +143,87 @@ module Charming
91
143
  # Wraps each line to the target width and applies horizontal alignment, then expands
92
144
  # to the target height.
93
145
  def apply_dimensions(lines)
146
+ lines = wrap_lines(lines)
94
147
  content_width = target_content_width(lines)
95
148
  dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
96
- apply_height(dimensioned, content_width)
149
+ apply_max_height(apply_height(dimensioned, content_width))
150
+ end
151
+
152
+ # Word-wraps lines at the fixed width when the style is in wrap mode.
153
+ def wrap_lines(lines)
154
+ wrap_width = @options[:width]
155
+ return lines unless wrap_width && @options[:fit] == :wrap
156
+
157
+ wrapper = TextWrapper.new(width: wrap_width)
158
+ lines.flat_map { |line| wrapper.wrap(line).lines(chomp: true) }
97
159
  end
98
160
 
99
161
  # Returns the target content width: the explicit :width if set, otherwise the natural
100
- # max display width of the lines.
162
+ # max display width of the lines, capped to :max_width when configured.
101
163
  def target_content_width(lines)
102
- explicit_width = @options[:width]
103
- natural_width = lines.map { |line| Width.measure(line) }.max || 0
104
- explicit_width || natural_width
164
+ width = @options[:width] || Width.widest(lines)
165
+ max = @options[:max_width]
166
+ max ? [width, max].min : width
105
167
  end
106
168
 
107
- # Clips *line* to *width* display columns, preserving ANSI styling where possible.
169
+ # Fits *line* into *width* display columns: clipped by default, or marked with an
170
+ # ellipsis in truncate mode. Preserves ANSI styling active at the cut.
108
171
  def fit_line(line, width)
109
172
  return line if Width.measure(line) <= width
173
+ return Truncate.tail(line, width, ellipsis: @options.fetch(:ellipsis, Truncate::ELLIPSIS)) if @options[:fit] == :truncate
110
174
 
111
175
  UI.visible_slice(line, 0, width)
112
176
  end
113
177
 
114
- # Truncates or pads the lines array to *height* rows, filling with blank rows.
178
+ # Truncates or pads the lines array to *height* rows, distributing blank fill
179
+ # rows according to :align_vertical (:top, :middle, or :bottom).
115
180
  def apply_height(lines, width)
116
181
  height = @options[:height]
117
182
  return lines unless height
118
183
 
119
184
  visible = lines.first(height)
120
- visible + Array.new([height - visible.length, 0].max) { " " * width }
185
+ distribute_rows(visible, height - visible.length, width)
186
+ end
187
+
188
+ # Places *missing* blank rows around *visible* per the vertical alignment.
189
+ def distribute_rows(visible, missing, width)
190
+ blank = ->(count) { Array.new([count, 0].max) { " " * width } }
191
+ case @options.fetch(:align_vertical, :top)
192
+ when :bottom
193
+ blank.call(missing) + visible
194
+ when :middle
195
+ top = missing / 2
196
+ blank.call(top) + visible + blank.call(missing - top)
197
+ else
198
+ visible + blank.call(missing)
199
+ end
200
+ end
201
+
202
+ # Surrounds the styled block with unstyled margin space: blank rows above and
203
+ # below, plain-space columns left and right. Applied after ANSI styling so the
204
+ # margin never carries the style's colors.
205
+ def apply_margin(styled)
206
+ top, right, bottom, left = @options.fetch(:margin)
207
+ return styled if [top, right, bottom, left].all?(&:zero?)
208
+
209
+ lines = styled.lines(chomp: true)
210
+ width = Width.widest(lines)
211
+ body = lines.map { |line| (" " * left) + Width.pad_to(line, width) + (" " * right) }
212
+ blank = " " * (left + width + right)
213
+ (Array.new(top, blank) + body + Array.new(bottom, blank)).join("\n")
214
+ end
215
+
216
+ # Caps the lines array to :max_height rows without filling missing rows.
217
+ def apply_max_height(lines)
218
+ max = @options[:max_height]
219
+ max ? lines.first(max) : lines
121
220
  end
122
221
 
123
222
  # Applies padding by prepending/appending blank rows (vertical) and indenting each
124
223
  # line (horizontal).
125
224
  def apply_padding(lines)
126
225
  top, right, bottom, left = @options.fetch(:padding)
127
- inner_width = lines.map { |line| Width.measure(line) }.max || 0
226
+ inner_width = Width.widest(lines)
128
227
  empty = " " * (left + inner_width + right)
129
228
  padded = lines.map do |line|
130
229
  pad_line(line, inner_width, left, right)
@@ -143,7 +242,7 @@ module Charming
143
242
 
144
243
  # Pads a single line to *inner_width*, with *left* and *right* padding spaces.
145
244
  def pad_line(line, inner_width, left, right)
146
- (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
245
+ (" " * left) + Width.pad_to(line, inner_width) + (" " * right)
147
246
  end
148
247
 
149
248
  # Builds a BorderPainter configured for the current border options.
@@ -152,13 +251,14 @@ module Charming
152
251
  border: Border.fetch(border_name),
153
252
  sides: @options[:border_sides],
154
253
  foreground: @options[:border_foreground],
155
- background: @options[:background]
254
+ background: @options[:border_background] || @options[:background],
255
+ background_explicit: !@options[:border_background].nil?
156
256
  )
157
257
  end
158
258
 
159
259
  # Returns the natural display width of the longest line in *lines*.
160
260
  def content_width(lines)
161
- lines.map { |line| Width.measure(line) }.max || 0
261
+ Width.widest(lines)
162
262
  end
163
263
 
164
264
  # Applies the active ANSI attribute/foreground/background codes to *value*.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Markdown
5
- # TextWrapper wraps Markdown text blocks to a configured terminal width.
4
+ module UI
5
+ # TextWrapper greedily word-wraps text blocks to a display width.
6
6
  class TextWrapper
7
7
  def initialize(width:)
8
8
  @width = width
@@ -19,7 +19,7 @@ module Charming
19
19
  attr_reader :width
20
20
 
21
21
  def wrap_line(line)
22
- return line if UI::Width.measure(line) <= width
22
+ return line if Width.measure(line) <= width
23
23
 
24
24
  wrap_words(line.split(/\s+/))
25
25
  end
@@ -31,7 +31,7 @@ module Charming
31
31
  def append_word(lines, word)
32
32
  current = lines.pop.to_s
33
33
  candidate = current.empty? ? word : "#{current} #{word}"
34
- return lines.push(candidate) if current.empty? || UI::Width.measure(candidate) <= width
34
+ return lines.push(candidate) if current.empty? || Width.measure(candidate) <= width
35
35
 
36
36
  lines.push(current.rstrip, word)
37
37
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Truncate cuts ANSI-styled text down to a display width, marking the cut
6
+ # with a trailing ellipsis. Styling active at the cut is preserved and
7
+ # terminated, and multi-column glyphs are never split (matching ANSISlicer).
8
+ module Truncate
9
+ ELLIPSIS = "…"
10
+
11
+ module_function
12
+
13
+ # Truncates each line of *text* to *width* display columns, appending
14
+ # *ellipsis* to lines that overflow. Lines that fit are returned unchanged.
15
+ def tail(text, width, ellipsis: ELLIPSIS)
16
+ text.to_s.lines(chomp: true).map { |line| tail_line(line, width, ellipsis) }.join("\n")
17
+ end
18
+
19
+ def tail_line(line, width, ellipsis)
20
+ return line if Width.measure(line) <= width
21
+
22
+ ellipsis_width = Width.measure(ellipsis)
23
+ return ANSISlicer.slice(line, 0, width) if ellipsis_width >= width
24
+
25
+ ANSISlicer.slice(line, 0, width - ellipsis_width) + ellipsis
26
+ end
27
+ end
28
+ end
29
+ end
@@ -44,6 +44,17 @@ module Charming
44
44
  def strip_ansi(value)
45
45
  value.to_s.gsub(ANSI_PATTERN, "")
46
46
  end
47
+
48
+ # Pads *line* with trailing spaces to *width* display columns. Lines already at
49
+ # or beyond the target are returned unchanged.
50
+ def pad_to(line, width)
51
+ line + (" " * [width - measure(line), 0].max)
52
+ end
53
+
54
+ # The maximum display width across *lines*; 0 when there are none.
55
+ def widest(lines)
56
+ lines.map { |line| measure(line) }.max || 0
57
+ end
47
58
  end
48
59
  end
49
60
  end