Skip to content

Commit 3aeb7e2

Browse files
authored
Merge pull request #4995 from wiktor-obrebski/widgets/text-area
Widgets/text area
2 parents 9e04b22 + c005846 commit 3aeb7e2

File tree

7 files changed

+4479
-0
lines changed

7 files changed

+4479
-0
lines changed

docs/dev/Lua API.rst

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5513,6 +5513,155 @@ The ``EditField`` class also provides the following functions:
55135513

55145514
Inserts the given text at the current cursor position.
55155515

5516+
TextArea class
5517+
--------------
5518+
5519+
Subclass of Panel; implements a multi-line text field with features such as
5520+
text wrapping, mouse control, text selection, clipboard support, history,
5521+
and typical text editor shortcuts.
5522+
5523+
Cursor Behavior
5524+
~~~~~~~~~~~~~~~
5525+
5526+
The cursor in the ``TextArea`` class is index-based, starting from 1,
5527+
consistent with Lua's text indexing conventions.
5528+
5529+
Each character, including newlines (``string.char(10)``),
5530+
occupies a single index in the text content.
5531+
5532+
Cursor movement and position are fully aware of line breaks,
5533+
meaning they count as one unit in the offset.
5534+
5535+
The cursor always points to the position between characters,
5536+
with 1 being the position before the first character and
5537+
``#text + 1`` representing the position after the last character.
5538+
5539+
Cursor positions are preserved during text operations like insertion,
5540+
deletion, or replacement. If changes affect the cursor's position,
5541+
it will be adjusted to the nearest valid index.
5542+
5543+
TextArea Attributes:
5544+
5545+
* ``init_text``: The initial text content for the text area.
5546+
5547+
* ``init_cursor``: The initial cursor position within the text content.
5548+
If not specified, defaults to end of the text (length of ``init_text`` + 1).
5549+
5550+
* ``text_pen``: Optional pen used to draw the text. Default is ``COLOR_LIGHTCYAN``.
5551+
5552+
* ``select_pen``: Optional pen used for text selection. Default is ``COLOR_CYAN``.
5553+
5554+
* ``ignore_keys``: List of input keys to ignore.
5555+
Functions similarly to the ``ignore_keys`` attribute in the ``EditField`` class.
5556+
5557+
* ``on_text_change``: Callback function called whenever the text changes.
5558+
The function signature should be ``on_text_change(new_text, old_text)``.
5559+
5560+
* ``on_cursor_change``: Callback function called whenever the cursor position changes.
5561+
Expected function signature is ``on_cursor_change(new_cursor, old_cursor)``.
5562+
5563+
* ``one_line_mode``: If set to ``true``, disables multi-line text features.
5564+
In this mode the :kbd:`Enter` key is not handled by the widget
5565+
as if it were included in ``ignore_keys``.
5566+
If multiline text (including ``\n`` chars) is pasted into the widget, newlines are removed.
5567+
5568+
TextArea Functions:
5569+
5570+
* ``textarea:getText()``
5571+
5572+
Returns the current text content of the ``TextArea`` widget as a string.
5573+
"\n" characters (``string.char(10)``) should be interpreted as new lines
5574+
5575+
* ``textarea:setText(text)``
5576+
5577+
Sets the content of the ``TextArea`` to the specified string ``text``.
5578+
The cursor position will not be adjusted, so should be set separately.
5579+
5580+
* ``textarea:getCursor()``
5581+
5582+
Returns the current cursor position within the text content.
5583+
The position is represented as a single integer, starting from 1.
5584+
5585+
* ``textarea:setCursor(cursor)``
5586+
5587+
Sets the cursor position within the text content.
5588+
5589+
* ``textarea:scrollToCursor()``
5590+
5591+
Scrolls the text area view to ensure that the current cursor position is visible.
5592+
This happens automatically when the user interactively moves the cursor or
5593+
pastes text into the widget, but may need to be called when ``setCursor`` is
5594+
called programmatically.
5595+
5596+
* ``textarea:clearHistory()``
5597+
5598+
Clears undo/redo history of the widget.
5599+
5600+
Functionality
5601+
~~~~~~~~~~~~~
5602+
5603+
The TextArea widget provides a familiar and intuitive text editing experience with baseline features such as:
5604+
5605+
- Text Wrapping: Automatically fits text within the display area.
5606+
- Mouse and Keyboard Support: Standard keys like :kbd:`Home`, :kbd:`End`, :kbd:`Backspace`, and :kbd:`Delete` are supported,
5607+
along with gestures like double-click to select a word or triple-click to select a line.
5608+
- Clipboard Operations: copy, cut, and paste,
5609+
with intuitive defaults when no text is selected.
5610+
- Undo/Redo: :kbd:`Ctrl` + :kbd:`Z` and :kbd:`Ctrl` + :kbd:`Y` for quick changes.
5611+
- Additional features include advanced navigation, line management,
5612+
and smooth scrolling for handling long text efficiently.
5613+
5614+
Detailed list:
5615+
5616+
- Cursor Control: Navigate through text using arrow keys (Left, Right, Up,
5617+
and Down) for precise cursor placement.
5618+
- Mouse Control: Use the mouse to position the cursor within the text,
5619+
providing an alternative to keyboard navigation.
5620+
- Text Selection: Select text with the mouse, with support for replacing or
5621+
removing selected text.
5622+
- Select Word/Line: Use double click to select current word, or triple click to
5623+
select current line.
5624+
- Move By Word: Use :kbd:`Ctrl` + :kbd:`Left` and :kbd:`Ctrl` + :kbd:`Right` to
5625+
move the cursor one word back or forward.
5626+
- Line Navigation: :kbd:`Home` moves the cursor to the beginning of the current
5627+
line, and :kbd:`End` moves it to the end.
5628+
- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the
5629+
text using :kbd:`Ctrl` + :kbd:`Home` and :kbd:`Ctrl` + :kbd:`End`.
5630+
- Longest X Position Memory: The cursor remembers the longest x position when
5631+
moving up or down, making vertical navigation more intuitive.
5632+
- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting
5633+
multiline text input.
5634+
- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit
5635+
within the display without manual adjustments.
5636+
- Scrolling for long text entries.
5637+
- Backspace Support: Use the backspace key to delete characters to the left of
5638+
the cursor.
5639+
- Delete Character: :kbd:`Delete` deletes the character under the cursor.
5640+
- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line
5641+
where the cursor is located.
5642+
- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to
5643+
the end of the line.
5644+
- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before
5645+
the cursor.
5646+
- Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A`.
5647+
- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` +
5648+
:kbd:`Y`.
5649+
- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on
5650+
selected text, allowing you to paste the copied content into other
5651+
applications.
5652+
- Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text.
5653+
- copy selected text, if available
5654+
- if no text is selected it copy the entire current line, including the
5655+
terminating newline if present
5656+
- Cut Text: Use :kbd:`Ctrl` + :kbd:`X` to cut selected text.
5657+
- cut selected text, if available
5658+
- if no text is selected it will cut the entire current line, including the
5659+
terminating newline if present
5660+
- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into
5661+
the editor.
5662+
- replace selected text, if available
5663+
- If no text is selected, paste text in the cursor position
5664+
55165665
Scrollbar class
55175666
---------------
55185667

