Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widgets/text area #4995

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
110 changes: 110 additions & 0 deletions docs/dev/Lua API.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5513,6 +5513,116 @@ The ``EditField`` class also provides the following functions:

Inserts the given text at the current cursor position.

TextArea class
--------------

Subclass of Panel; implements a multi-line text field with features such as
text wrapping, mouse control, text selection, clipboard support, history,
and typical text editor shortcuts.

Attributes:

* ``init_text``: The initial text content for the text area.

* ``init_cursor``: The initial cursor position within the text content.

* ``text_pen``: Optional pen used to draw the text.

* ``select_pen``: Optional pen used for text selection.

* ``ignore_keys``: List of input keys to ignore.
Functions similarly to the ``ignore_keys`` attribute in the ``EditField`` class.

* ``on_text_change``: Callback function called whenever the text changes.
The function signature should be ``on_text_change(new_text)``.

* ``on_cursor_change``: Callback function called whenever the cursor
position changes. The function signature should be ``on_cursor_change(new_cursor_pos)``.

* ``one_line_mode``: Boolean attribute that, when set to ``true``,
disables multi-line text features and restricts the text area to a single line.

Functions:

* ``textarea:getText()``

Returns the current text content of the ``TextArea`` widget as a string.

* ``textarea:setText(text)``

Sets the content of the ``TextArea`` to the specified string ``text``.
The cursor position will not be adjusted, so should be set separately.

* ``textarea:getCursor()``

Returns the current cursor position within the text content.
The position is represented as a single integer, starting from 1.

* ``textarea:setCursor(cursor)``

Sets the cursor position within the text content.

* ``textarea:scrollToCursor()``

Scrolls the text area view to ensure that the current cursor position is visible.
This is useful for automatically scrolling when the user moves the cursor
beyond the visible region of the text area.

* ``textarea:clearHistory()``

Clear undo/redo history of the widget.

Functionality:

- Cursor Control: Navigate through text using arrow keys (Left, Right, Up,
and Down) for precise cursor placement.
- Fast Rewind: Use :kbd:`Ctrl` + :kbd:`Left` and :kbd:`Ctrl` + :kbd:`Right` to
move the cursor one word back or forward.
- Longest X Position Memory: The cursor remembers the longest x position when
moving up or down, making vertical navigation more intuitive.
- Mouse Control: Use the mouse to position the cursor within the text,
providing an alternative to keyboard navigation.
- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting
multiline text input.
- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit
within the display without manual adjustments.
- Backspace Support: Use the backspace key to delete characters to the left of
the cursor.
- Delete Character: :kbd:`Delete` deletes the character under the cursor.
- Line Navigation: :kbd:`Home` moves the cursor to the beginning of the current
line, and :kbd:`End` moves it to the end.
- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line
where the cursor is located.
- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to
the end of the line.
- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before
the cursor.
- Text Selection: Select text with the mouse, with support for replacing or
removing selected text.
- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the
text using :kbd:`Ctrl` + :kbd:`Home` and :kbd:`Ctrl` + :kbd:`End`.
- Select Word/Line: Use double click to select current word, or triple click to
select current line
- Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A`
- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` +
:kbd:`Y`
- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on
selected text, allowing you to paste the copied content into other
applications.
- Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text.
- copy selected text, if available
- If no text is selected it copy the entire current line, including the
terminating newline if present.
- Cut Text: Use :kbd:`Ctrl` + :kbd:`X` to cut selected text.
- cut selected text, if available
- If no text is selected it will cut the entire current line, including the
terminating newline if present
- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into
the editor.
- replace selected text, if available
- If no text is selected, paste text in the cursor position
- Scrolling behaviour for long text build-in

Scrollbar class
---------------

Expand Down
1 change: 1 addition & 0 deletions library/lua/gui/widgets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ FilteredList = require('gui.widgets.filtered_list')
TabBar = require('gui.widgets.tab_bar')
RangeSlider = require('gui.widgets.range_slider')
DimensionsTooltip = require('gui.widgets.dimensions_tooltip')
TextArea = require('gui.widgets.text_area')

