From 795126a90d651ede075e9a94741dae4841e4de62 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 18 Oct 2026 00:45:42 +0800 Subject: feat(tui): add structured selection text and clipboard messages --- include/ricochet/tui/messages.hh | 39 ++++++- include/ricochet/tui/view.hh | 3 + src/tui/CMakeLists.txt | 2 +- src/tui/document_text.cc | 231 +++++++++++++++++++++++++++++++++++++++ src/tui/document_text.hh | 66 +++++++++++ src/tui/layout.cc | 35 +----- 6 files changed, 340 insertions(+), 36 deletions(-) create mode 100644 src/tui/document_text.cc create mode 100644 src/tui/document_text.hh diff --git a/include/ricochet/tui/messages.hh b/include/ricochet/tui/messages.hh index e7dc417..9d51fa3 100644 --- a/include/ricochet/tui/messages.hh +++ b/include/ricochet/tui/messages.hh @@ -139,12 +139,30 @@ struct ExitDecision std::optional explanation {}; }; +enum class CopyStatus : std::uint8_t +{ + Confirmed, + Dispatched, + Unavailable, + Failed +}; + +// The host's injected clipboard provider completes a CopySelection request once. +// Dispatched must not be used as confirmation that a terminal accepted the text. +struct CopyResult +{ + RequestId request {}; + CopyStatus status { CopyStatus::Unavailable }; + std::string summary {}; +}; + using HostUpdate = std::variant; @@ -220,6 +238,16 @@ struct ExportSelection std::string text {}; }; +struct CopySelection +{ + RequestId request {}; + DocumentScope source {}; + DraftRevision draft_revision {}; + // Frozen before dispatch; later cursor/selection changes cannot change this copy. + // Password content is already masked. Clipboard providers never receive drafts. + std::string text {}; +}; + struct RequestExit { RequestId request {}; @@ -227,7 +255,14 @@ struct RequestExit std::vector views {}; }; -using UserAction - = std::variant; +using UserAction = std::variant; } // namespace ricochet::tui diff --git a/include/ricochet/tui/view.hh b/include/ricochet/tui/view.hh index d259cf3..f5f33d4 100644 --- a/include/ricochet/tui/view.hh +++ b/include/ricochet/tui/view.hh @@ -39,6 +39,9 @@ struct SelectionSnapshot // The normalized half-open range also covers any controls between its endpoints. DocumentRange range {}; SelectionDirection direction { SelectionDirection::Forward }; + // Inclusive endpoint units retain the anchor through reversal and history restore. + DocumentRange anchor {}; + DocumentRange active {}; }; struct HorizontalScroll diff --git a/src/tui/CMakeLists.txt b/src/tui/CMakeLists.txt index 8b49cf1..ed7b067 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.cc mailbox.cc screen.cc session.cc text.cc layout.cc) +add_library(ricochet_tui STATIC document_text.cc document.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/document_text.cc b/src/tui/document_text.cc new file mode 100644 index 0000000..dd0c64d --- /dev/null +++ b/src/tui/document_text.cc @@ -0,0 +1,231 @@ +/* + * 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 "document_text.hh" + +#include +#include +#include + +namespace ricochet::tui::detail { + +std::string control_display_text( const Control& control, const TextService& text ) +{ + return std::visit( + [&control, &text]( const auto& value ) -> std::string { + using Value = std::decay_t; + if constexpr ( std::is_same_v ) { + if ( value.kind == TextControlKind::Password ) { + return std::string( text.analyze( value.value, true, false ).graphemes.size(), '*' ); + } + return value.value; + } else if constexpr ( std::is_same_v || std::is_same_v + || std::is_same_v ) { + return control.label; + } else if constexpr ( std::is_same_v ) { + std::string result; + for ( const auto& option : value.options ) { + if ( std::ranges::find( value.selected, option.id ) != value.selected.end() ) { + if ( !result.empty() ) { + result += ", "; + } + result += option.label; + } + } + return result; + } else { + return value.description; + } + }, + control.content ); +} + +DocumentText::DocumentText( const Document& document, const TextService& text ) : service_( text ) +{ + for ( const auto& control : document.data().controls ) { + definitions_.emplace( control.id, &control ); + } + append_blocks( document.data().blocks, 0 ); +} + +void DocumentText::append_text( const Text& text ) +{ + const auto start = text_.size(); + text_ += text.utf8; + blocks_.emplace( text.id, ByteRange { start, text_.size() } ); +} + +void DocumentText::append_control( ControlId id ) +{ + const auto& control = *definitions_.at( id ); + const auto start = text_.size(); + // State is semantic content when selection crosses the object's opening boundary. + if ( const auto* checkbox = std::get_if( &control.content ) ) { + text_ += checkbox->checked ? "checked " : "unchecked "; + } else if ( const auto* radio = std::get_if( &control.content ) ) { + text_ += radio->checked ? "selected " : "unselected "; + } + const auto display = text_.size(); + text_ += control_display_text( control, service_ ); + controls_.emplace( id, ControlSpan { { start, text_.size() }, { display, text_.size() } } ); +} + +void DocumentText::append_inline( std::span content ) +{ + for ( const auto& item : content ) { + std::visit( + [this]( const auto& value ) { + using Value = std::decay_t; + if constexpr ( std::is_same_v ) { + append_text( value ); + } else { + append_control( value.control ); + } + }, + item ); + } +} + +void DocumentText::append_blocks( std::span blocks, std::size_t depth ) +{ + for ( std::size_t index = 0; index < blocks.size(); ++index ) { + if ( index != 0 ) { + text_ += "\n\n"; + } + append_block( blocks[index], depth ); + } +} + +void DocumentText::append_block( const Block& block, std::size_t depth ) +{ + const auto start = text_.size(); + std::visit( + [this, depth]( const auto& value ) { + using Value = std::decay_t; + if constexpr ( std::is_same_v || std::is_same_v ) { + append_inline( value.content ); + } else if constexpr ( std::is_same_v ) { + append_text( value.content ); + } else if constexpr ( std::is_same_v ) { + append_text( value.description ); + } else if constexpr ( std::is_same_v ) { + append_control( value.control ); + } else if constexpr ( std::is_same_v ) { + auto number = value.first_number; + for ( std::size_t index = 0; index < value.items.size(); ++index ) { + if ( index != 0 ) { + text_ += '\n'; + } + const auto& item = value.items[index]; + const auto prefix = text_.size(); + text_.append( depth * 2, ' ' ); + text_ += item.marker.value_or( value.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" ); + text_ += ' '; + prefixes_.push_back( { prefix, text_.size() } ); + append_blocks( item.content, depth + 1 ); + // Match layout's saturating counter at the model's largest list number. + if ( number < std::numeric_limits::max() ) { + ++number; + } + } + } else if constexpr ( std::is_same_v ) { + for ( std::size_t row = 0; row < value.rows.size(); ++row ) { + if ( row != 0 ) { + text_ += '\n'; + } + const auto& cells = value.rows[row].cells; + for ( std::size_t cell = 0; cell < cells.size(); ++cell ) { + if ( cell != 0 ) { + text_ += '\t'; + } + append_blocks( cells[cell].content, depth ); + } + } + } + // Separators carry positions but no copied decoration. + }, + block.content ); + blocks_.emplace( block.id, ByteRange { start, text_.size() } ); +} + +std::size_t DocumentText::offset( const DocumentPoint& point ) const +{ + return std::visit( + [this]( const auto& value ) -> std::size_t { + using Value = std::decay_t; + if constexpr ( std::is_same_v ) { + return blocks_.at( value.block ).start + value.byte_offset; + } else if constexpr ( std::is_same_v ) { + return controls_.at( value.control ).display.start + value.byte_offset; + } else if constexpr ( std::is_same_v ) { + const auto span = std::visit( + [this]( const auto& id ) -> ByteRange { + using Id = std::decay_t; + if constexpr ( std::is_same_v ) { + return blocks_.at( id ); + } else { + return controls_.at( id ).object; + } + }, + value.object ); + return value.side == BoundarySide::Before ? span.start : span.end; + } else { + return 0; + } + }, + point ); +} + +ByteRange DocumentText::offsets( const DocumentRange& range ) const +{ return { offset( range.start ), offset( range.end ) }; } + +SelectionSnapshot DocumentText::select( const DocumentRange& anchor, const DocumentRange& active ) const +{ + const bool backward = offset( active.start ) < offset( anchor.start ); + // A zero-width boundary can coincide with a nonempty endpoint unit. Keep the + // whole anchored unit even when the active endpoint comes back to that boundary. + return { .range = { backward ? active.start : anchor.start, + offset( active.end ) > offset( anchor.end ) ? active.end : anchor.end }, + .direction = backward ? SelectionDirection::Backward : SelectionDirection::Forward, + .anchor = anchor, + .active = active }; +} + +std::string DocumentText::extract( const DocumentRange& range ) const +{ + auto bytes = offsets( range ); + if ( bytes.start == bytes.end ) { + return {}; + } + // Include a semantic marker only when selection includes the item's start. + // Inner prefixes expand first, including a surrounding item with a nested list. + for ( auto prefix = prefixes_.rbegin(); prefix != prefixes_.rend(); ++prefix ) { + if ( prefix->end == bytes.start ) { + bytes.start = prefix->start; + } + } + auto result = text_.substr( bytes.start, bytes.end - bytes.start ); + // CRLF is one hard-break grapheme; copied hard breaks use one LF. + for ( auto position = result.find( "\r\n" ); position != std::string::npos; + position = result.find( "\r\n", position + 1 ) ) { + result.erase( position, 1 ); + } + return result; +} + +} // namespace ricochet::tui::detail diff --git a/src/tui/document_text.hh b/src/tui/document_text.hh new file mode 100644 index 0000000..d490c9d --- /dev/null +++ b/src/tui/document_text.hh @@ -0,0 +1,66 @@ +/* + * 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 . + */ + +#pragma once + +#include "ricochet/tui/document.hh" +#include "ricochet/tui/view.hh" +#include "text.hh" + +#include +#include +#include + +namespace ricochet::tui::detail { + +// Shared by layout and extraction: password positions address only this mask. +std::string control_display_text( const Control& control, const TextService& text ); + +// Logical reading-order text, independent of wrapping, clipping and decorations. +// Owned together with one immutable document and its revision-zero control values. +class DocumentText +{ +public: + DocumentText( const Document& document, const TextService& text ); + ByteRange offsets( const DocumentRange& range ) const; + SelectionSnapshot select( const DocumentRange& anchor, const DocumentRange& active ) const; + std::string extract( const DocumentRange& range ) const; + +private: + struct ControlSpan + { + ByteRange object {}; + ByteRange display {}; + }; + std::size_t offset( const DocumentPoint& point ) const; + void append_blocks( std::span blocks, std::size_t depth ); + void append_block( const Block& block, std::size_t depth ); + void append_inline( std::span content ); + void append_text( const Text& text ); + void append_control( ControlId id ); + + std::string text_ {}; + std::map blocks_ {}; + std::map controls_ {}; + std::map definitions_ {}; + // Prefix ends coincide with item starts; selecting that start includes its marker. + std::vector prefixes_ {}; + const TextService& service_; +}; + +} // namespace ricochet::tui::detail diff --git a/src/tui/layout.cc b/src/tui/layout.cc index 5aebe17..fa6eaa6 100644 --- a/src/tui/layout.cc +++ b/src/tui/layout.cc @@ -17,6 +17,7 @@ */ #include "layout.hh" +#include "document_text.hh" #include #include @@ -161,38 +162,6 @@ private: } } - std::string control_text( const Control& control ) const - { - return std::visit( - [this, &control]( const auto& value ) -> std::string { - using Value = std::decay_t; - if constexpr ( std::is_same_v ) { - if ( value.kind == TextControlKind::Password ) { - return std::string( text_.analyze( value.value, true, false ).graphemes.size(), '*' ); - } - return value.value; - } else if constexpr ( std::is_same_v || std::is_same_v ) { - return control.label; - } else if constexpr ( std::is_same_v ) { - std::string result; - for ( const auto& option : value.options ) { - if ( std::ranges::find( value.selected, option.id ) != value.selected.end() ) { - if ( !result.empty() ) { - result += ", "; - } - result += option.label; - } - } - return result; - } else if constexpr ( std::is_same_v ) { - return control.label; - } else { - return value.description; - } - }, - control.content ); - } - void control_atoms( std::vector& atoms, ControlId id, TextStyle base ) const { // A missing ID is a malformed document reference; map::at reports it directly. @@ -217,7 +186,7 @@ private: .style = style, .target = id } ); } - const auto display = control_text( control ); + const auto display = control_display_text( control, text_ ); const auto analysis = text_.analyze( display, multiline, false ); for ( const auto& glyph : analysis.graphemes ) { atoms.push_back( { .glyph = glyph, -- cgit v1.2.3