/* * 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 . */ #include "editor.hh" #include #include #include 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 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 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::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( 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( 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::max(), std::numeric_limits::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::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( &event ) ) { now = tick->now; } if ( prefix_ && now >= *prefix_ ) { prefix_.reset(); } if ( const auto* input = std::get_if( &event ) ) { return key( *input, now ); } if ( const auto* input = std::get_if( &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( &event ) ) { insert( paste->text ); return EditorIntent::Consumed; } if ( std::holds_alternative( 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( 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( 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( 0, column ); cell < std::min( viewport_.columns, column + glyph.columns ); ++cell ) { frame.glyphs.push_back( { .position = { row, origin.column + static_cast( 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( column ) }, .width = width, .text = display, .selected = selected_unit, .cursor = at_cursor } ); } } frame.cursor = CellPoint { origin.row + static_cast( cursor_point.row - top_row_ ), origin.column + static_cast( cursor_point.column - left_column_ ) }; } } // namespace ricochet::tui::detail