Tab = TabBar.Tab
makeButtonLabelText = Label.makeButtonLabelText
Expand Down
184 changes: 184 additions & 0 deletions library/lua/gui/widgets/text_area.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
-- Multiline text area control

local Panel = require('gui.widgets.containers.panel')
local Scrollbar = require('gui.widgets.scrollbar')
local TextAreaContent = require('gui.widgets.text_area.text_area_content')
local HistoryStore = require('gui.widgets.text_area.history_store')

local HISTORY_ENTRY = HistoryStore.HISTORY_ENTRY

TextArea = defclass(TextArea, Panel)

TextArea.ATTRS{
init_text = '',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it makes sense to switch this to text instead of init_text? It feels like text might align more closely with the API of other widgets like EditField, Label

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am afraid it will consfuse users and they will treat it as writeable property - where it is not. they need to to use :setText(...)

init_cursor = DEFAULT_NIL,
text_pen = COLOR_LIGHTCYAN,
ignore_keys = {'STRING_A096'},
select_pen = COLOR_CYAN,
on_text_change = DEFAULT_NIL,
on_cursor_change = DEFAULT_NIL,
one_line_mode = false,
debug = false
}

function TextArea:init()
self.render_start_line_y = 1

self.text_area = TextAreaContent{
frame={l=0,r=3,t=0},
text=self.init_text,

text_pen=self.text_pen,
ignore_keys=self.ignore_keys,
select_pen=self.select_pen,
debug=self.debug,
one_line_mode=self.one_line_mode,

on_text_change=function (val)
self:updateLayout()
if self.on_text_change then
self.on_text_change(val)
end
end,
on_cursor_change=self:callback('onCursorChange')
}
self.scrollbar = Scrollbar{
frame={r=0,t=1},
on_scroll=self:callback('onScrollbar'),
visible=not self.one_line_mode
}

self:addviews{
self.text_area,
self.scrollbar,
}
self:setFocus(true)
end

function TextArea:getText()
return self.text_area.text
end

function TextArea:setText(text)
self.text_area.history:store(
HISTORY_ENTRY.OTHER,
self:getText(),
self:getCursor()
)

return self.text_area:setText(text)
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we expose a setText method as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Contributor

