From b1c08a55448a6e363eddba88cbc58e61de630dc9 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 17 Oct 2026 03:37:11 +0800 Subject: feat(tui): add ncurses backend and session loop --- ricochet/src/tui/screen.hh | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 ricochet/src/tui/screen.hh (limited to 'ricochet/src/tui/screen.hh') diff --git a/ricochet/src/tui/screen.hh b/ricochet/src/tui/screen.hh new file mode 100644 index 0000000..f0df278 --- /dev/null +++ b/ricochet/src/tui/screen.hh @@ -0,0 +1,76 @@ +/* + * 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/session.hh" + +#include +#include +#include +#include +#include +#include +#include + +namespace ricochet::tui::detail { + +// Layout supplies one printable spacing character and its combining marks per +// glyph, with a measured width. Raw document text never goes directly to curses. +struct PaintGlyph +{ + CellPoint position {}; + int width { 1 }; + std::wstring text {}; + TextStyle style {}; +}; + +struct Frame +{ + std::vector glyphs {}; + std::optional cursor {}; +}; + +struct InputBatch +{ + std::vector events {}; + bool consumed { false }; +}; + +class Screen +{ +public: + Screen( std::FILE& input, std::FILE& output, TerminalOptions options ); + ~Screen(); + Screen( const Screen& ) = delete; + Screen& operator=( const Screen& ) = delete; + + ScreenSize size() const; + TerminalCapabilities capabilities() const; + InputBatch read_events( std::size_t limit ); + // Wait only polls descriptors; wgetch remains the sole terminal reader. + std::optional wait( int wake_fd, std::chrono::milliseconds max_wait ); + void draw( const Frame& frame ); + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace ricochet::tui::detail -- cgit v1.2.3 From b84c87ef820976cc541586a09ec6f845726580f2 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 17 Oct 2026 03:54:06 +0800 Subject: feat(tui): add Unicode text service and document layout --- ricochet/include/ricochet/tui/session.hh | 2 +- ricochet/src/tui/CMakeLists.txt | 6 +- ricochet/src/tui/layout.cc | 677 +++++++++++++++++++++++++++++++ ricochet/src/tui/layout.hh | 98 +++++ ricochet/src/tui/paint.hh | 46 +++ ricochet/src/tui/screen.cc | 3 + ricochet/src/tui/screen.hh | 20 +- ricochet/src/tui/session.cc | 29 +- ricochet/src/tui/text.cc | 138 +++++++ ricochet/src/tui/text.hh | 75 ++++ 10 files changed, 1072 insertions(+), 22 deletions(-) create mode 100644 ricochet/src/tui/layout.cc create mode 100644 ricochet/src/tui/layout.hh create mode 100644 ricochet/src/tui/paint.hh create mode 100644 ricochet/src/tui/text.cc create mode 100644 ricochet/src/tui/text.hh (limited to 'ricochet/src/tui/screen.hh') diff --git a/ricochet/include/ricochet/tui/session.hh b/ricochet/include/ricochet/tui/session.hh index 9968178..94cae68 100644 --- a/ricochet/include/ricochet/tui/session.hh +++ b/ricochet/include/ricochet/tui/session.hh @@ -71,7 +71,7 @@ private: struct StepResult { bool running { true }; - // Content editing/layout handlers are subsequent milestones. Until they exist, + // Incremental document updates and editing are subsequent milestones. Until then, // unsupported updates are returned intact rather than silently acknowledged. std::vector unhandled_updates {}; std::optional terminal_closed {}; diff --git a/ricochet/src/tui/CMakeLists.txt b/ricochet/src/tui/CMakeLists.txt index 908448e..8b49cf1 100644 --- a/ricochet/src/tui/CMakeLists.txt +++ b/ricochet/src/tui/CMakeLists.txt @@ -1,8 +1,10 @@ find_package(PkgConfig REQUIRED) pkg_check_modules(NCURSESW REQUIRED IMPORTED_TARGET ncursesw) 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) +add_library(ricochet_tui STATIC 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 Threads::Threads) +target_link_libraries(ricochet_tui PRIVATE PkgConfig::NCURSESW PkgConfig::ICU Threads::Threads) diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc new file mode 100644 index 0000000..5aebe17 --- /dev/null +++ b/ricochet/src/tui/layout.cc @@ -0,0 +1,677 @@ +/* + * 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 "layout.hh" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ricochet::tui::detail { +namespace { + +constexpr int tab_columns = 8; +constexpr std::int64_t cell_gap = 3; +constexpr std::int64_t minimum_cell_columns = 4; + +TextStyle overlay( TextStyle base, TextStyle extra ) +{ + return { base.emphasis || extra.emphasis, + base.strong || extra.strong, + base.code || extra.code, + base.subdued || extra.subdued, + base.warning || extra.warning }; +} + +struct Atom +{ + Grapheme glyph {}; + DocumentRange source {}; + TextStyle style {}; + std::optional target {}; + bool decoration { false }; +}; + +struct TextSpan +{ + const Text* leaf; + ByteRange bytes; +}; + +DocumentPoint text_point( std::span spans, std::size_t offset, bool end ) +{ + for ( const auto& span : spans ) { + // At an inline seam an end belongs to the preceding leaf, a start to the next. + if ( offset < span.bytes.end || ( end && offset == span.bytes.end ) ) { + return TextPosition { span.leaf->id, offset - span.bytes.start }; + } + } + const auto& last = spans.back(); + return TextPosition { last.leaf->id, last.leaf->utf8.size() }; +} + +LayoutGlyph anchor( DocumentPoint point, LayoutPoint location, std::int64_t width ) +{ return { .position = location, .source = { point, point }, .clip_right = width }; } + +void append_box( LayoutData& destination, LayoutData source, std::size_t row, std::int64_t column ) +{ + destination.rows = std::max( destination.rows, row + source.rows ); + destination.extent = std::max( destination.extent, column + source.extent ); + for ( auto& glyph : source.glyphs ) { + glyph.position.row += row; + glyph.position.column += column; + glyph.line_start += column; + glyph.clip_left += column; + glyph.clip_right += column; + destination.glyphs.push_back( std::move( glyph ) ); + } +} + +class LayoutBuilder +{ +public: + LayoutBuilder( const DocumentData& document, const TextService& text, std::span scrolls ) + : document_( document ), text_( text ), scrolls_( scrolls ) + { + for ( const auto& control : document.controls ) { + controls_.emplace( control.id, &control ); + } + } + + LayoutData blocks( std::span content, std::int64_t width, TextStyle style = {} ) const + { + LayoutData result; + std::size_t row = 0; + for ( const auto& block : content ) { + auto box = block_box( block, width, style ); + const auto height = box.rows; + append_box( result, std::move( box ), row, 0 ); + row += height; + } + return result; + } + +private: + void text_run( std::vector& atoms, + std::span spans, + const std::string& utf8, + bool tabs, + TextStyle base ) const + { + const auto analysis = text_.analyze( utf8, true, tabs ); + for ( const auto& glyph : analysis.graphemes ) { + Atom atom { .glyph = glyph, + .source + = { text_point( spans, glyph.bytes.start, false ), text_point( spans, glyph.bytes.end, true ) }, + .style = base }; + for ( const auto& span : spans ) { + // Only source portions intersecting this grapheme can contribute style/link. + if ( span.bytes.start >= glyph.bytes.end || span.bytes.end <= glyph.bytes.start ) { + continue; + } + const ByteRange local { std::max( span.bytes.start, glyph.bytes.start ) - span.bytes.start, + std::min( span.bytes.end, glyph.bytes.end ) - span.bytes.start }; + for ( const auto& styled : span.leaf->styles ) { + if ( styled.bytes.start < local.end && local.start < styled.bytes.end ) { + atom.style = overlay( atom.style, styled.style ); + } + } + for ( const auto& link : document_.links ) { + for ( const auto& range : link.ranges ) { + if ( range.block == span.leaf->id && range.bytes.start < local.end && local.start < range.bytes.end ) { + const HitTarget target = link.id; + // One grapheme cannot activate two links, including across leaf seams. + if ( atom.target && atom.target != target ) { + throw std::invalid_argument( "Overlapping links address the same grapheme" ); + } + atom.target = target; + atom.style.emphasis = true; + } + } + } + } + atoms.push_back( std::move( atom ) ); + } + // Empty text still has a real byte boundary; no fabricated display character. + if ( utf8.empty() ) { + const auto point = text_point( spans, 0, false ); + atoms.push_back( { .glyph = { .columns = 0 }, .source = { point, point }, .style = base } ); + } + } + + 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. + const auto& control = *controls_.at( id ); + const auto* input = std::get_if( &control.content ); + const bool multiline = input && input->kind == TextControlKind::MultiLine; + const auto before = ObjectPosition { id, BoundarySide::Before }; + const auto after = ObjectPosition { id, BoundarySide::After }; + const auto style = overlay( base, { .subdued = control.disabled, .warning = control.error.has_value() } ); + std::wstring_view opening = L"["; + std::wstring_view closing = L"]"; + if ( const auto* checkbox = std::get_if( &control.content ) ) { + opening = checkbox->checked ? L"[x] " : L"[ ] "; + closing = L""; + } else if ( const auto* radio = std::get_if( &control.content ) ) { + opening = radio->checked ? L"(*) " : L"( ) "; + closing = L""; + } + for ( const auto character : opening ) { + atoms.push_back( { .glyph = { .display = std::wstring( 1, character ) }, + .source = { before, before }, + .style = style, + .target = id } ); + } + const auto display = control_text( control ); + const auto analysis = text_.analyze( display, multiline, false ); + for ( const auto& glyph : analysis.graphemes ) { + atoms.push_back( { .glyph = glyph, + .source = { ControlTextPosition { id, {}, glyph.bytes.start }, + ControlTextPosition { id, {}, glyph.bytes.end } }, + .style = style, + .target = id } ); + } + if ( display.empty() ) { + const auto point = ControlTextPosition { id, {}, 0 }; + atoms.push_back( { .glyph = { .display = L" " }, .source = { point, point }, .style = style, .target = id } ); + } + for ( const auto character : closing ) { + atoms.push_back( { .glyph = { .display = std::wstring( 1, character ), .break_after = true }, + .source = { after, after }, + .style = style, + .target = id } ); + } + // Checkbox/radio controls have no closing bracket but still have an after boundary. + atoms.push_back( { .glyph = { .columns = 0, .break_after = true }, + .source = { after, after }, + .style = style, + .target = id } ); + } + + std::vector inline_atoms( std::span content, TextStyle style ) const + { + std::vector result; + std::vector spans; + std::string utf8; + const auto flush = [&] { + if ( !spans.empty() ) { + text_run( result, spans, utf8, false, style ); + spans.clear(); + utf8.clear(); + } + }; + for ( const auto& item : content ) { + if ( const auto* text = std::get_if( &item ) ) { + const auto start = utf8.size(); + utf8 += text->utf8; + spans.push_back( { text, { start, utf8.size() } } ); + } else { + flush(); + control_atoms( result, std::get( item ).control, style ); + } + } + flush(); + return result; + } + + LayoutData flow( std::span atoms, std::int64_t width, bool wrap ) const + { + LayoutData result; + std::size_t start = 0; + std::size_t row = 0; + while ( start < atoms.size() ) { + std::size_t end = start; + std::size_t opportunity = start; + std::int64_t column = 0; + while ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak ) { + const auto& glyph = atoms[end].glyph; + const int cells = glyph.kind == TextUnitKind::Tab ? tab_columns - column % tab_columns : glyph.columns; + if ( wrap && column + cells > width && end > start ) { + break; + } + column += cells; + ++end; + if ( glyph.break_after ) { + opportunity = end; + } + } + // Prefer a Unicode line boundary; an overlong word breaks at a grapheme. + if ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak && opportunity > start ) { + end = opportunity; + } + column = 0; + for ( auto index = start; index < end; ++index ) { + const auto& atom = atoms[index]; + const bool tab = atom.glyph.kind == TextUnitKind::Tab; + const int cells = tab ? tab_columns - column % tab_columns : atom.glyph.columns; + result.glyphs.push_back( { .position = { row, column }, + .columns = cells, + .text = atom.glyph.display, + .style = atom.style, + .source = atom.source, + .target = atom.target, + .clip_right = wrap ? width : std::numeric_limits::max(), + .decoration = atom.decoration, + .fill = tab } ); + column += cells; + } + result.extent = std::max( result.extent, column ); + if ( end < atoms.size() && atoms[end].glyph.kind == TextUnitKind::LineBreak ) { + const auto& atom = atoms[end++]; + result.glyphs.push_back( { .position = { row, column }, + .source = atom.source, + .target = atom.target, + .clip_right = width, + .line_break = true } ); + // The endpoint of a hard LF belongs to the next line, including a + // trailing empty line; it must also be reachable by hit testing. + auto boundary = anchor( atom.source.end, { row + 1, 0 }, width ); + boundary.target = atom.target; + result.glyphs.push_back( std::move( boundary ) ); + result.rows = row + 2; + } else { + result.rows = row + 1; + } + start = end; + ++row; + } + return result; + } + + void horizontal( LayoutData& box, BlockId block, std::int64_t width ) const + { + const auto scroll = std::ranges::find( scrolls_, block, &HorizontalScroll::block ); + const auto requested = scroll == scrolls_.end() ? 0 : scroll->columns; + const auto maximum = std::max( 0, box.extent - width ); + const auto shift = static_cast( std::min( requested, maximum ) ); + for ( auto& glyph : box.glyphs ) { + glyph.position.column -= shift; + glyph.line_start -= shift; + glyph.clip_left = std::max( 0, glyph.clip_left - shift ); + glyph.clip_right = std::min( width, glyph.clip_right - shift ); + } + } + + LayoutData list_box( const List& list, std::int64_t width, TextStyle style ) const + { + LayoutData result; + std::size_t row = 0; + auto number = list.first_number; + for ( const auto& item : list.items ) { + const auto marker + = item.marker.value_or( list.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" ); + const auto analysis = text_.analyze( marker, false, false ); + const auto marker_width + = std::accumulate( analysis.graphemes.begin(), + analysis.graphemes.end(), + std::int64_t { 0 }, + []( auto total, const auto& glyph ) { return total + glyph.columns; } ); + // Deep nesting must leave at least one body cell; clipped markers stay decoration. + const auto indent = std::min( marker_width + 1, width - 1 ); + auto body = blocks( item.content, width - indent, style ); + const auto height = body.rows; + append_box( result, std::move( body ), row, indent ); + std::int64_t column = 0; + for ( const auto& glyph : analysis.graphemes ) { + result.glyphs.push_back( { .position = { row, column }, + .columns = glyph.columns, + .text = glyph.display, + .style = style, + .clip_right = indent, + .decoration = true } ); + column += glyph.columns; + } + row += height; + // Ordered counters can start at the model's largest int64 value. + if ( number < std::numeric_limits::max() ) { + ++number; + } + } + return result; + } + + LayoutData table_box( const Table& table, BlockId id, std::int64_t width, TextStyle style ) const + { + if ( table.columns == 0 ) { + return {}; + } + std::vector widths( table.columns, 1 ); + std::vector desired( table.columns, 1 ); + for ( std::size_t row = 0; row < table.rows.size(); ++row ) { + for ( const auto& cell : table.rows[row].cells ) { + // Spans index the supplied grid. Reject zero/out-of-grid spans before indexing. + if ( cell.column >= table.columns || cell.column_span == 0 || cell.row_span == 0 + || cell.column_span > table.columns - cell.column || cell.row_span > table.rows.size() - row ) { + throw std::invalid_argument( "Table cell span lies outside its resolved grid" ); + } + const auto measure = blocks( cell.content, width, style ); + const auto span = static_cast( cell.column_span ); + const auto preferred = std::max( 1, ( measure.extent + span - 1 ) / span ); + for ( auto column = cell.column; column < cell.column + cell.column_span; ++column ) { + desired[column] = std::max( desired[column], preferred ); + widths[column] = std::max( widths[column], std::min( preferred, minimum_cell_columns ) ); + } + } + } + const auto gaps = cell_gap * static_cast( table.columns - 1 ); + auto remaining = width - gaps - std::accumulate( widths.begin(), widths.end(), std::int64_t { 0 } ); + // Grow content-bearing columns toward their preferred width, within the viewport. + while ( remaining > 0 ) { + bool grew = false; + for ( std::size_t column = 0; column < widths.size() && remaining > 0; ++column ) { + if ( widths[column] < desired[column] ) { + ++widths[column]; + --remaining; + grew = true; + } + } + if ( !grew ) { + break; + } + } + struct CellBox + { + const TableCell* cell; + std::size_t row; + LayoutData box; + }; + std::vector cells; + std::vector heights( table.rows.size(), 1 ); + for ( std::size_t row = 0; row < table.rows.size(); ++row ) { + for ( const auto& cell : table.rows[row].cells ) { + const auto first = widths.begin() + static_cast( cell.column ); + const auto last = first + static_cast( cell.column_span ); + const auto cell_width = std::accumulate( first, last, std::int64_t { 0 } ) + + cell_gap * static_cast( cell.column_span - 1 ); + auto box = blocks( cell.content, cell_width, overlay( style, { .strong = cell.header } ) ); + const auto begin = heights.begin() + static_cast( row ); + const auto end = begin + static_cast( cell.row_span ); + const auto height = std::accumulate( begin, end, std::size_t { 0 } ); + if ( box.rows > height ) { + heights[row + cell.row_span - 1] += box.rows - height; + } + cells.push_back( { &cell, row, std::move( box ) } ); + } + } + std::vector row_offsets( heights.size() + 1, 0 ); + std::partial_sum( heights.begin(), heights.end(), row_offsets.begin() + 1 ); + std::vector column_offsets( widths.size(), 0 ); + for ( std::size_t column = 1; column < widths.size(); ++column ) { + column_offsets[column] = column_offsets[column - 1] + widths[column - 1] + cell_gap; + } + LayoutData result; + result.extent = std::accumulate( widths.begin(), widths.end(), gaps ); + result.rows = std::max( 1, row_offsets.back() ); + for ( auto& cell : cells ) { + const auto column = column_offsets[cell.cell->column]; + append_box( result, std::move( cell.box ), row_offsets[cell.row], column ); + if ( cell.cell->column != 0 ) { + for ( auto row = row_offsets[cell.row]; row < row_offsets[cell.row + cell.cell->row_span]; ++row ) { + result.glyphs.push_back( { .position = { row, column - 2 }, + .columns = 1, + .text = L"|", + .style = { .subdued = true }, + .clip_right = result.extent, + .decoration = true } ); + } + } + } + // A nested preformatted block scrolls inside its cell; its natural extent + // must not enlarge the enclosing table's horizontal scroll range. + result.extent = std::accumulate( widths.begin(), widths.end(), gaps ); + horizontal( result, id, width ); + return result; + } + + LayoutData block_box( const Block& block, std::int64_t width, TextStyle style ) const + { + auto result = std::visit( + [this, &block, width, style]( const auto& value ) -> LayoutData { + using Value = std::decay_t; + if constexpr ( std::is_same_v ) { + return flow( inline_atoms( value.content, style ), width, true ); + } else if constexpr ( std::is_same_v ) { + return flow( inline_atoms( value.content, overlay( style, { .strong = true } ) ), width, true ); + } else if constexpr ( std::is_same_v || std::is_same_v ) { + const Text& leaf = [&]() -> const Text& { + if constexpr ( std::is_same_v ) { + return value.content; + } else { + return value.description; + } + }(); + const TextSpan span { &leaf, { 0, leaf.utf8.size() } }; + std::vector atoms; + constexpr bool preformatted = std::is_same_v; + text_run( + atoms, std::span( &span, 1 ), leaf.utf8, preformatted, overlay( style, { .code = preformatted } ) ); + auto box = flow( atoms, width, !preformatted ); + if constexpr ( preformatted ) { + horizontal( box, block.id, width ); + } + return box; + } else if constexpr ( std::is_same_v ) { + return list_box( value, width, style ); + } else if constexpr ( std::is_same_v ) { + return table_box( value, block.id, width, style ); + } else if constexpr ( std::is_same_v ) { + std::vector atoms; + control_atoms( atoms, value.control, style ); + return flow( atoms, width, true ); + } else { + LayoutData box; + box.extent = width; + for ( std::int64_t column = 0; column < width; ++column ) { + box.glyphs.push_back( { .position = { 0, column }, + .columns = 1, + .text = L"-", + .style = { .subdued = true }, + .clip_right = width, + .decoration = true } ); + } + return box; + } + }, + block.content ); + const bool has_text + = std::ranges::any_of( result.glyphs, []( const auto& glyph ) { return !glyph.decoration; } ); + auto before = anchor( ObjectPosition { block.id, BoundarySide::Before }, {}, width ); + auto after = anchor( ObjectPosition { block.id, BoundarySide::After }, + { result.rows - 1, std::min( result.extent, width ) }, + width ); + before.boundary_only = has_text; + after.boundary_only = has_text; + result.glyphs.push_back( std::move( before ) ); + result.glyphs.push_back( std::move( after ) ); + return result; + } + + const DocumentData& document_; + const TextService& text_; + std::span scrolls_; + std::map controls_ {}; +}; + +LayoutPoint end_point( const LayoutGlyph& glyph ) +{ + // Object decorations and empty fields do not create a second text boundary. + if ( glyph.source.start == glyph.source.end ) { + return glyph.position; + } + if ( glyph.line_break ) { + return { glyph.position.row + 1, glyph.line_start }; + } + return { glyph.position.row, glyph.position.column + glyph.columns }; +} + +} // namespace + +DocumentLayout::DocumentLayout( const Document& document, + const TextService& text, + int columns, + std::span scrolls ) + : columns_( std::max( 1, columns ) ) + , data_( LayoutBuilder( document.data(), text, scrolls ).blocks( document.data().blocks, columns_ ) ) +{ + // An empty document still has a location for its cursor/viewport anchor. + if ( data_.glyphs.empty() ) { + data_.glyphs.push_back( anchor( DocumentStart {}, {}, columns_ ) ); + } +} + +int DocumentLayout::columns() const +{ return columns_; } +std::size_t DocumentLayout::rows() const +{ return data_.rows; } + +std::optional DocumentLayout::locate( const DisplayPosition& position ) const +{ + if ( std::holds_alternative( position.point ) ) { + return LayoutPoint {}; + } + std::optional fallback; + for ( const auto& glyph : data_.glyphs ) { + if ( glyph.decoration ) { + continue; + } + if ( position.point == glyph.source.start ) { + if ( position.affinity == WrapAffinity::NextLine ) { + return glyph.position; + } + fallback = glyph.position; + } + if ( position.point == glyph.source.end ) { + if ( position.affinity == WrapAffinity::PreviousLine ) { + return end_point( glyph ); + } + fallback = end_point( glyph ); + } + } + return fallback; +} + +std::optional DocumentLayout::hit( LayoutPoint point ) const +{ + const LayoutGlyph* nearest = nullptr; + auto distance + = std::tuple { std::numeric_limits::max(), std::numeric_limits::max(), true }; + for ( const auto& glyph : data_.glyphs ) { + // Decorations and partially clipped wide glyphs have no independent text hit. + if ( glyph.decoration || glyph.boundary_only ) { + continue; + } + const auto left = std::max( glyph.position.column, glyph.clip_left ); + const auto edge = std::min( glyph.position.column + glyph.columns, glyph.clip_right ); + // Tab blanks may be clipped individually; a wide glyph remains indivisible. + if ( ( glyph.fill && left >= edge ) + || ( !glyph.fill + && ( glyph.position.column < glyph.clip_left + || glyph.position.column + glyph.columns > glyph.clip_right ) ) ) { + continue; + } + const auto row_distance + = glyph.position.row > point.row ? glyph.position.row - point.row : point.row - glyph.position.row; + const auto right = std::max( left, edge - 1 ); + const auto column_distance = std::max( { left - point.column, point.column - right, std::int64_t { 0 } } ); + const auto candidate = std::tuple { row_distance, column_distance, glyph.columns == 0 }; + if ( candidate < distance ) { + nearest = &glyph; + distance = candidate; + } + } + if ( !nearest ) { + return std::nullopt; + } + const bool after = !nearest->line_break && point.column >= nearest->position.column + nearest->columns; + return LayoutHit { .position = { after ? nearest->source.end : nearest->source.start, + after ? WrapAffinity::PreviousLine : WrapAffinity::NextLine }, + .unit = nearest->source, + .target = nearest->target }; +} + +void DocumentLayout::append_frame( Frame& frame, + std::size_t first_row, + CellPoint origin, + ScreenSize viewport ) const +{ + for ( const auto& glyph : data_.glyphs ) { + if ( glyph.text.empty() || glyph.position.row < first_row + || glyph.position.row - first_row >= static_cast( std::max( 0, viewport.rows ) ) ) { + continue; + } + const auto left = std::max( 0, glyph.clip_left ); + const auto right = std::min( viewport.columns, glyph.clip_right ); + const auto row = origin.row + static_cast( glyph.position.row - first_row ); + if ( glyph.fill ) { + // Expanded tabs are many blank cells but keep a single source range/hit unit. + for ( auto column = std::max( left, glyph.position.column ); + column < std::min( right, glyph.position.column + glyph.columns ); + ++column ) { + frame.glyphs.push_back( + { .position = { row, origin.column + static_cast( column ) }, .text = L" ", .style = glyph.style } ); + } + } else if ( glyph.position.column >= left && glyph.position.column + glyph.columns <= right ) { + frame.glyphs.push_back( { .position = { row, origin.column + static_cast( glyph.position.column ) }, + .width = glyph.columns, + .text = glyph.text, + .style = glyph.style } ); + } + } +} + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/layout.hh b/ricochet/src/tui/layout.hh new file mode 100644 index 0000000..5feb0cd --- /dev/null +++ b/ricochet/src/tui/layout.hh @@ -0,0 +1,98 @@ +/* + * 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 "paint.hh" +#include "ricochet/tui/view.hh" +#include "text.hh" + +#include +#include +#include +#include +#include +#include + +namespace ricochet::tui::detail { + +using HitTarget = std::variant; + +struct LayoutPoint +{ + std::size_t row { 0 }; + std::int64_t column { 0 }; +}; + +struct LayoutHit +{ + DisplayPosition position {}; + DocumentRange unit {}; + std::optional target {}; +}; + +struct LayoutGlyph +{ + LayoutPoint position {}; + int columns { 0 }; + std::wstring text {}; + TextStyle style {}; + DocumentRange source {}; + std::optional target {}; + // Clip bounds are in document-layout columns and also constrain hit testing. + std::int64_t clip_left { 0 }; + std::int64_t clip_right { 0 }; + // Structural boundaries locate anchors but do not displace nearby text hits. + bool boundary_only { false }; + std::int64_t line_start { 0 }; + bool decoration { false }; + bool line_break { false }; + bool fill { false }; +}; + +struct LayoutData +{ + // Reading order, including table cells in row/cell order; not screen order. + std::vector glyphs {}; + std::size_t rows { 1 }; + std::int64_t extent { 0 }; +}; + +// A derived snapshot for one Document + initial control values + width/scrolls. +// Editing will supply a separate draft snapshot; this stage uses revision zero. +// No ncurses headers, terminal objects or business-module dependencies. +class DocumentLayout +{ +public: + DocumentLayout( const Document& document, + const TextService& text, + int columns, + std::span scrolls ); + + int columns() const; + std::size_t rows() const; + std::optional locate( const DisplayPosition& position ) const; + std::optional hit( LayoutPoint point ) const; + void append_frame( Frame& frame, std::size_t first_row, CellPoint origin, ScreenSize viewport ) const; + +private: + int columns_; + LayoutData data_; +}; + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/paint.hh b/ricochet/src/tui/paint.hh new file mode 100644 index 0000000..549718d --- /dev/null +++ b/ricochet/src/tui/paint.hh @@ -0,0 +1,46 @@ +/* + * 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/input.hh" + +#include +#include +#include + +namespace ricochet::tui::detail { + +// Layout supplies one printable spacing character and its combining marks per +// glyph, with a measured width. Raw document text never goes directly to curses. +struct PaintGlyph +{ + CellPoint position {}; + int width { 1 }; + std::wstring text {}; + TextStyle style {}; +}; + +struct Frame +{ + std::vector glyphs {}; + std::optional cursor {}; +}; + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/screen.cc b/ricochet/src/tui/screen.cc index 43582cd..34a8542 100644 --- a/ricochet/src/tui/screen.cc +++ b/ricochet/src/tui/screen.cc @@ -574,6 +574,9 @@ Screen::Screen( std::FILE& input, std::FILE& output, TerminalOptions options ) Screen::~Screen() = default; +std::size_t Screen::glyph_capacity() +{ return CCHARW_MAX - 1; } + ScreenSize Screen::size() const { return impl_->size(); } diff --git a/ricochet/src/tui/screen.hh b/ricochet/src/tui/screen.hh index f0df278..9677eb0 100644 --- a/ricochet/src/tui/screen.hh +++ b/ricochet/src/tui/screen.hh @@ -18,7 +18,7 @@ #pragma once -#include "ricochet/tui/document.hh" +#include "paint.hh" #include "ricochet/tui/session.hh" #include @@ -26,27 +26,10 @@ #include #include #include -#include #include namespace ricochet::tui::detail { -// Layout supplies one printable spacing character and its combining marks per -// glyph, with a measured width. Raw document text never goes directly to curses. -struct PaintGlyph -{ - CellPoint position {}; - int width { 1 }; - std::wstring text {}; - TextStyle style {}; -}; - -struct Frame -{ - std::vector glyphs {}; - std::optional cursor {}; -}; - struct InputBatch { std::vector events {}; @@ -62,6 +45,7 @@ public: Screen& operator=( const Screen& ) = delete; ScreenSize size() const; + static std::size_t glyph_capacity(); TerminalCapabilities capabilities() const; InputBatch read_events( std::size_t limit ); // Wait only polls descriptors; wgetch remains the sole terminal reader. diff --git a/ricochet/src/tui/session.cc b/ricochet/src/tui/session.cc index 38d501e..a596c87 100644 --- a/ricochet/src/tui/session.cc +++ b/ricochet/src/tui/session.cc @@ -18,6 +18,7 @@ #include "ricochet/tui/session.hh" +#include "layout.hh" #include "mailbox.hh" #include "screen.hh" @@ -36,12 +37,13 @@ struct TabState TabInfo info {}; std::optional navigation {}; std::optional document {}; + std::optional layout {}; std::optional view {}; std::optional failure {}; }; // Only fixed ASCII chrome uses this helper. Document text goes through the -// forthcoming Unicode layout service, not a second ad-hoc text renderer. +// Unicode text service and document layout. void append_chrome( detail::Frame& frame, CellPoint start, std::wstring_view text, TextStyle style = {} ) { int column = start.column; @@ -147,6 +149,7 @@ public: tab.info.display_url = update.document.data().metadata.display_url; tab.info.loading_task.reset(); tab.document = std::move( update.document ); + tab.layout.reset(); tab.failure.reset(); dirty_ = true; } @@ -252,6 +255,26 @@ public: return batch.consumed || !batch.events.empty(); } + void draw_document( TabState& tab, detail::Frame& frame, ScreenSize dimensions ) + { + // The cached layout belongs to the committed snapshot and terminal width. + // CommitDocument drops it; resizing rebuilds it from logical view anchors. + if ( !tab.layout || tab.layout->columns() != dimensions.columns ) { + tab.layout.emplace( *tab.document, text_, dimensions.columns, tab.view->horizontal_scroll ); + } + const auto& layout = *tab.layout; + const auto anchor = layout.locate( tab.view->viewport_anchor ).value_or( detail::LayoutPoint {} ); + const auto body_rows = dimensions.rows - 2; + const auto first_row = std::min( anchor.row, layout.rows() - 1 ); + layout.append_frame( frame, first_row, { 1, 0 }, { body_rows, dimensions.columns } ); + const auto cursor = layout.locate( tab.view->cursor ); + if ( cursor && cursor->row >= first_row && cursor->row - first_row < static_cast( body_rows ) + && cursor->column >= 0 && cursor->column < dimensions.columns ) { + frame.cursor + = CellPoint { 1 + static_cast( cursor->row - first_row ), static_cast( cursor->column ) }; + } + } + void draw() { detail::Frame frame; @@ -271,6 +294,9 @@ public: if ( active_ ) { const auto found = tabs_.find( *active_ ); if ( found != tabs_.end() ) { + if ( found->second.document ) { + draw_document( found->second, frame, dimensions ); + } if ( found->second.info.loading_task ) { status = L"Loading | q quit"; } @@ -327,6 +353,7 @@ public: std::shared_ptr mailbox_; std::unique_ptr screen_; TerminalCapabilities capabilities_; + detail::TextService text_ { detail::Screen::glyph_capacity() }; std::map tabs_ {}; std::vector tab_order_ {}; std::optional active_ {}; diff --git a/ricochet/src/tui/text.cc b/ricochet/src/tui/text.cc new file mode 100644 index 0000000..14f0e09 --- /dev/null +++ b/ricochet/src/tui/text.cc @@ -0,0 +1,138 @@ +/* + * 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 "text.hh" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ricochet::tui::detail { +namespace { + +using UnicodeText = std::unique_ptr; +using Breaks = std::unique_ptr; + +Breaks breaks_for( UBreakIteratorType type, UText& text ) +{ + UErrorCode status = U_ZERO_ERROR; + Breaks breaks( ubrk_open( type, "root", nullptr, 0, &status ), &ubrk_close ); + if ( U_FAILURE( status ) ) { + throw std::runtime_error( u_errorName( status ) ); + } + ubrk_setUText( breaks.get(), &text, &status ); + if ( U_FAILURE( status ) ) { + throw std::runtime_error( u_errorName( status ) ); + } + return breaks; +} + +WordKind word_kind( UChar32 codepoint ) +{ + if ( u_isUWhiteSpace( codepoint ) ) { + return WordKind::Space; + } + if ( u_hasBinaryProperty( codepoint, UCHAR_IDEOGRAPHIC ) ) { + return WordKind::Ideograph; + } + if ( codepoint == '_' || u_isalnum( codepoint ) ) { + return WordKind::Word; + } + return WordKind::Punctuation; +} + +} // namespace + +TextService::TextService( std::size_t glyph_capacity ) : glyph_capacity_( glyph_capacity ) {} + +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. + if ( utf8.size() > INT32_MAX ) { + throw std::length_error( "Text exceeds ICU break-iterator offset capacity" ); + } + UErrorCode status = U_ZERO_ERROR; + UnicodeText text( + utext_openUTF8( nullptr, utf8.empty() ? "" : utf8.data(), static_cast( utf8.size() ), &status ), + &utext_close ); + if ( U_FAILURE( status ) ) { + throw std::runtime_error( u_errorName( status ) ); + } + auto characters = breaks_for( UBRK_CHARACTER, *text ); + auto lines = breaks_for( UBRK_LINE, *text ); + TextAnalysis result; + int32_t begin = ubrk_first( characters.get() ); + int32_t line_end = ubrk_next( lines.get() ); + WordKind previous = WordKind::Space; + for ( int32_t end = ubrk_next( characters.get() ); end != UBRK_DONE; + begin = end, end = ubrk_next( characters.get() ) ) { + while ( line_end != UBRK_DONE && line_end < end ) { + line_end = ubrk_next( lines.get() ); + } + Grapheme glyph { .bytes = { static_cast( begin ), static_cast( end ) }, + .break_after = line_end == end }; + utext_setNativeIndex( text.get(), begin ); + const auto first = utext_next32( text.get() ); + glyph.word = word_kind( first ); + const auto source = utf8.substr( glyph.bytes.start, glyph.bytes.end - glyph.bytes.start ); + if ( line_breaks && ( source == "\n" || source == "\r\n" ) ) { + glyph.kind = TextUnitKind::LineBreak; + glyph.columns = 0; + } else if ( tabs && source == "\t" ) { + glyph.kind = TextUnitKind::Tab; + glyph.display = L" "; + } else { + const int width = ::wcwidth( static_cast( first ) ); + glyph.display.push_back( static_cast( first ) ); + bool representable = width > 0 && !u_hasBinaryProperty( first, UCHAR_DEFAULT_IGNORABLE_CODE_POINT ) + && !u_hasBinaryProperty( first, UCHAR_EMOJI_PRESENTATION ); + while ( utext_getNativeIndex( text.get() ) < end ) { + const auto codepoint = utext_next32( text.get() ); + glyph.display.push_back( static_cast( codepoint ) ); + // ncurses accepts one spacing character followed by nonspacing marks. + // Joiners, selectors and extra spacing characters use one visible fallback. + representable = representable && ::wcwidth( static_cast( codepoint ) ) == 0 + && u_charType( codepoint ) == U_NON_SPACING_MARK + && !u_hasBinaryProperty( codepoint, UCHAR_DEFAULT_IGNORABLE_CODE_POINT ); + } + if ( !representable || glyph.display.size() > glyph_capacity_ ) { + glyph.display = L"?"; + glyph.columns = 1; + } else { + glyph.columns = width; + } + } + if ( glyph.word != WordKind::Space ) { + if ( glyph.word == previous && glyph.word != WordKind::Ideograph ) { + result.words.back().end = glyph.bytes.end; + } else { + result.words.push_back( glyph.bytes ); + } + } + previous = glyph.word; + result.graphemes.push_back( std::move( glyph ) ); + } + return result; +} + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/text.hh b/ricochet/src/tui/text.hh new file mode 100644 index 0000000..c2d4af9 --- /dev/null +++ b/ricochet/src/tui/text.hh @@ -0,0 +1,75 @@ +/* + * 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/position.hh" + +#include +#include +#include +#include +#include + +namespace ricochet::tui::detail { + +enum class TextUnitKind : std::uint8_t +{ + Glyph, + LineBreak, + Tab +}; +enum class WordKind : std::uint8_t +{ + Space, + Word, + Ideograph, + Punctuation +}; + +struct Grapheme +{ + ByteRange bytes {}; + std::wstring display {}; + int columns { 1 }; + TextUnitKind kind { TextUnitKind::Glyph }; + WordKind word { WordKind::Punctuation }; + bool break_after { false }; +}; + +struct TextAnalysis +{ + std::vector graphemes {}; + // Vim-style reading units: letters/numbers/underscore, punctuation runs, + // individual ideographs. Whitespace is excluded; offsets remain UTF-8 bytes. + std::vector words {}; +}; + +// Called on the UI thread in the same UTF-8 locale as Screen. ICU owns Unicode +// segmentation/properties; POSIX wcwidth owns terminal widths. No curses types. +class TextService +{ +public: + explicit TextService( std::size_t glyph_capacity ); + TextAnalysis analyze( std::string_view utf8, bool line_breaks, bool tabs ) const; + +private: + std::size_t glyph_capacity_; +}; + +} // namespace ricochet::tui::detail -- cgit v1.2.3 From bdefdb301db843e87aee8c6c35808d295bb66ee5 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 26 Oct 2026 23:25:13 +0800 Subject: feat(tui): integrate address input and navigation requests --- ricochet/src/tui/screen.cc | 13 ++- ricochet/src/tui/screen.hh | 2 +- ricochet/src/tui/session.cc | 220 ++++++++++++++++++++++++++++++++++++++++++-- ricochet/src/tui/text.cc | 2 + ricochet/src/tui/text.hh | 2 + 5 files changed, 228 insertions(+), 11 deletions(-) (limited to 'ricochet/src/tui/screen.hh') diff --git a/ricochet/src/tui/screen.cc b/ricochet/src/tui/screen.cc index 99d4e36..2bb9c85 100644 --- a/ricochet/src/tui/screen.cc +++ b/ricochet/src/tui/screen.cc @@ -513,7 +513,7 @@ public: return std::nullopt; } - void draw( const Frame& frame ) const + void draw( const Frame& frame, bool redraw ) const { const auto dimensions = size(); if ( werase( stdscr ) == ERR ) { @@ -550,7 +550,12 @@ public: } else { curs_set( 0 ); } - if ( wnoutrefresh( stdscr ) == ERR || doupdate() == ERR ) { + // An explicit Ctrl+L repairs the physical screen, not just our virtual frame. + // clearok is consumed by wrefresh; ordinary frames retain batched updates. + if ( redraw ) { + clearok( stdscr, TRUE ); + } + if ( redraw ? wrefresh( stdscr ) == ERR : ( wnoutrefresh( stdscr ) == ERR || doupdate() == ERR ) ) { throw std::runtime_error( "ncurses screen refresh failed" ); } } @@ -590,7 +595,7 @@ InputBatch Screen::read_events( std::size_t limit ) std::optional Screen::wait( int wake_fd, std::chrono::milliseconds max_wait ) { return impl_->wait( wake_fd, max_wait ); } -void Screen::draw( const Frame& frame ) -{ impl_->draw( frame ); } +void Screen::draw( const Frame& frame, bool redraw ) +{ impl_->draw( frame, redraw ); } } // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/screen.hh b/ricochet/src/tui/screen.hh index 9677eb0..0ff1c58 100644 --- a/ricochet/src/tui/screen.hh +++ b/ricochet/src/tui/screen.hh @@ -50,7 +50,7 @@ public: InputBatch read_events( std::size_t limit ); // Wait only polls descriptors; wgetch remains the sole terminal reader. std::optional wait( int wake_fd, std::chrono::milliseconds max_wait ); - void draw( const Frame& frame ); + void draw( const Frame& frame, bool redraw = false ); private: class Impl; diff --git a/ricochet/src/tui/session.cc b/ricochet/src/tui/session.cc index f54d382..09af2ce 100644 --- a/ricochet/src/tui/session.cc +++ b/ricochet/src/tui/session.cc @@ -19,6 +19,7 @@ #include "ricochet/tui/session.hh" #include "document_view.hh" +#include "editor.hh" #include "mailbox.hh" #include "screen.hh" @@ -51,6 +52,35 @@ struct TabState std::string copy_feedback {}; }; +enum class AddressEntry : std::uint8_t +{ + Empty, + Current, + Selected +}; + +int address_label_columns( ScreenSize dimensions ) +{ return std::min( 5, std::max( 0, dimensions.columns - 1 ) ); } + +struct AddressInput +{ + TabId tab; + std::optional restore; + detail::TextEditor editor; + bool dragging { false }; + std::wstring notice {}; + + AddressInput( TabId source, + std::optional snapshot, + std::string value, + const detail::TextService& text, + ScreenSize dimensions ) + : tab( source ) + , restore( std::move( snapshot ) ) + , editor( std::move( value ), {}, text, { 1, dimensions.columns - address_label_columns( dimensions ) } ) + {} +}; + // Only fixed ASCII chrome uses this helper. Document text goes through the // Unicode text service and document layout. void append_chrome( detail::Frame& frame, CellPoint start, std::wstring_view text, TextStyle style = {} ) @@ -78,6 +108,7 @@ public: void close() noexcept { mailbox_->close(); + address_.reset(); screen_.reset(); } @@ -103,6 +134,12 @@ public: void apply( SyncTabs update, StepResult& ) { + // Cancel against the old tab map before a switch/removal destroys its view. + if ( address_ + && ( update.active != address_->tab + || std::ranges::find( update.tabs, address_->tab, &TabInfo::id ) == update.tabs.end() ) ) { + finish_address(); + } if ( active_ != update.active ) { stop_drag(); } @@ -170,6 +207,11 @@ public: std::move( snapshot ), text_, ScreenSize { dimensions.rows - 2, dimensions.columns } ); + if ( address_ && address_->tab == update.tab ) { + // Address editing survives a background commit, but cancellation must now + // return to the newly committed page, never restore the old view snapshot. + address_->restore.reset(); + } if ( active_ == update.tab ) { prefix_.reset(); notice_.clear(); @@ -405,6 +447,144 @@ public: dirty_ = true; } + void open_address( AddressEntry entry ) + { + const auto found = active_ ? tabs_.find( *active_ ) : tabs_.end(); + if ( found == tabs_.end() ) { + notice_ = L"No active tab"; + dirty_ = true; + return; + } + auto& tab = found->second; + if ( tab.view && tab.view->snapshot().selection ) { + return; // Address shortcuts belong to Browse, not the document selection mode. + } + stop_drag(); + const auto snapshot = tab.view ? std::optional( tab.view->snapshot() ) : std::nullopt; + address_.emplace( + tab.info.id, snapshot, entry == AddressEntry::Empty ? "" : tab.info.display_url, text_, screen_->size() ); + if ( entry == AddressEntry::Selected ) { + address_->editor.execute( detail::EditorCommand::SelectAll ); + } + prefix_.reset(); + notice_.clear(); + dirty_ = true; + } + + void finish_address() + { + auto& tab = tabs_.at( address_->tab ); + const auto& restore = address_->restore; + if ( restore && tab.view && tab.view->snapshot().scope == restore->scope + && tab.view->snapshot().draft_revision == restore->draft_revision ) { + // Copy the shared document before emplace destroys the current view. + auto document = tab.view->document(); + const auto dimensions = screen_->size(); + tab.view.emplace( + std::move( document ), *restore, text_, ScreenSize { dimensions.rows - 2, dimensions.columns } ); + } + address_.reset(); + notice_.clear(); + dirty_ = true; + } + + void submit_address() + { + const auto& value = address_->editor.value(); + if ( text_.analyze( value, false, false ).whitespace_only ) { + address_->notice = L"Enter an address | Esc cancel"; + dirty_ = true; + return; + } + const RequestId request { next_request_++ }; + actions_.emplace_back( OpenLocation { request, address_->tab, value, OpenTarget::CurrentTab } ); + auto& tab = tabs_.at( address_->tab ); + // A host rejection may precede NavigationStarted, just as for OpenLink. + tab.navigation = request; + tab.failure.reset(); + tab.copy_feedback.clear(); + finish_address(); + } + + void address_mouse( const Mouse& mouse ) + { + const auto dimensions = screen_->size(); + const auto label = address_label_columns( dimensions ); + const CellPoint point { 0, mouse.position.column - label }; + if ( address_->dragging ) { + if ( mouse.released.left || mouse.motion || mouse.pressed.left ) { + address_->editor.place( point, true ); + address_->dragging = !mouse.released.left; + dirty_ = true; + } + return; + } + if ( mouse.pressed.left && mouse.position.row == 0 && mouse.position.column >= label + && mouse.position.column < dimensions.columns + && ( mouse.modifiers == Modifiers {} || mouse.modifiers == Modifiers { .shift = true } ) ) { + address_->editor.place( point, mouse.modifiers.shift ); + address_->dragging = true; + dirty_ = true; + } + // Clicks and wheel events outside the input never reach the document underneath. + } + + void address_input( const InputEvent& event ) + { + if ( const auto* mouse = std::get_if( &event ) ) { + address_mouse( *mouse ); + return; + } + const auto prefix = address_->editor.prefix_deadline(); + const auto intent = address_->editor.handle( event ); + if ( std::holds_alternative( event ) ) { + dirty_ = dirty_ || prefix != address_->editor.prefix_deadline(); + return; + } + address_->dragging = false; + address_->notice.clear(); + dirty_ = true; + switch ( intent ) { + case detail::EditorIntent::Enter: + submit_address(); + break; + case detail::EditorIntent::Escape: + case detail::EditorIntent::Cancel: + finish_address(); + break; + case detail::EditorIntent::Redraw: + redraw_ = true; + break; + case detail::EditorIntent::Menu: + address_->notice = L"Menu unavailable | Enter open | Esc cancel"; + break; + default: + break; // Tab/history/unbound keys have no second recipient in this mode. + } + } + + void draw_address( detail::Frame& frame, ScreenSize dimensions ) + { + const auto label = address_label_columns( dimensions ); + append_chrome( frame, { 0, 0 }, std::wstring_view( L"URL: " ).substr( 0, label ), { .strong = true } ); + address_->editor.resize( { 1, dimensions.columns - label } ); + address_->editor.draw( frame, { 0, label } ); + if ( dimensions.rows > 1 ) { + append_chrome( frame, { dimensions.rows - 1, 0 }, std::wstring( dimensions.columns, L' ' ) ); + std::wstring_view status + = capabilities_.bracketed_paste + ? L"Address | Enter open | Esc cancel" + : L"Enter open | Esc cancel | Paste boundaries unavailable; multiline paste may confirm"; + if ( !address_->notice.empty() ) { + status = address_->notice; + } + if ( address_->editor.prefix_deadline() ) { + status = L"Ctrl+X ... | Ctrl+U undo | Esc cancel prefix"; + } + append_chrome( frame, { dimensions.rows - 1, 0 }, status ); + } + } + void expire_prefix() { if ( prefix_ && Clock::now() >= prefix_->deadline ) { @@ -430,6 +610,12 @@ public: return; } switch ( character ) { + case U'o': + open_address( AddressEntry::Empty ); + break; + case U'O': + open_address( AddressEntry::Current ); + break; case U'h': move( Motion::Left ); break; @@ -517,6 +703,9 @@ public: } if ( key.modifiers == Modifiers { .control = true } && key.code == KeyCode::Character ) { switch ( key.character ) { + case U'l': + open_address( AddressEntry::Selected ); + break; case U'c': if ( auto* view = active_view(); view && view->snapshot().selection ) { view->cancel_selection(); @@ -573,6 +762,11 @@ public: return; // Modified keys have distinct bindings; do not alias them to arrows. } switch ( key.code ) { + case KeyCode::Function: + if ( key.function == 6 ) { + open_address( AddressEntry::Selected ); + } + break; case KeyCode::Enter: activate_link(); break; @@ -650,7 +844,14 @@ public: close(); } else if ( std::holds_alternative( event ) ) { stop_drag(); + if ( address_ ) { + address_->dragging = false; + const auto dimensions = screen_->size(); + address_->editor.resize( { 1, dimensions.columns - address_label_columns( dimensions ) } ); + } dirty_ = true; + } else if ( address_ ) { + address_input( event ); } else if ( const auto* tick = std::get_if( &event ) ) { if ( auto* view = active_view() ) { dirty_ = view->tick( tick->now ) || dirty_; @@ -746,7 +947,10 @@ public: append_chrome( frame, { dimensions.rows - 1, 0 }, status ); } } - screen_->draw( frame ); + if ( address_ ) { + draw_address( frame, dimensions ); + } + screen_->draw( frame, std::exchange( redraw_, false ) ); dirty_ = false; } @@ -765,11 +969,13 @@ public: const bool input = read_input( result ); if ( screen_ && received == 0 && !input && !dirty_ ) { auto wait = max_wait; - if ( prefix_ ) { - wait = std::min( - wait, - std::max( std::chrono::milliseconds::zero(), - std::chrono::ceil( prefix_->deadline - Clock::now() ) ) ); + const auto deadline = address_ ? address_->editor.prefix_deadline() + : prefix_ ? std::optional( prefix_->deadline ) + : std::nullopt; + if ( deadline ) { + wait = std::min( wait, + std::max( std::chrono::milliseconds::zero(), + std::chrono::ceil( *deadline - Clock::now() ) ) ); } const auto closed = screen_->wait( mailbox_->descriptor(), wait ); if ( closed ) { @@ -807,10 +1013,12 @@ public: std::optional active_ {}; std::optional exit_request_ {}; std::optional prefix_ {}; + std::optional address_ {}; std::uint64_t next_request_ { 1 }; std::vector actions_ {}; std::wstring notice_ {}; bool dirty_ { true }; + bool redraw_ { false }; }; UpdateSender::UpdateSender( std::shared_ptr mailbox ) : mailbox_( std::move( mailbox ) ) {} diff --git a/ricochet/src/tui/text.cc b/ricochet/src/tui/text.cc index 95b324b..ba24af2 100644 --- a/ricochet/src/tui/text.cc +++ b/ricochet/src/tui/text.cc @@ -113,6 +113,7 @@ TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool .break_after = line_end == end }; utext_setNativeIndex( text.get(), begin ); const auto first = utext_next32( text.get() ); + result.whitespace_only = result.whitespace_only && u_isUWhiteSpace( first ); glyph.word = word_kind( first ); const auto source = utf8.substr( glyph.bytes.start, glyph.bytes.end - glyph.bytes.start ); if ( line_breaks && ( source == "\n" || source == "\r\n" ) ) { @@ -128,6 +129,7 @@ TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool && !u_hasBinaryProperty( first, UCHAR_EMOJI_PRESENTATION ); while ( utext_getNativeIndex( text.get() ) < end ) { const auto codepoint = utext_next32( text.get() ); + result.whitespace_only = result.whitespace_only && u_isUWhiteSpace( codepoint ); glyph.display.push_back( static_cast( codepoint ) ); // ncurses accepts one spacing character followed by nonspacing marks. // Joiners, selectors and extra spacing characters use one visible fallback. diff --git a/ricochet/src/tui/text.hh b/ricochet/src/tui/text.hh index eb0a2bb..99fd7c9 100644 --- a/ricochet/src/tui/text.hh +++ b/ricochet/src/tui/text.hh @@ -57,6 +57,8 @@ struct Grapheme struct TextAnalysis { std::vector graphemes {}; + // All codepoints, including combining marks; an empty input is whitespace-only. + bool whitespace_only { true }; // Vim-style reading units: letters/numbers/underscore, punctuation runs, // individual ideographs. Whitespace is excluded; offsets remain UTF-8 bytes. std::vector words {}; -- cgit v1.2.3