charming 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
|
+
# Progressbar renders a fixed-width ASCII progress bar. The bar is sized to the configured
|
|
7
|
+
# *total* (in arbitrary units) and fills proportionally to the current value. Optionally
|
|
8
|
+
# appends a label after the bar.
|
|
6
9
|
class Progressbar < Component
|
|
10
|
+
# Public accessors: total units, current value, label text, completed and remaining
|
|
11
|
+
# characters, and the bar format symbol.
|
|
7
12
|
attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
|
|
8
13
|
|
|
14
|
+
# *total* is the maximum unit count. *complete* and *incomplete* are the characters used
|
|
15
|
+
# for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
|
|
16
|
+
# future format variants. *label* is an optional suffix shown after the bar.
|
|
9
17
|
def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
|
|
10
18
|
super()
|
|
11
19
|
@total = [total.to_i, 0].max
|
|
@@ -16,21 +24,25 @@ module Charming
|
|
|
16
24
|
@current = 0
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# Advances the current value by *count* (default 1), clamping to `[0, total]`. Returns self.
|
|
19
28
|
def tick(count = 1)
|
|
20
29
|
update(@current + count)
|
|
21
30
|
self
|
|
22
31
|
end
|
|
23
32
|
|
|
33
|
+
# Sets the current value, clamping to `[0, total]`. Returns self.
|
|
24
34
|
def update(value)
|
|
25
35
|
@current = value.to_i.clamp(0, @total)
|
|
26
36
|
self
|
|
27
37
|
end
|
|
28
38
|
|
|
39
|
+
# Jumps the bar directly to 100% completion. Returns self.
|
|
29
40
|
def complete!
|
|
30
41
|
@current = @total
|
|
31
42
|
self
|
|
32
43
|
end
|
|
33
44
|
|
|
45
|
+
# Renders the bar as `[==== ]` (with the *label* appended when present).
|
|
34
46
|
def render
|
|
35
47
|
width = [@total, 1].max
|
|
36
48
|
completed = completed_width(width)
|
|
@@ -46,6 +58,7 @@ module Charming
|
|
|
46
58
|
|
|
47
59
|
private
|
|
48
60
|
|
|
61
|
+
# Returns the number of `complete` characters to draw, rounded to the nearest integer.
|
|
49
62
|
def completed_width(width)
|
|
50
63
|
return 0 unless @total.positive?
|
|
51
64
|
|
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
|
+
# Spinner is a simple rotating-frame indicator. The component cycles through a list of
|
|
7
|
+
# frames on each `tick`; pair it with a controller timer to drive animation. An optional
|
|
8
|
+
# *label* is appended after the current frame on each render.
|
|
6
9
|
class Spinner < Component
|
|
10
|
+
# The default frame set: a 4-frame ASCII spinner.
|
|
7
11
|
DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
|
|
8
12
|
|
|
13
|
+
# The current frame list, frame index, and optional label string.
|
|
9
14
|
attr_reader :frames, :index, :label
|
|
10
15
|
|
|
16
|
+
# *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
|
|
17
|
+
# *index* is the starting frame index. *label* is an optional suffix shown after the frame.
|
|
11
18
|
def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
|
|
12
19
|
super()
|
|
13
20
|
raise ArgumentError, "frames cannot be empty" if frames.empty?
|
|
@@ -17,11 +24,13 @@ module Charming
|
|
|
17
24
|
@label = label
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
# Advances the frame index by one position, wrapping around. Returns self for chaining.
|
|
20
28
|
def tick
|
|
21
29
|
@index = (index + 1) % frames.length
|
|
22
30
|
self
|
|
23
31
|
end
|
|
24
32
|
|
|
33
|
+
# Renders the current frame, optionally followed by the label and a space.
|
|
25
34
|
def render
|
|
26
35
|
return frame unless label
|
|
27
36
|
|
|
@@ -30,6 +39,7 @@ module Charming
|
|
|
30
39
|
|
|
31
40
|
private
|
|
32
41
|
|
|
42
|
+
# Returns the current frame string (with index modulo frame count to be safe).
|
|
33
43
|
def frame
|
|
34
44
|
frames.fetch(index % frames.length)
|
|
35
45
|
end
|
|
@@ -5,9 +5,14 @@ require "tty-table"
|
|
|
5
5
|
module Charming
|
|
6
6
|
module Presentation
|
|
7
7
|
module Components
|
|
8
|
+
# Table renders tabular data with a header row, a selected row highlight, and keyboard
|
|
9
|
+
# navigation. Mouse clicks within the body area also select rows. The table is rendered
|
|
10
|
+
# via tty-table and the selected row is overlaid with reverse-video ANSI styling.
|
|
8
11
|
class Table < Component
|
|
9
12
|
include KeyboardHandler
|
|
10
13
|
|
|
14
|
+
# Maps navigation keys to the instance methods that move the selection. Shared with
|
|
15
|
+
# List and Viewport via KeyboardHandler.
|
|
11
16
|
KEY_ACTIONS = {
|
|
12
17
|
up: :move_up,
|
|
13
18
|
down: :move_down,
|
|
@@ -15,10 +20,16 @@ module Charming
|
|
|
15
20
|
end: :move_end
|
|
16
21
|
}.freeze
|
|
17
22
|
|
|
23
|
+
# Number of terminal rows occupied by the table's top border and header line. Used by
|
|
24
|
+
# the mouse handler to translate absolute row coordinates to body rows.
|
|
18
25
|
HEADER_HEIGHT = 2
|
|
19
26
|
|
|
27
|
+
# The header row, the body rows, and the currently selected row index, respectively.
|
|
20
28
|
attr_reader :header, :rows, :selected_index
|
|
21
29
|
|
|
30
|
+
# *header* is an array of column labels. *rows* is the array of body rows (each either a
|
|
31
|
+
# String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
|
|
32
|
+
# *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
|
|
22
33
|
def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
|
|
23
34
|
super()
|
|
24
35
|
@header = Array(header).map(&:to_s)
|
|
@@ -27,6 +38,8 @@ module Charming
|
|
|
27
38
|
@keymap = keymap
|
|
28
39
|
end
|
|
29
40
|
|
|
41
|
+
# Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
|
|
42
|
+
# KeyboardHandler for navigation keys.
|
|
30
43
|
def handle_key(event)
|
|
31
44
|
return nil if rows.empty?
|
|
32
45
|
|
|
@@ -36,6 +49,8 @@ module Charming
|
|
|
36
49
|
end
|
|
37
50
|
end
|
|
38
51
|
|
|
52
|
+
# Handles mouse events: a click within the body area selects the clicked row.
|
|
53
|
+
# Returns :handled on a successful click.
|
|
39
54
|
def handle_mouse(event)
|
|
40
55
|
return nil if rows.empty?
|
|
41
56
|
return nil unless event.respond_to?(:click?) && event.click?
|
|
@@ -47,10 +62,12 @@ module Charming
|
|
|
47
62
|
:handled
|
|
48
63
|
end
|
|
49
64
|
|
|
65
|
+
# Returns the currently selected row, or nil when the table is empty.
|
|
50
66
|
def selected_row
|
|
51
67
|
rows[selected_index]
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
# Renders the table to a string. Returns a placeholder when both header and rows are empty.
|
|
54
71
|
def render
|
|
55
72
|
return "(empty table)" if header.empty? && rows.empty?
|
|
56
73
|
|
|
@@ -64,6 +81,8 @@ module Charming
|
|
|
64
81
|
|
|
65
82
|
private
|
|
66
83
|
|
|
84
|
+
# Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
|
|
85
|
+
# Excess cells are merged into the last column with a space separator.
|
|
67
86
|
def normalize_row(row)
|
|
68
87
|
cells = case row
|
|
69
88
|
when Hash then row.values
|
|
@@ -77,6 +96,7 @@ module Charming
|
|
|
77
96
|
kept + [merged]
|
|
78
97
|
end
|
|
79
98
|
|
|
99
|
+
# Applies the selected-row highlight and trims unused body rows below the actual row count.
|
|
80
100
|
def compact_layout(lines)
|
|
81
101
|
return lines.join("\n") if lines.length < 4
|
|
82
102
|
|
|
@@ -91,22 +111,27 @@ module Charming
|
|
|
91
111
|
[top, header_line, *highlighted, bottom].compact.join("\n")
|
|
92
112
|
end
|
|
93
113
|
|
|
114
|
+
# Moves the selection up one row.
|
|
94
115
|
def move_up
|
|
95
116
|
@selected_index -= 1 if selected_index.positive?
|
|
96
117
|
end
|
|
97
118
|
|
|
119
|
+
# Moves the selection down one row.
|
|
98
120
|
def move_down
|
|
99
121
|
@selected_index += 1 if selected_index < rows.length - 1
|
|
100
122
|
end
|
|
101
123
|
|
|
124
|
+
# Moves the selection to the first row.
|
|
102
125
|
def move_home
|
|
103
126
|
@selected_index = 0
|
|
104
127
|
end
|
|
105
128
|
|
|
129
|
+
# Moves the selection to the last row.
|
|
106
130
|
def move_end
|
|
107
131
|
@selected_index = rows.length - 1
|
|
108
132
|
end
|
|
109
133
|
|
|
134
|
+
# Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
|
|
110
135
|
def clamp_index(value)
|
|
111
136
|
return 0 if rows.empty?
|
|
112
137
|
|
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
|
+
# TextArea is a multi-line text editor component. Supports character insertion (with
|
|
7
|
+
# newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
|
|
8
|
+
# home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
|
|
9
|
+
# Vertical movement preserves a "preferred column" so left/right navigation feels stable.
|
|
6
10
|
class TextArea < Component
|
|
11
|
+
# The current text value, cursor byte offset, top-visible row offset, and remembered
|
|
12
|
+
# column for vertical navigation, respectively.
|
|
7
13
|
attr_reader :value, :cursor, :offset, :preferred_column
|
|
8
14
|
|
|
15
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty. *width* and
|
|
16
|
+
# *height* constrain the rendered output. *cursor* defaults to the end of the value.
|
|
17
|
+
# *offset* is the top-visible row. *preferred_column* is the column to resume at on
|
|
18
|
+
# vertical movement (defaults to the current column on first use).
|
|
9
19
|
def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
|
|
10
20
|
super()
|
|
11
21
|
@value = value.dup
|
|
@@ -19,6 +29,8 @@ module Charming
|
|
|
19
29
|
ensure_cursor_visible
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
# Routes key events to the appropriate cursor/text mutation. Returns :handled when the
|
|
33
|
+
# event was consumed, nil otherwise.
|
|
22
34
|
def handle_key(event)
|
|
23
35
|
key = Charming.key_of(event)
|
|
24
36
|
return :handled if newline_event?(event) && insert("\n")
|
|
@@ -41,6 +53,8 @@ module Charming
|
|
|
41
53
|
:handled
|
|
42
54
|
end
|
|
43
55
|
|
|
56
|
+
# Renders the visible portion of the text buffer (scrolled to `offset`), with each
|
|
57
|
+
# visible line either clipped to `width` or padded to it.
|
|
44
58
|
def render
|
|
45
59
|
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
46
60
|
end
|
|
@@ -49,6 +63,7 @@ module Charming
|
|
|
49
63
|
|
|
50
64
|
attr_reader :placeholder, :width, :height
|
|
51
65
|
|
|
66
|
+
# True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
|
|
52
67
|
def newline_event?(event)
|
|
53
68
|
key = Charming.key_of(event)
|
|
54
69
|
return true if key == :enter && event.respond_to?(:shift) && event.shift
|
|
@@ -57,14 +72,18 @@ module Charming
|
|
|
57
72
|
false
|
|
58
73
|
end
|
|
59
74
|
|
|
75
|
+
# True when *event* carries a single printable character.
|
|
60
76
|
def character_event?(event)
|
|
61
77
|
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
62
78
|
end
|
|
63
79
|
|
|
80
|
+
# True when *char* is not a control character.
|
|
64
81
|
def printable?(char)
|
|
65
82
|
!char.match?(/[[:cntrl:]]/)
|
|
66
83
|
end
|
|
67
84
|
|
|
85
|
+
# Inserts *text* at the cursor, advances the cursor by its length, resets the preferred
|
|
86
|
+
# column, and ensures the cursor remains visible.
|
|
68
87
|
def insert(text)
|
|
69
88
|
@value = value[0...cursor].to_s + text + value[cursor..].to_s
|
|
70
89
|
@cursor += text.length
|
|
@@ -72,26 +91,31 @@ module Charming
|
|
|
72
91
|
ensure_cursor_visible
|
|
73
92
|
end
|
|
74
93
|
|
|
94
|
+
# Moves the cursor one character left.
|
|
75
95
|
def move_left
|
|
76
96
|
@cursor -= 1 if cursor.positive?
|
|
77
97
|
reset_preferred_column
|
|
78
98
|
ensure_cursor_visible
|
|
79
99
|
end
|
|
80
100
|
|
|
101
|
+
# Moves the cursor one character right.
|
|
81
102
|
def move_right
|
|
82
103
|
@cursor += 1 if cursor < value.length
|
|
83
104
|
reset_preferred_column
|
|
84
105
|
ensure_cursor_visible
|
|
85
106
|
end
|
|
86
107
|
|
|
108
|
+
# Moves the cursor up one line while preserving the preferred column.
|
|
87
109
|
def move_up
|
|
88
110
|
move_vertical(-1)
|
|
89
111
|
end
|
|
90
112
|
|
|
113
|
+
# Moves the cursor down one line while preserving the preferred column.
|
|
91
114
|
def move_down
|
|
92
115
|
move_vertical(+1)
|
|
93
116
|
end
|
|
94
117
|
|
|
118
|
+
# Moves the cursor to the start of the current line.
|
|
95
119
|
def move_home
|
|
96
120
|
row, = cursor_position
|
|
97
121
|
@cursor = line_start(row)
|
|
@@ -99,6 +123,7 @@ module Charming
|
|
|
99
123
|
ensure_cursor_visible
|
|
100
124
|
end
|
|
101
125
|
|
|
126
|
+
# Moves the cursor to the end of the current line.
|
|
102
127
|
def move_end
|
|
103
128
|
row, = cursor_position
|
|
104
129
|
@cursor = line_start(row) + line_length(row)
|
|
@@ -106,6 +131,7 @@ module Charming
|
|
|
106
131
|
ensure_cursor_visible
|
|
107
132
|
end
|
|
108
133
|
|
|
134
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
109
135
|
def delete_before_cursor
|
|
110
136
|
return if cursor.zero?
|
|
111
137
|
|
|
@@ -115,6 +141,7 @@ module Charming
|
|
|
115
141
|
ensure_cursor_visible
|
|
116
142
|
end
|
|
117
143
|
|
|
144
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
118
145
|
def delete_at_cursor
|
|
119
146
|
return if cursor >= value.length
|
|
120
147
|
|
|
@@ -123,16 +150,20 @@ module Charming
|
|
|
123
150
|
ensure_cursor_visible
|
|
124
151
|
end
|
|
125
152
|
|
|
153
|
+
# Scrolls the buffer up by one viewport height.
|
|
126
154
|
def page_up
|
|
127
155
|
@offset -= viewport_height
|
|
128
156
|
clamp_offset
|
|
129
157
|
end
|
|
130
158
|
|
|
159
|
+
# Scrolls the buffer down by one viewport height.
|
|
131
160
|
def page_down
|
|
132
161
|
@offset += viewport_height
|
|
133
162
|
clamp_offset
|
|
134
163
|
end
|
|
135
164
|
|
|
165
|
+
# Moves the cursor vertically by *delta* rows. Stays within the line count and uses
|
|
166
|
+
# `preferred_column` so up/down movement feels stable on short lines.
|
|
136
167
|
def move_vertical(delta)
|
|
137
168
|
row, column = cursor_position
|
|
138
169
|
target_row = (row + delta).clamp(0, lines.length - 1)
|
|
@@ -141,10 +172,13 @@ module Charming
|
|
|
141
172
|
ensure_cursor_visible
|
|
142
173
|
end
|
|
143
174
|
|
|
175
|
+
# Sets the preferred column to the current column (called when horizontal movement happens).
|
|
144
176
|
def reset_preferred_column
|
|
145
177
|
@preferred_column = cursor_position.last
|
|
146
178
|
end
|
|
147
179
|
|
|
180
|
+
# Returns the cursor's current position as `[row, column]`, where row is the zero-based
|
|
181
|
+
# line index and column is the character offset within that line.
|
|
148
182
|
def cursor_position
|
|
149
183
|
before = value[0...cursor].to_s
|
|
150
184
|
row = before.count("\n")
|
|
@@ -153,24 +187,30 @@ module Charming
|
|
|
153
187
|
[row, column]
|
|
154
188
|
end
|
|
155
189
|
|
|
190
|
+
# Returns the byte offset where line *row* begins in the value.
|
|
156
191
|
def line_start(row)
|
|
157
192
|
lines.first(row).sum(&:length) + row
|
|
158
193
|
end
|
|
159
194
|
|
|
195
|
+
# Returns the character length of the line at *row* (empty string when row is past the end).
|
|
160
196
|
def line_length(row)
|
|
161
197
|
lines.fetch(row, "").length
|
|
162
198
|
end
|
|
163
199
|
|
|
200
|
+
# Splits the value into an array of lines (preserving trailing empty lines).
|
|
164
201
|
def lines
|
|
165
202
|
value.empty? ? [""] : value.split("\n", -1)
|
|
166
203
|
end
|
|
167
204
|
|
|
205
|
+
# Returns the rendered lines (with cursor marker inserted) before viewport slicing.
|
|
168
206
|
def rendered_lines
|
|
169
207
|
return [cursor_marker + placeholder] if value.empty?
|
|
170
208
|
|
|
171
209
|
(value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
|
|
172
210
|
end
|
|
173
211
|
|
|
212
|
+
# Returns the lines that should be visible in the current viewport, padded to *height*
|
|
213
|
+
# with empty strings when the buffer is shorter.
|
|
174
214
|
def visible_lines
|
|
175
215
|
ensure_cursor_visible
|
|
176
216
|
rendered = rendered_lines.slice(offset, viewport_height) || []
|
|
@@ -179,6 +219,7 @@ module Charming
|
|
|
179
219
|
rendered + Array.new([height - rendered.length, 0].max, "")
|
|
180
220
|
end
|
|
181
221
|
|
|
222
|
+
# Renders a single line, clipping to *width* and padding with spaces.
|
|
182
223
|
def render_line(line)
|
|
183
224
|
return line unless width
|
|
184
225
|
|
|
@@ -186,6 +227,8 @@ module Charming
|
|
|
186
227
|
clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
|
|
187
228
|
end
|
|
188
229
|
|
|
230
|
+
# Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
|
|
231
|
+
# one row at a time when needed.
|
|
189
232
|
def ensure_cursor_visible
|
|
190
233
|
row, = cursor_position
|
|
191
234
|
@offset = row if row < offset
|
|
@@ -193,23 +236,28 @@ module Charming
|
|
|
193
236
|
clamp_offset
|
|
194
237
|
end
|
|
195
238
|
|
|
239
|
+
# Clamps the cursor and offset to valid bounds.
|
|
196
240
|
def clamp_position
|
|
197
241
|
@cursor = cursor.clamp(0, value.length)
|
|
198
242
|
clamp_offset
|
|
199
243
|
end
|
|
200
244
|
|
|
245
|
+
# Clamps the offset to the valid range `[0, max_offset]`.
|
|
201
246
|
def clamp_offset
|
|
202
247
|
@offset = offset.clamp(0, max_offset)
|
|
203
248
|
end
|
|
204
249
|
|
|
250
|
+
# Returns the maximum allowed offset (so the bottom of the buffer is reachable).
|
|
205
251
|
def max_offset
|
|
206
252
|
[lines.length - viewport_height, 0].max
|
|
207
253
|
end
|
|
208
254
|
|
|
255
|
+
# Returns the visible row count (the configured *height* or the buffer's line count).
|
|
209
256
|
def viewport_height
|
|
210
257
|
height || lines.length
|
|
211
258
|
end
|
|
212
259
|
|
|
260
|
+
# The literal character used to mark the cursor position in `rendered_lines`.
|
|
213
261
|
def cursor_marker
|
|
214
262
|
"|"
|
|
215
263
|
end
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
|
+
# TextInput is a single-line text editor component. Supports printable character insertion,
|
|
7
|
+
# cursor movement (left/right/home/end), and deletion (backspace/delete). The component
|
|
8
|
+
# exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
|
|
9
|
+
# is given, the rendered output is padded to that width via a UI::Style.
|
|
6
10
|
class TextInput < Component
|
|
7
11
|
include KeyboardHandler
|
|
8
12
|
|
|
@@ -18,8 +22,11 @@ module Charming
|
|
|
18
22
|
delete: :delete_at_cursor
|
|
19
23
|
}.freeze
|
|
20
24
|
|
|
25
|
+
# The current input string and the byte offset of the cursor within it.
|
|
21
26
|
attr_reader :value, :cursor
|
|
22
27
|
|
|
28
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
29
|
+
# *width* optionally constrains the rendered output width; *cursor* defaults to the end.
|
|
23
30
|
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
24
31
|
super()
|
|
25
32
|
@value = value.dup
|
|
@@ -29,12 +36,16 @@ module Charming
|
|
|
29
36
|
clamp_position
|
|
30
37
|
end
|
|
31
38
|
|
|
39
|
+
# Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
|
|
40
|
+
# Returns :handled when the event was consumed, nil otherwise.
|
|
32
41
|
def handle_key(event)
|
|
33
42
|
return :handled if character_event?(event) && insert(event.char)
|
|
34
43
|
|
|
35
44
|
super
|
|
36
45
|
end
|
|
37
46
|
|
|
47
|
+
# Renders the value with a cursor marker. When *width* was given at construction, the
|
|
48
|
+
# output is padded to that width via the configured style.
|
|
38
49
|
def render
|
|
39
50
|
rendered = render_value
|
|
40
51
|
@width ? style.width(@width).render(rendered) : rendered
|
|
@@ -44,35 +55,43 @@ module Charming
|
|
|
44
55
|
|
|
45
56
|
attr_reader :placeholder
|
|
46
57
|
|
|
58
|
+
# True when *event* carries a single printable character that should be inserted.
|
|
47
59
|
def character_event?(event)
|
|
48
60
|
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
49
61
|
end
|
|
50
62
|
|
|
63
|
+
# True when *char* is not a control character (and therefore safe to insert).
|
|
51
64
|
def printable?(char)
|
|
52
65
|
!char.match?(/[[:cntrl:]]/)
|
|
53
66
|
end
|
|
54
67
|
|
|
68
|
+
# Inserts *char* at the cursor and advances the cursor by its byte length.
|
|
55
69
|
def insert(char)
|
|
56
70
|
@value = value[0...cursor] + char + value[cursor..]
|
|
57
71
|
@cursor += char.length
|
|
58
72
|
end
|
|
59
73
|
|
|
74
|
+
# Moves the cursor one position left, when possible.
|
|
60
75
|
def move_left
|
|
61
76
|
@cursor -= 1 if cursor.positive?
|
|
62
77
|
end
|
|
63
78
|
|
|
79
|
+
# Moves the cursor one position right, when possible.
|
|
64
80
|
def move_right
|
|
65
81
|
@cursor += 1 if cursor < value.length
|
|
66
82
|
end
|
|
67
83
|
|
|
84
|
+
# Moves the cursor to the start of the value.
|
|
68
85
|
def move_home
|
|
69
86
|
@cursor = 0
|
|
70
87
|
end
|
|
71
88
|
|
|
89
|
+
# Moves the cursor to the end of the value.
|
|
72
90
|
def move_end
|
|
73
91
|
@cursor = value.length
|
|
74
92
|
end
|
|
75
93
|
|
|
94
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
76
95
|
def delete_before_cursor
|
|
77
96
|
return if cursor.zero?
|
|
78
97
|
|
|
@@ -80,22 +99,27 @@ module Charming
|
|
|
80
99
|
@cursor -= 1
|
|
81
100
|
end
|
|
82
101
|
|
|
102
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
83
103
|
def delete_at_cursor
|
|
84
104
|
return if cursor >= value.length
|
|
85
105
|
|
|
86
106
|
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
87
107
|
end
|
|
88
108
|
|
|
109
|
+
# Renders the value with a "|" cursor marker at the current position. When the value is
|
|
110
|
+
# empty, the placeholder is rendered instead, preceded by the cursor marker.
|
|
89
111
|
def render_value
|
|
90
112
|
return cursor_marker + placeholder if value.empty?
|
|
91
113
|
|
|
92
114
|
value[0...cursor] + cursor_marker + value[cursor..]
|
|
93
115
|
end
|
|
94
116
|
|
|
117
|
+
# The literal character used to mark the cursor position in `render`.
|
|
95
118
|
def cursor_marker
|
|
96
119
|
"|"
|
|
97
120
|
end
|
|
98
121
|
|
|
122
|
+
# Clamps the cursor to the valid range `[0, value.length]`.
|
|
99
123
|
def clamp_position
|
|
100
124
|
@cursor = cursor.clamp(0, value.length)
|
|
101
125
|
end
|