@robob27 robob27 Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make it so that if you call setText(text) and then press CTRL-Z, it will revert to the text before the setText call? (If it's already working like that for you, let me know, I might just need to re-pull)

Edit: I repulled just to make sure so I didn't waste your time and confirmed that it's not working this way on the latest update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have mixed feelings about it, can you present a usecase where it would be useful?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The situation where this came up for me is with this UI in a script I've been working on:

image

I'm using this to be able to quickly add notes to individual units. Once I no longer need the note for the unit, I'll clear it out with ALT+C. I know I could CTRL+A/backspace to clear all but I also wanted to provide a shortcut to clear the notes with a single hotkey press (I had this before while using an EditField here and still find it handy).

If a user ever did ALT+C by accident here, ideally they could CTRL+Z it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok. I would additionally expose clearHistory function for cases where it would be a required behaviour.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

function TextArea:getCursor()
return self.text_area.cursor
end

function TextArea:setCursor(cursor_offset)
return self.text_area:setCursor(cursor_offset)
end

function TextArea:clearHistory()
return self.text_area.history:clear()
end

function TextArea:onCursorChange(cursor)
local x, y = self.text_area.wrapped_text:indexToCoords(
self.text_area.cursor
)

if y >= self.render_start_line_y + self.text_area.frame_body.height then
self:updateScrollbar(
y - self.text_area.frame_body.height + 1
)
elseif (y < self.render_start_line_y) then
self:updateScrollbar(y)
end

if self.on_cursor_change then
self.on_cursor_change(cursor)
end
end

function TextArea:scrollToCursor(cursor_offset)
if self.scrollbar.visible then
local _, cursor_liny_y = self.text_area.wrapped_text:indexToCoords(
cursor_offset
)
self:updateScrollbar(cursor_liny_y)
end
end

function TextArea:getPreferredFocusState()
return self.parent_view.focus
end

function TextArea:postUpdateLayout()
self:updateScrollbar(self.render_start_line_y)

if self.text_area.cursor == nil then
local cursor = self.init_cursor or #self.init_text + 1
self.text_area:setCursor(cursor)
self:scrollToCursor(cursor)
end
end

function TextArea:onScrollbar(scroll_spec)
local height = self.text_area.frame_body.height

local render_start_line = self.render_start_line_y
if scroll_spec == 'down_large' then
render_start_line = render_start_line + math.ceil(height / 2)
elseif scroll_spec == 'up_large' then
render_start_line = render_start_line - math.ceil(height / 2)
elseif scroll_spec == 'down_small' then
render_start_line = render_start_line + 1
elseif scroll_spec == 'up_small' then
render_start_line = render_start_line - 1
else
render_start_line = tonumber(scroll_spec)
end

self:updateScrollbar(render_start_line)
end

function TextArea:updateScrollbar(scrollbar_current_y)
local lines_count = #self.text_area.wrapped_text.lines

local render_start_line_y = (math.min(
#self.text_area.wrapped_text.lines - self.text_area.frame_body.height + 1,
math.max(1, scrollbar_current_y)
))

self.scrollbar:update(
render_start_line_y,
self.frame_body.height,
lines_count
)

if (self.frame_body.height >= lines_count) then
render_start_line_y = 1
end

self.render_start_line_y = render_start_line_y
self.text_area:setRenderStartLineY(self.render_start_line_y)
end

function TextArea:renderSubviews(dc)
self.text_area.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1)

TextArea.super.renderSubviews(self, dc)
end

function TextArea:onInput(keys)
if (self.scrollbar.is_dragging) then
return self.scrollbar:onInput(keys)
end

if keys._MOUSE_L and self:getMousePos() then
self:setFocus(true)
end

return TextArea.super.onInput(self, keys)
end

return TextArea
86 changes: 86 additions & 0 deletions library/lua/gui/widgets/text_area/history_store.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
HistoryStore = defclass(HistoryStore)

local HISTORY_ENTRY = {
TEXT_BLOCK = 1,
WHITESPACE_BLOCK = 2,
BACKSPACE = 2,
DELETE = 3,
OTHER = 4
}

HistoryStore.ATTRS{
history_size = 25,
}

function HistoryStore:init()
self.past = {}
self.future = {}
end

function HistoryStore:store(history_entry_type, text, cursor)
local last_entry = self.past[#self.past]

if not last_entry or history_entry_type == HISTORY_ENTRY.OTHER or
last_entry.entry_type ~= history_entry_type then
table.insert(self.past, {
entry_type=history_entry_type,
text=text,
cursor=cursor
})
end

self.future = {}

if #self.past > self.history_size then
table.remove(self.past, 1)
end
end

function HistoryStore:undo(curr_text, curr_cursor)
if #self.past == 0 then
return nil
end

local history_entry = table.remove(self.past, #self.past)

table.insert(self.future, {
entry_type=HISTORY_ENTRY.OTHER,
text=curr_text,
cursor=curr_cursor
})

if #self.future > self.history_size then
table.remove(self.future, 1)
end

return history_entry
end

function HistoryStore:redo(curr_text, curr_cursor)
if #self.future == 0 then
return true
end

local history_entry = table.remove(self.future, #self.future)

table.insert(self.past, {
entry_type=HISTORY_ENTRY.OTHER,
text=curr_text,
cursor=curr_cursor
})

if #self.past > self.history_size then
table.remove(self.past, 1)
end

return history_entry
end

function HistoryStore:clear()
self.past = {}
self.future = {}
end

HistoryStore.HISTORY_ENTRY = HISTORY_ENTRY

return HistoryStore
Loading