aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--include/ricochet/tui/messages.hh39
-rw-r--r--include/ricochet/tui/view.hh3
-rw-r--r--src/tui/CMakeLists.txt2
-rw-r--r--src/tui/document_text.cc231
-rw-r--r--src/tui/document_text.hh66
-rw-r--r--src/tui/layout.cc35
6 files changed, 340 insertions, 36 deletions
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<std::string> 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<SyncTabs,
NavigationStarted,
CommitDocument,
UpdateDocument,
LocateView,
FormFeedback,
+ CopyResult,
ActionFailed,
ExitDecision>;
@@ -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<ViewSnapshot> views {};
};
-using UserAction
- = std::variant<OpenLocation, OpenLink, Navigate, CreateTab, CloseTab, FormAction, ExportSelection, RequestExit>;
+using UserAction = std::variant<OpenLocation,
+ OpenLink,
+ Navigate,
+ CreateTab,
+ CloseTab,
+ FormAction,
+ ExportSelection,
+ CopySelection,
+ RequestExit>;
} // 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "document_text.hh"
+
+#include <algorithm>
+#include <limits>
+#include <type_traits>
+
+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<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, TextControl> ) {
+ 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<Value, Checkbox> || std::is_same_v<Value, RadioButton>
+ || std::is_same_v<Value, Button> ) {
+ return control.label;
+ } else if constexpr ( std::is_same_v<Value, SelectControl> ) {
+ 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<Checkbox>( &control.content ) ) {
+ text_ += checkbox->checked ? "checked " : "unchecked ";
+ } else if ( const auto* radio = std::get_if<RadioButton>( &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<const Inline> content )
+{
+ for ( const auto& item : content ) {
+ std::visit(
+ [this]( const auto& value ) {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, Text> ) {
+ append_text( value );
+ } else {
+ append_control( value.control );
+ }
+ },
+ item );
+ }
+}
+
+void DocumentText::append_blocks( std::span<const Block> 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<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, Paragraph> || std::is_same_v<Value, Heading> ) {
+ append_inline( value.content );
+ } else if constexpr ( std::is_same_v<Value, Preformatted> ) {
+ append_text( value.content );
+ } else if constexpr ( std::is_same_v<Value, Alternative> ) {
+ append_text( value.description );
+ } else if constexpr ( std::is_same_v<Value, ControlPlaceholder> ) {
+ append_control( value.control );
+ } else if constexpr ( std::is_same_v<Value, List> ) {
+ 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<std::int64_t>::max() ) {
+ ++number;
+ }
+ }
+ } else if constexpr ( std::is_same_v<Value, Table> ) {
+ 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<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, TextPosition> ) {
+ return blocks_.at( value.block ).start + value.byte_offset;
+ } else if constexpr ( std::is_same_v<Value, ControlTextPosition> ) {
+ return controls_.at( value.control ).display.start + value.byte_offset;
+ } else if constexpr ( std::is_same_v<Value, ObjectPosition> ) {
+ const auto span = std::visit(
+ [this]( const auto& id ) -> ByteRange {
+ using Id = std::decay_t<decltype( id )>;
+ if constexpr ( std::is_same_v<Id, BlockId> ) {
+ 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/tui/document.hh"
+#include "ricochet/tui/view.hh"
+#include "text.hh"
+
+#include <map>
+#include <span>
+#include <string>
+
+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<const Block> blocks, std::size_t depth );
+ void append_block( const Block& block, std::size_t depth );
+ void append_inline( std::span<const Inline> content );
+ void append_text( const Text& text );
+ void append_control( ControlId id );
+
+ std::string text_ {};
+ std::map<BlockId, ByteRange> blocks_ {};
+ std::map<ControlId, ControlSpan> controls_ {};
+ std::map<ControlId, const Control*> definitions_ {};
+ // Prefix ends coincide with item starts; selecting that start includes its marker.
+ std::vector<ByteRange> 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 <algorithm>
#include <limits>
@@ -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<decltype( value )>;
- if constexpr ( std::is_same_v<Value, TextControl> ) {
- 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<Value, Checkbox> || std::is_same_v<Value, RadioButton> ) {
- return control.label;
- } else if constexpr ( std::is_same_v<Value, SelectControl> ) {
- 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<Value, Button> ) {
- return control.label;
- } else {
- return value.description;
- }
- },
- control.content );
- }
-
void control_atoms( std::vector<Atom>& 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,