library/lua/gui/widgets.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ FilteredList = require('gui.widgets.filtered_list')
2828
TabBar = require('gui.widgets.tab_bar')
2929
RangeSlider = require('gui.widgets.range_slider')
3030
DimensionsTooltip = require('gui.widgets.dimensions_tooltip')
31+
TextArea = require('gui.widgets.text_area')
3132

3233
Tab = TabBar.Tab
3334
makeButtonLabelText = Label.makeButtonLabelText

library/lua/gui/widgets/text_area.lua

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
-- Multiline text area control
2+
3+
local Panel = require('gui.widgets.containers.panel')
4+
local Scrollbar = require('gui.widgets.scrollbar')
5+
local TextAreaContent = require('gui.widgets.text_area.text_area_content')
6+
local HistoryStore = require('gui.widgets.text_area.history_store')
7+
8+
local HISTORY_ENTRY = HistoryStore.HISTORY_ENTRY
9+
10+
TextArea = defclass(TextArea, Panel)
11+
12+
TextArea.ATTRS{
13+
init_text = '',
14+
init_cursor = DEFAULT_NIL,
15+
text_pen = COLOR_LIGHTCYAN,
16+
ignore_keys = {},
17+
select_pen = COLOR_CYAN,
18+
on_text_change = DEFAULT_NIL,
19+
on_cursor_change = DEFAULT_NIL,
20+
one_line_mode = false,
21+
debug = false
22+
}
23+
24+
function TextArea:init()
25+
self.render_start_line_y = 1
26+
27+
self.text_area = TextAreaContent{
28+
frame={l=0,r=3,t=0},
29+
text=self.init_text,
30+
31+
text_pen=self.text_pen,
32+
ignore_keys=self.ignore_keys,
33+
select_pen=self.select_pen,
34+
debug=self.debug,
35+
one_line_mode=self.one_line_mode,
36+
37+
on_text_change=function (text, old_text)
38+
self:updateLayout()
39+
if self.on_text_change then
40+
self.on_text_change(text, old_text)
41+
end
42+
end,
43+
on_cursor_change=self:callback('onCursorChange')
44+
}
45+
self.scrollbar = Scrollbar{
46+
frame={r=0,t=1},
47+
on_scroll=self:callback('onScrollbar'),
48+
visible=not self.one_line_mode
49+
}
50+
51+
self:addviews{
52+
self.text_area,
53+
self.scrollbar,
54+
}
55+
end
56+
57+
function TextArea:getText()
58+
return self.text_area.text
59+
end
60+
61+
function TextArea:setText(text)
62+
self.text_area.history:store(
63+
HISTORY_ENTRY.OTHER,
64+
self:getText(),
65+
self:getCursor()
66+
)
67+
68+
return self.text_area:setText(text)
69+
end
70+
71+
function TextArea:getCursor()
72+
return self.text_area.cursor
73+
end
74+
75+
function TextArea:setCursor(cursor_offset)
76+
return self.text_area:setCursor(cursor_offset)
77+
end
78+
79+
function TextArea:clearHistory()
80+
return self.text_area.history:clear()
81+
end
82+
83+
function TextArea:onCursorChange(cursor, old_cursor)
84+
local x, y = self.text_area.wrapped_text:indexToCoords(
85+
self.text_area.cursor
86+
)
87+
88+
if y >= self.render_start_line_y + self.text_area.frame_body.height then
89+
self:updateScrollbar(
90+
y - self.text_area.frame_body.height + 1
91+
)
92+
elseif (y < self.render_start_line_y) then
93+
self:updateScrollbar(y)
94+
end
95+
96+
if self.on_cursor_change then
97+
self.on_cursor_change(cursor, old_cursor)
98+
end
99+
end
100+
101+
function TextArea:scrollToCursor(cursor_offset)
102+
if self.scrollbar.visible then
103+
local _, cursor_line_y = self.text_area.wrapped_text:indexToCoords(
104+
cursor_offset
105+
)
106+
self:updateScrollbar(cursor_line_y)
107+
end
108+
end
109+
110+
function TextArea:getPreferredFocusState()
111+
return true
112+
end
113+
114+
function TextArea:postUpdateLayout()
115+
self:updateScrollbar(self.render_start_line_y)
116+
117+
if self.text_area.cursor == nil then
118+
local cursor = self.init_cursor or #self.init_text + 1
119+
self.text_area:setCursor(cursor)
120+
self:scrollToCursor(cursor)
121+
end
122+
end
123+
124+
function TextArea:onScrollbar(scroll_spec)
125+
local height = self.text_area.frame_body.height
126+
127+
local render_start_line = self.render_start_line_y
128+
if scroll_spec == 'down_large' then
129+
render_start_line = render_start_line + math.ceil(height / 2)
130+
elseif scroll_spec == 'up_large' then
131+
render_start_line = render_start_line - math.ceil(height / 2)
132+
elseif scroll_spec == 'down_small' then
133+
render_start_line = render_start_line + 1
134+
elseif scroll_spec == 'up_small' then
135+
render_start_line = render_start_line - 1
136+
else
137+
render_start_line = tonumber(scroll_spec)
138+
end
139+
140+
self:updateScrollbar(render_start_line)
141+
end
142+
143+
function TextArea:updateScrollbar(scrollbar_current_y)
144+
local lines_count = #self.text_area.wrapped_text.lines
145+
146+
local render_start_line_y = math.min(
147+
#self.text_area.wrapped_text.lines - self.text_area.frame_body.height + 1,
148+
math.max(1, scrollbar_current_y)
149+
)
150+
151+
self.scrollbar:update(
152+
render_start_line_y,
153+
self.frame_body.height,
154+
lines_count
155+
)
156+
157+
if (self.frame_body.height >= lines_count) then
158+
render_start_line_y = 1
159+
end
160+
161+
self.render_start_line_y = render_start_line_y
162+
self.text_area:setRenderStartLineY(self.render_start_line_y)
163+
end
164+
165+
function TextArea:renderSubviews(dc)
166+
self.text_area.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1)
167+
-- only visible lines of text_area will be rendered
168+
TextArea.super.renderSubviews(self, dc)
169+
end
170+
171+
function TextArea:onInput(keys)
172+
if (self.scrollbar.is_dragging) then
173+
return self.scrollbar:onInput(keys)
174+
end
175+
176+
if keys._MOUSE_L and self:getMousePos() then
177+
self:setFocus(true)
178+
end
179+
180+
return TextArea.super.onInput(self, keys)
181+
end
182+
183+
return TextArea

0 commit comments

Comments
 (0)