diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-10-26 21:27:00 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-10-26 21:27:00 +0800 |
| commit | 9bfcdbf2131ae6c08040226be6e92447db625b18 (patch) | |
| tree | a710d4c581da2e93756bf16a8a8aa6db303ee2b9 | |
| parent | 2971d699423e2f28f2686b098cb5dbf9a705038e (diff) | |
| download | rwebbrowser-9bfcdbf2131ae6c08040226be6e92447db625b18.tar.gz rwebbrowser-9bfcdbf2131ae6c08040226be6e92447db625b18.tar.xz | |
feat(tui): add reusable text input component
| -rw-r--r-- | src/tui/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | src/tui/editor.cc | 629 | ||||
| -rw-r--r-- | src/tui/editor.hh | 169 | ||||
| -rw-r--r-- | src/tui/text.cc | 20 | ||||
| -rw-r--r-- | src/tui/text.hh | 1 |
5 files changed, 820 insertions, 1 deletions
diff --git a/src/tui/CMakeLists.txt b/src/tui/CMakeLists.txt index 98dcee2..27820e8 100644 --- a/src/tui/CMakeLists.txt +++ b/src/tui/CMakeLists.txt @@ -4,7 +4,7 @@ find_package(Threads REQUIRED) # ICU 76.1 supplies UTF-8 grapheme/line boundaries and Unicode properties. pkg_check_modules(ICU REQUIRED IMPORTED_TARGET icu-uc>=76.1 icu-i18n>=76.1) -add_library(ricochet_tui STATIC document_text.cc document.cc document_view.cc mailbox.cc screen.cc session.cc text.cc layout.cc) +add_library(ricochet_tui STATIC editor.cc document_text.cc document.cc document_view.cc mailbox.cc screen.cc session.cc text.cc layout.cc) target_include_directories(ricochet_tui PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(ricochet_tui PUBLIC cxx_std_23) target_link_libraries(ricochet_tui PRIVATE PkgConfig::NCURSESW PkgConfig::ICU Threads::Threads) diff --git a/src/tui/editor.cc b/src/tui/editor.cc new file mode 100644 index 0000000..aa0545b --- /dev/null +++ b/src/tui/editor.cc @@ -0,0 +1,629 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "editor.hh" + +#include <algorithm> +#include <limits> +#include <utility> + +namespace ricochet::tui::detail { +namespace { + +ScreenSize editor_size( ScreenSize viewport ) +{ return { std::max( 1, viewport.rows ), std::max( 1, viewport.columns ) }; } + +} // namespace + +TextEditor::TextEditor( std::string value, EditorOptions options, const TextService& text, ScreenSize viewport ) + : options_( options ), text_( text ), viewport_( editor_size( viewport ) ) +{ + state_.value = std::move( value ); + state_.cursor = state_.value.size(); + analyze(); + desired_column_ = carets_.back().column; + reveal(); +} + +const std::string& TextEditor::value() const +{ return state_.value; } +std::uint64_t TextEditor::revision() const +{ return revision_; } +std::size_t TextEditor::cursor() const +{ return state_.cursor; } + +ByteRange TextEditor::selection() const +{ + const auto anchor = state_.anchor.value_or( state_.cursor ); + return { std::min( anchor, state_.cursor ), std::max( anchor, state_.cursor ) }; +} + +std::optional<std::string> TextEditor::copy_selection() +{ + killing_ = false; + prefix_.reset(); + const auto range = selection(); + if ( options_.kind == EditorKind::Password || range.start == range.end ) { + return std::nullopt; + } + return state_.value.substr( range.start, range.end - range.start ); +} + +std::optional<std::chrono::steady_clock::time_point> TextEditor::prefix_deadline() const +{ return prefix_; } + +std::string TextEditor::normalize( std::string_view value ) const +{ + std::string result; + result.reserve( value.size() ); + for ( std::size_t index = 0; index < value.size(); ++index ) { + const auto character = value[index]; + if ( character == '\r' ) { + if ( index + 1 < value.size() && value[index + 1] == '\n' ) { + ++index; + } + result += options_.kind == EditorKind::MultiLine ? '\n' : ' '; + } else if ( character == '\n' && options_.kind != EditorKind::MultiLine ) { + result += ' '; + } else { + result += character; + } + } + return result; +} + +std::size_t TextEditor::unit_index() const +{ + return static_cast<std::size_t>( + std::ranges::lower_bound( + analysis_.graphemes, state_.cursor, {}, []( const auto& glyph ) { return glyph.bytes.start; } ) + - analysis_.graphemes.begin() ); +} + +std::size_t TextEditor::boundary( std::size_t index ) const +{ return index == analysis_.graphemes.size() ? state_.value.size() : analysis_.graphemes[index].bytes.start; } + +std::size_t TextEditor::line_start() const +{ + const auto newline + = std::find_if( analysis_.graphemes.rbegin(), analysis_.graphemes.rend(), [this]( const auto& unit ) { + return unit.kind == TextUnitKind::LineBreak && unit.bytes.end <= state_.cursor; + } ); + return newline == analysis_.graphemes.rend() ? 0 : newline->bytes.end; +} + +std::size_t TextEditor::line_end() const +{ + const auto newline = std::ranges::find_if( analysis_.graphemes, [this]( const auto& unit ) { + return unit.kind == TextUnitKind::LineBreak && unit.bytes.start >= state_.cursor; + } ); + return newline == analysis_.graphemes.end() ? state_.value.size() : newline->bytes.start; +} + +void TextEditor::analyze() +{ + analysis_ = text_.analyze( state_.value, options_.kind == EditorKind::MultiLine, true ); + layout(); +} + +void TextEditor::layout() +{ + glyphs_.clear(); + carets_.assign( analysis_.graphemes.size() + 1, {} ); + Point point; + for ( std::size_t index = 0; index < analysis_.graphemes.size(); ++index ) { + const auto& unit = analysis_.graphemes[index]; + const bool password = options_.kind == EditorKind::Password; + if ( unit.kind == TextUnitKind::LineBreak && !password ) { + // A hard break after a full row stays on that row. Reuse the preceding + // complete glyph's cell for its insertion cursor instead of wrapping twice. + carets_[index] = point.column == viewport_.columns ? glyphs_.back().point : point; + glyphs_.push_back( { point, 0 } ); + ++point.row; + point.column = 0; + } else { + const auto width_at = [&]( std::int64_t column ) { + const auto width = password ? 1 + : unit.kind == TextUnitKind::Tab ? 8 - static_cast<int>( column % 8 ) + : unit.columns; + return options_.kind == EditorKind::MultiLine ? std::min( width, viewport_.columns ) : width; + }; + int width = width_at( point.column ); + if ( options_.kind == EditorKind::MultiLine && point.column > 0 + && point.column + width > viewport_.columns ) { + ++point.row; + point.column = 0; + width = width_at( 0 ); + } + carets_[index] = point; + glyphs_.push_back( { point, width } ); + point.column += width; + } + carets_[index + 1] = point; + } + // A full final soft row needs a real insertion cell after its final grapheme. + if ( options_.kind == EditorKind::MultiLine && point.column == viewport_.columns ) { + carets_.back() = { point.row + 1, 0 }; + } +} + +void TextEditor::reveal() +{ + const auto point = carets_[unit_index()]; + const auto height = static_cast<std::size_t>( viewport_.rows ); + const auto rows = carets_.back().row + 1; + top_row_ = std::min( top_row_, rows - std::min( rows, height ) ); + if ( point.row < top_row_ ) { + top_row_ = point.row; + } else if ( point.row - top_row_ >= height ) { + top_row_ = point.row - height + 1; + } + if ( options_.kind == EditorKind::MultiLine ) { + left_column_ = 0; + } else { + left_column_ = std::min( left_column_, point.column ); + left_column_ = std::max( left_column_, point.column - viewport_.columns + 1 ); + } +} + +void TextEditor::resize( ScreenSize viewport ) +{ + const auto next = editor_size( viewport ); + if ( next == viewport_ ) { + return; + } + viewport_ = next; + layout(); + desired_column_ = carets_[unit_index()].column; + reveal(); +} + +std::size_t TextEditor::nearest( Point point ) const +{ + std::size_t chosen = 0; + auto distance = std::pair { std::numeric_limits<std::size_t>::max(), std::numeric_limits<std::int64_t>::max() }; + for ( std::size_t index = 0; index < carets_.size(); ++index ) { + const auto& candidate = carets_[index]; + const auto delta = std::pair { + candidate.row > point.row ? candidate.row - point.row : point.row - candidate.row, + candidate.column > point.column ? candidate.column - point.column : point.column - candidate.column }; + if ( delta < distance ) { + chosen = index; + distance = delta; + } + } + return boundary( chosen ); +} + +void TextEditor::move( std::size_t destination, bool extend, bool preserve_column ) +{ + if ( extend || state_.selecting ) { + state_.anchor = state_.anchor.value_or( state_.cursor ); + } else { + state_.anchor.reset(); + } + state_.cursor = destination; + if ( !preserve_column ) { + desired_column_ = carets_[unit_index()].column; + } + reveal(); +} + +void TextEditor::replace( ByteRange range, std::string_view replacement ) +{ + auto next = state_.value; + next.replace( range.start, range.end - range.start, replacement ); + if ( next != state_.value ) { + past_.push_back( state_ ); + future_.clear(); + ++revision_; + } + state_.value = std::move( next ); + state_.cursor = range.start + replacement.size(); + state_.anchor.reset(); + state_.selecting = false; + analyze(); + // Insertion/deletion can join surrounding graphemes. Snap to the next boundary + // in the new text, never retain an offset inside a new combining/ZWJ cluster. + state_.cursor = boundary( unit_index() ); + desired_column_ = carets_[unit_index()].column; + reveal(); +} + +void TextEditor::insert( std::u32string_view input ) +{ + killing_ = false; + prefix_.reset(); + if ( input.empty() ) { + return; // An empty paste must not delete an existing selection. + } + const auto value = normalize( text_.encode( input ) ); + replace( selection(), value ); +} + +void TextEditor::replace_value( std::string value ) +{ + killing_ = false; + prefix_.reset(); + replace( { 0, state_.value.size() }, value ); +} + +void TextEditor::kill( ByteRange range ) +{ + if ( range.start == range.end ) { + return; // Empty kills preserve both the buffer and the current kill chain. + } + auto removed = state_.value.substr( range.start, range.end - range.start ); + if ( !killing_ ) { + killed_ = std::move( removed ); + } else if ( range.end <= state_.cursor ) { + killed_.insert( 0, removed ); + } else { + killed_ += removed; + } + replace( range, {} ); + killing_ = true; +} + +void TextEditor::undo( bool redo ) +{ + auto& source = redo ? future_ : past_; + auto& destination = redo ? past_ : future_; + if ( source.empty() ) { + return; + } + destination.push_back( std::move( state_ ) ); + state_ = std::move( source.back() ); + source.pop_back(); + ++revision_; + analyze(); + desired_column_ = carets_[unit_index()].column; + reveal(); +} + +void TextEditor::execute( EditorCommand command, bool extend ) +{ + prefix_.reset(); + const bool killing = command == EditorCommand::KillToStart || command == EditorCommand::KillToEnd + || command == EditorCommand::KillWord; + killing_ = killing_ && killing; + const auto range = selection(); + auto index = unit_index(); + switch ( command ) { + case EditorCommand::Left: + move( !extend && !state_.selecting && range.start != range.end ? range.start + : boundary( index == 0 ? 0 : index - 1 ), + extend, + false ); + break; + case EditorCommand::Right: + move( !extend && !state_.selecting && range.start != range.end + ? range.end + : boundary( std::min( index + 1, analysis_.graphemes.size() ) ), + extend, + false ); + break; + case EditorCommand::Up: + case EditorCommand::Down: { + if ( options_.kind != EditorKind::MultiLine ) { + break; + } + auto row = carets_[index].row; + row = command == EditorCommand::Up ? ( row == 0 ? 0 : row - 1 ) : std::min( row + 1, carets_.back().row ); + move( nearest( { row, desired_column_ } ), extend, true ); + break; + } + case EditorCommand::LineStart: + move( line_start(), extend, false ); + break; + case EditorCommand::LineEnd: + move( line_end(), extend, false ); + break; + case EditorCommand::PreviousWord: { + const auto word = std::find_if( analysis_.words.rbegin(), analysis_.words.rend(), [this]( const auto& item ) { + return item.start < state_.cursor; + } ); + move( word == analysis_.words.rend() ? 0 : word->start, extend, false ); + break; + } + case EditorCommand::NextWord: { + const auto word + = std::ranges::find_if( analysis_.words, [this]( const auto& item ) { return item.end > state_.cursor; } ); + move( word == analysis_.words.end() ? state_.value.size() : word->end, extend, false ); + break; + } + case EditorCommand::Backspace: + replace( range.start != range.end ? range + : ByteRange { boundary( index == 0 ? 0 : index - 1 ), state_.cursor }, + {} ); + break; + case EditorCommand::Delete: + replace( range.start != range.end + ? range + : ByteRange { state_.cursor, boundary( std::min( index + 1, analysis_.graphemes.size() ) ) }, + {} ); + break; + case EditorCommand::KillToStart: + kill( range.start != range.end ? range : ByteRange { line_start(), state_.cursor } ); + break; + case EditorCommand::KillToEnd: + kill( range.start != range.end ? range : ByteRange { state_.cursor, line_end() } ); + break; + case EditorCommand::KillWord: + while ( index > 0 && analysis_.graphemes[index - 1].word == WordKind::Space ) { + --index; + } + while ( index > 0 && analysis_.graphemes[index - 1].word != WordKind::Space ) { + --index; + } + kill( range.start != range.end ? range : ByteRange { boundary( index ), state_.cursor } ); + break; + case EditorCommand::Yank: + if ( !killed_.empty() ) { + replace( range, killed_ ); + } + break; + case EditorCommand::BeginSelection: + state_.anchor = state_.cursor; + state_.selecting = true; + break; + case EditorCommand::SelectAll: + state_.anchor = 0; + state_.cursor = state_.value.size(); + state_.selecting = false; + desired_column_ = carets_.back().column; + reveal(); + break; + case EditorCommand::CancelSelection: + state_.anchor.reset(); + state_.selecting = false; + break; + case EditorCommand::Clear: + replace( { 0, state_.value.size() }, {} ); + break; + case EditorCommand::Newline: + if ( options_.kind == EditorKind::MultiLine ) { + replace( range, "\n" ); + } + break; + case EditorCommand::Undo: + undo( false ); + break; + case EditorCommand::Redo: + undo( true ); + break; + } +} + +void TextEditor::place( CellPoint point, bool extend ) +{ + prefix_.reset(); + killing_ = false; + // The owner routes body clicks/dragging; outside drag coordinates clamp to this viewport. + const Point location { top_row_ + static_cast<std::size_t>( std::clamp( point.row, 0, viewport_.rows - 1 ) ), + left_column_ + std::clamp( point.column, 0, viewport_.columns - 1 ) }; + state_.selecting = false; + move( nearest( location ), extend, false ); +} + +EditorIntent TextEditor::key( const Key& input, std::chrono::steady_clock::time_point now ) +{ + if ( prefix_ ) { + prefix_.reset(); + if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true } + && input.character == U'u' ) { + execute( EditorCommand::Undo ); + } + return EditorIntent::Consumed; // Mismatches never execute a second command. + } + const bool kill_key = input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true } + && ( input.character == U'u' || input.character == U'k' || input.character == U'w' ); + killing_ = killing_ && kill_key; + if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true } ) { + switch ( input.character ) { + case U'b': + execute( EditorCommand::Left ); + break; + case U'f': + execute( EditorCommand::Right ); + break; + case U'p': + case U'n': + if ( options_.kind == EditorKind::SingleLine && options_.history ) { + return input.character == U'p' ? EditorIntent::HistoryPrevious : EditorIntent::HistoryNext; + } + execute( input.character == U'p' ? EditorCommand::Up : EditorCommand::Down ); + break; + case U'a': + execute( EditorCommand::LineStart ); + break; + case U'e': + execute( EditorCommand::LineEnd ); + break; + case U'd': + execute( EditorCommand::Delete ); + break; + case U'u': + execute( EditorCommand::KillToStart ); + break; + case U'k': + execute( EditorCommand::KillToEnd ); + break; + case U'w': + execute( EditorCommand::KillWord ); + break; + case U'y': + execute( EditorCommand::Yank ); + break; + case U'_': + execute( EditorCommand::Undo ); + break; + case U'x': + prefix_ = now + std::chrono::seconds( 1 ); + break; + case U'l': + return EditorIntent::Redraw; + case U'c': + return EditorIntent::Cancel; + default: + return EditorIntent::Unhandled; + } + return EditorIntent::Consumed; + } + if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .alt = true } ) { + switch ( input.character ) { + case U'b': + execute( EditorCommand::PreviousWord ); + return EditorIntent::Consumed; + case U'f': + execute( EditorCommand::NextWord ); + return EditorIntent::Consumed; + default: + return EditorIntent::Unhandled; + } + } + if ( input.modifiers != Modifiers {} && input.modifiers != Modifiers { .shift = true } ) { + return EditorIntent::Unhandled; + } + switch ( input.code ) { + case KeyCode::Left: + execute( EditorCommand::Left, input.modifiers.shift ); + break; + case KeyCode::Right: + execute( EditorCommand::Right, input.modifiers.shift ); + break; + case KeyCode::Up: + case KeyCode::Down: + if ( options_.kind == EditorKind::SingleLine && options_.history && !input.modifiers.shift ) { + return input.code == KeyCode::Up ? EditorIntent::HistoryPrevious : EditorIntent::HistoryNext; + } + execute( input.code == KeyCode::Up ? EditorCommand::Up : EditorCommand::Down, input.modifiers.shift ); + break; + case KeyCode::Home: + execute( EditorCommand::LineStart, input.modifiers.shift ); + break; + case KeyCode::End: + execute( EditorCommand::LineEnd, input.modifiers.shift ); + break; + case KeyCode::Tab: + return input.modifiers.shift ? EditorIntent::BackTab : EditorIntent::Tab; + default: + if ( input.modifiers.shift ) { + return EditorIntent::Unhandled; + } + switch ( input.code ) { + case KeyCode::Backspace: + execute( EditorCommand::Backspace ); + break; + case KeyCode::Delete: + execute( EditorCommand::Delete ); + break; + case KeyCode::Enter: + return EditorIntent::Enter; + case KeyCode::Escape: + return EditorIntent::Escape; + case KeyCode::Function: + return input.function == 10 ? EditorIntent::Menu : EditorIntent::Unhandled; + default: + return EditorIntent::Unhandled; + } + break; + } + return EditorIntent::Consumed; +} + +EditorIntent TextEditor::handle( const InputEvent& event, std::chrono::steady_clock::time_point now ) +{ + if ( const auto* tick = std::get_if<Tick>( &event ) ) { + now = tick->now; + } + if ( prefix_ && now >= *prefix_ ) { + prefix_.reset(); + } + if ( const auto* input = std::get_if<Key>( &event ) ) { + return key( *input, now ); + } + if ( const auto* input = std::get_if<TextInput>( &event ) ) { + killing_ = false; + if ( prefix_ ) { + prefix_.reset(); + } else { + insert( std::u32string_view( &input->character, 1 ) ); + } + return EditorIntent::Consumed; + } + if ( const auto* paste = std::get_if<Paste>( &event ) ) { + insert( paste->text ); + return EditorIntent::Consumed; + } + if ( std::holds_alternative<Tick>( event ) ) { + return EditorIntent::Consumed; + } + return EditorIntent::Unhandled; +} + +void TextEditor::draw( Frame& frame, CellPoint origin ) const +{ + // Fields can overlay an already painted document. Clear their entire region so + // shortening the value cannot expose the old field text beneath the editor. + for ( int row = 0; row < viewport_.rows; ++row ) { + for ( int column = 0; column < viewport_.columns; ++column ) { + frame.glyphs.push_back( { .position = { origin.row + row, origin.column + column }, .text = L" " } ); + } + } + const auto selected = selection(); + const auto cursor_point = carets_[unit_index()]; + for ( std::size_t index = 0; index < glyphs_.size(); ++index ) { + const auto& glyph = glyphs_[index]; + if ( glyph.point.row < top_row_ || glyph.point.row - top_row_ >= static_cast<std::size_t>( viewport_.rows ) ) { + continue; + } + const auto& unit = analysis_.graphemes[index]; + const bool password = options_.kind == EditorKind::Password; + const bool selected_unit = unit.bytes.start < selected.end && selected.start < unit.bytes.end; + const int row = origin.row + static_cast<int>( glyph.point.row - top_row_ ); + const auto column = glyph.point.column - left_column_; + const bool at_cursor = cursor_point.row == glyph.point.row && cursor_point.column == glyph.point.column; + if ( unit.kind == TextUnitKind::Tab && !password ) { + for ( auto cell = std::max<std::int64_t>( 0, column ); + cell < std::min<std::int64_t>( viewport_.columns, column + glyph.columns ); + ++cell ) { + frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( cell ) }, + .text = L" ", + .selected = selected_unit, + .cursor = at_cursor && cell == column } ); + } + } else { + const bool newline = unit.kind == TextUnitKind::LineBreak; + const int width = newline ? 1 : glyph.columns; + if ( column < 0 || column + width > viewport_.columns || ( newline && !selected_unit ) ) { + continue; + } + const auto display = password ? L"*" : newline ? L" " : glyph.columns < unit.columns ? L"?" : unit.display; + frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( column ) }, + .width = width, + .text = display, + .selected = selected_unit, + .cursor = at_cursor } ); + } + } + frame.cursor = CellPoint { origin.row + static_cast<int>( cursor_point.row - top_row_ ), + origin.column + static_cast<int>( cursor_point.column - left_column_ ) }; +} + +} // namespace ricochet::tui::detail diff --git a/src/tui/editor.hh b/src/tui/editor.hh new file mode 100644 index 0000000..457b0ca --- /dev/null +++ b/src/tui/editor.hh @@ -0,0 +1,169 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#pragma once + +#include "paint.hh" +#include "text.hh" + +#include <chrono> +#include <optional> + +namespace ricochet::tui::detail { + +enum class EditorKind : std::uint8_t +{ + SingleLine, + MultiLine, + Password +}; + +struct EditorOptions +{ + EditorKind kind { EditorKind::SingleLine }; + // The owner supplies history; password and multiline editors never request it. + bool history { false }; +}; + +enum class EditorCommand : std::uint8_t +{ + Left, + Right, + Up, + Down, + LineStart, + LineEnd, + PreviousWord, + NextWord, + Backspace, + Delete, + KillToStart, + KillToEnd, + KillWord, + Yank, + BeginSelection, + SelectAll, + CancelSelection, + Clear, + Newline, + Undo, + Redo +}; + +// Enter/Tab/Escape are context decisions, never implicit submit/finish operations. +// The owner may translate Enter into Newline for a multiline field. +// Unhandled keys stay with the owning editor context, never fall through to Browse. +enum class EditorIntent : std::uint8_t +{ + Consumed, + Unhandled, + Enter, + Tab, + BackTab, + Escape, + Cancel, + Menu, + Redraw, + HistoryPrevious, + HistoryNext +}; + +// One editing session. Owners recreate it when changing fields; its text, undo +// history and kill buffer are never shared with another editor or a view snapshot. +class TextEditor +{ +public: + TextEditor( std::string value, EditorOptions options, const TextService& text, ScreenSize viewport ); + TextEditor( const TextEditor& ) = delete; + TextEditor& operator=( const TextEditor& ) = delete; + TextEditor( TextEditor&& ) = delete; + TextEditor& operator=( TextEditor&& ) = delete; + ~TextEditor() = default; + + // Business value for the owning field; rendering and copying use the methods below. + const std::string& value() const; + std::uint64_t revision() const; + std::size_t cursor() const; + ByteRange selection() const; + std::optional<std::string> copy_selection(); + std::optional<std::chrono::steady_clock::time_point> prefix_deadline() const; + + void resize( ScreenSize viewport ); + void insert( std::u32string_view input ); + // Host history replacement is one undoable edit, not a new editing session. + void replace_value( std::string value ); + void execute( EditorCommand command, bool extend = false ); + EditorIntent handle( const InputEvent& event, + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now() ); + // Region-local mouse positioning, with extend=true while selecting/dragging. + void place( CellPoint point, bool extend ); + void draw( Frame& frame, CellPoint origin ) const; + +private: + struct State + { + std::string value {}; + std::size_t cursor { 0 }; + std::optional<std::size_t> anchor {}; + bool selecting { false }; + }; + struct Point + { + std::size_t row { 0 }; + std::int64_t column { 0 }; + }; + struct Glyph + { + Point point {}; + int columns { 0 }; + }; + + std::string normalize( std::string_view value ) const; + std::size_t unit_index() const; + std::size_t boundary( std::size_t index ) const; + std::size_t line_start() const; + std::size_t line_end() const; + std::size_t nearest( Point point ) const; + void analyze(); + void layout(); + void reveal(); + void move( std::size_t destination, bool extend, bool preserve_column ); + void replace( ByteRange range, std::string_view replacement ); + void kill( ByteRange range ); + void undo( bool redo ); + EditorIntent key( const Key& key, std::chrono::steady_clock::time_point now ); + + EditorOptions options_; + const TextService& text_; + ScreenSize viewport_; + State state_ {}; + TextAnalysis analysis_ {}; + std::vector<Glyph> glyphs_ {}; + std::vector<Point> carets_ {}; + std::size_t top_row_ { 0 }; + std::int64_t left_column_ { 0 }; + std::int64_t desired_column_ { 0 }; + std::vector<State> past_ {}; + std::vector<State> future_ {}; + std::string killed_ {}; + bool killing_ { false }; + std::uint64_t revision_ { 0 }; + std::optional<std::chrono::steady_clock::time_point> prefix_ {}; +}; + +} // namespace ricochet::tui::detail diff --git a/src/tui/text.cc b/src/tui/text.cc index b2c64b8..95b324b 100644 --- a/src/tui/text.cc +++ b/src/tui/text.cc @@ -24,6 +24,7 @@ #include <stdexcept> #include <unicode/ubrk.h> #include <unicode/uchar.h> +#include <unicode/unistr.h> #include <unicode/utext.h> #include <utility> @@ -65,6 +66,25 @@ WordKind word_kind( UChar32 codepoint ) TextService::TextService( std::size_t glyph_capacity ) : glyph_capacity_( glyph_capacity ) {} +std::string TextService::encode( std::u32string_view input ) const +{ + // ICU's UTF-32 conversion takes a signed 32-bit length, like its break iterator. + if ( input.size() > INT32_MAX ) { + throw std::length_error( "Input exceeds ICU conversion length capacity" ); + } + const std::vector<UChar32> codepoints( input.begin(), input.end() ); + // fromUTF32 requires a non-null pointer even for a zero-length input. + const UChar32 empty = 0; + const auto converted = icu::UnicodeString::fromUTF32( codepoints.empty() ? &empty : codepoints.data(), + static_cast<int32_t>( codepoints.size() ) ); + if ( converted.isBogus() ) { + throw std::runtime_error( "ICU could not convert editor input" ); + } + std::string result; + converted.toUTF8String( result ); + return result; +} + TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool tabs ) const { // ICU BreakIterator exposes int32_t native offsets even with a UTF-8 UText. diff --git a/src/tui/text.hh b/src/tui/text.hh index 3c0b103..eb0a2bb 100644 --- a/src/tui/text.hh +++ b/src/tui/text.hh @@ -69,6 +69,7 @@ class TextService public: explicit TextService( std::size_t glyph_capacity ); TextAnalysis analyze( std::string_view utf8, bool line_breaks, bool tabs ) const; + std::string encode( std::u32string_view input ) const; private: std::size_t glyph_capacity_; |
