diff --git a/README.md b/README.md index 2850eae..8fc6848 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ details. ## Dependencies -This project depends on the [dius](https://github.com/coletrammer/dius) library, to use its cross-platform -abstractions. +- [fzf](https://github.com/junegunn/fzf) program, for various popup memus +- [dius](https://github.com/coletrammer/dius) library, to use its cross-platform + abstractions. ## Installing diff --git a/docs/pages/build.md b/docs/pages/build.md index f7bdde5..64f66f9 100644 --- a/docs/pages/build.md +++ b/docs/pages/build.md @@ -11,6 +11,8 @@ This package can be built either directly through [CMake](https://cmake.org/) or ### Dependencies +- The [fzf](https://github.com/junegunn/fzf) program. This is used at runtime for various menus. To be clear, this + dependency is only needed for the `ttx` application and not the terminal emulation library itself. - The [dius](https://github.com/coletrammer/dius) library. - The [di](https://github.com/coletrammer/di) library (dependency of dius). @@ -66,6 +68,9 @@ Afterwards, use the library via `target_link_libraries(target PRIVATE ttx::ttx)` ### Note to packagers +Any `ttx` package should depend on `fzf` as `ttx` requires the `fzf` program to be +in the user's PATH on application startup. + The `CMAKE_INSTALL_INCLUDEDIR` is set to a path other than just `include` if the project is configured as a top level project to avoid indirectly including other libraries when installed to a common prefix. Please review the @@ -98,7 +103,9 @@ ttx = { Then include `inputs.ttx.packages.${system}.ttx-lib` in the `buildInputs` of your derivation. Assuming your project is using CMake, `find_package(ttx)` will succeed automatically. -This flake provides takes the di and dius libraries as a flake input, so it can be overridden easily. +This flake provides takes the di and dius libraries as a flake input, so it can be overridden easily. The +`fzf` dependency is taken from nixpkgs (which is also easy to override), and the `ttx` binary is wrapped +by nix to ensure `fzf` will be in the `PATH` variable when running `ttx`. ### Manual Build Commands diff --git a/docs/pages/install.md b/docs/pages/install.md index 20b7fca..d2f577d 100644 --- a/docs/pages/install.md +++ b/docs/pages/install.md @@ -1,8 +1,9 @@ # Install The easiest way to install ttx is using [nix](https://nixos). As of now, the only alternative is compiling from source. -In the future, a statically linked binary will available via a GitHub release. To build ttx from source, -see the steps [here](./build.md). +In the future, a statically linked binary will available via a GitHub release. However the user will +still need to install `fzf` to make use to the application. To build ttx from source, see the steps +[here](./build.md). ## Installing with Nix diff --git a/flake.lock b/flake.lock index 97d0825..e5eaeef 100644 --- a/flake.lock +++ b/flake.lock @@ -6,11 +6,11 @@ "nixpkgs": ["nixpkgs"] }, "locked": { - "lastModified": 1744089186, - "narHash": "sha256-0/044okI7No2Go5iZjLAHq06iyAU95mIyv5+jWC3WBU=", + "lastModified": 1744611131, + "narHash": "sha256-LtHtOq1WBQM7i/6OlP+9yhteDwd2fHDuxlCFLwbl1kI=", "owner": "coletrammer", "repo": "di", - "rev": "a75de4c5c211f33cf864b3a62a45aab30ee1710a", + "rev": "451f3d9329274d831e78b2e63b5fa12d36b40c1a", "type": "github" }, "original": { @@ -26,11 +26,11 @@ "nixpkgs": ["nixpkgs"] }, "locked": { - "lastModified": 1742167628, - "narHash": "sha256-9sekISvEV3P+PJVaH9thzP7WvNC5aiCwyR8HqYiYS9w=", + "lastModified": 1744777067, + "narHash": "sha256-9Aa7o+jsMY5iO5rKcVMArnXSErt8GwnsWz97luwinKk=", "owner": "coletrammer", "repo": "dius", - "rev": "b0a5d50e67700bd3b6cb1819a7c242dfad45632f", + "rev": "b113535e8bc0e0707686b4563926f48826746628", "type": "github" }, "original": { diff --git a/justfile b/justfile index c54612c..619974f 100644 --- a/justfile +++ b/justfile @@ -131,7 +131,7 @@ tidy *args="": ensure_configured #!/usr/bin/env bash set -euo pipefail - export ttx_TIDY_ARGS="{{ args }}" + export TTX_TIDY_ARGS="{{ args }}" cmake --build --preset {{ preset }} -t tidy # Run static analysis @@ -139,7 +139,7 @@ analyze *args="": ensure_configured #!/usr/bin/env bash set -euo pipefail - export ttx_TIDY_ARGS="{{ args }}" + export TTX_TIDY_ARGS="{{ args }}" cmake --build --preset {{ preset }} -t analyze # Run clang-tidy and output failures @@ -147,7 +147,7 @@ check_tidy *args="": ensure_configured #!/usr/bin/env bash set -euo pipefail - export ttx_TIDY_ARGS="{{ args }}" + export TTX_TIDY_ARGS="{{ args }}" cmake --build --preset {{ preset }} -t check_tidy # Clean diff --git a/lib/include/ttx/pane.h b/lib/include/ttx/pane.h index 6255e87..642ea05 100644 --- a/lib/include/ttx/pane.h +++ b/lib/include/ttx/pane.h @@ -19,39 +19,51 @@ #include "ttx/terminal.h" namespace ttx { +class Pane; + +struct PaneHooks { + /// @brief Application controlled callback when the internal process exits. + di::Function did_exit; + + /// @brief controlled callback when the terminal buffer has updated. + di::Function did_update; + + /// @brief Application controlled callback when text is selected. + di::Function)> did_selection; + + /// @brief Application controlled callback when APC command is set. + di::Function apc_passthrough; + + /// @brief Callback with the results on reading from the output pipe. + di::Function did_finish_output; +}; + struct CreatePaneArgs { - di::Vector command {}; + di::Vector command {}; di::Optional capture_command_output_path {}; di::Optional replay_path {}; di::Optional save_state_path {}; + di::Optional pipe_input {}; + bool pipe_output { false }; + PaneHooks hooks {}; }; class Pane { public: static auto create_from_replay(u64 id, di::PathView replay_path, di::Optional save_state_path, - Size const& size, di::Function did_exit, - di::Function did_update, - di::Function)> did_selection, - di::Function apc_passthrough) -> di::Result>; - static auto create(u64 id, CreatePaneArgs args, Size const& size, di::Function did_exit, - di::Function did_update, di::Function)> did_selection, - di::Function apc_passthrough) -> di::Result>; + Size const& size, PaneHooks hooks) -> di::Result>; + static auto create(u64 id, CreatePaneArgs args, Size const& size) -> di::Result>; // For testing, create a mock pane. This doesn't actually create a psuedo terminal or a subprocess. static auto create_mock() -> di::Box; explicit Pane(u64 id, dius::SyncFile pty_controller, Size const& size, dius::system::ProcessHandle process, - di::Function did_exit, di::Function did_update, - di::Function)> did_selection, - di::Function apc_passthrough) + PaneHooks hooks) : m_id(id) , m_pty_controller(di::move(pty_controller)) , m_terminal(di::in_place, id, m_pty_controller, size) , m_process(process) - , m_did_exit(di::move(did_exit)) - , m_did_update(di::move(did_update)) - , m_did_selection(di::move(did_selection)) - , m_apc_passthrough(di::move(apc_passthrough)) {} + , m_hooks(di::move(hooks)) {} ~Pane(); auto id() const { return m_id; } @@ -67,6 +79,7 @@ class Pane { void scroll(Direction direction, i32 amount_in_cells); auto save_state(di::PathView path) -> di::Result<>; void stop_capture(); + void soft_reset(); void exit(); private: @@ -83,20 +96,12 @@ class Pane { u32 m_vertical_scroll_offset { 0 }; u32 m_horizontal_scroll_offset { 0 }; - // Application controlled callback when the internal process exits. - di::Function m_did_exit; - - // Application controlled callback when the terminal buffer has updated. - di::Function m_did_update; - - // Application controlled callback when text is selected. - di::Function)> m_did_selection; - - // Application controlled callback when APC command is set. - di::Function m_apc_passthrough; + PaneHooks m_hooks; // These are declared last, for when dius::Thread calls join() in the destructor. dius::Thread m_process_thread; dius::Thread m_reader_thread; + dius::Thread m_pipe_writer_thread; + dius::Thread m_pipe_reader_thread; }; } diff --git a/lib/include/ttx/popup.h b/lib/include/ttx/popup.h new file mode 100644 index 0000000..21745e6 --- /dev/null +++ b/lib/include/ttx/popup.h @@ -0,0 +1,41 @@ +#pragma once + +#include "ttx/layout.h" +#include "ttx/size.h" + +namespace ttx { +enum class PopupAlignment { + Left, + Right, + Top, + Bottom, + Center, +}; + +namespace detail { + struct RelatizeSizeTag { + using Type = i64; + }; + struct AbsoluteSizeTag { + using Type = u32; + }; +} + +using RelatizeSize = di::StrongInt; +using AbsoluteSize = di::StrongInt; + +using PopupSize = di::Variant; + +struct PopupLayout { + PopupAlignment alignment { PopupAlignment::Center }; + PopupSize width { RelatizeSize(max_layout_precision / 2) }; // 50% width default + PopupSize height { RelatizeSize(max_layout_precision / 2) }; // 50% height default +}; + +struct Popup { + di::Box pane {}; + PopupLayout layout_config; + + auto layout(Size const& size) -> LayoutEntry; +}; +} diff --git a/lib/include/ttx/renderer.h b/lib/include/ttx/renderer.h index 8b23334..f0cc0c7 100644 --- a/lib/include/ttx/renderer.h +++ b/lib/include/ttx/renderer.h @@ -18,6 +18,9 @@ struct RenderedCursor { class Renderer { public: + auto setup(dius::SyncFile& output) -> di::Result<>; + auto cleanup(dius::SyncFile& output) -> di::Result<>; + void start(Size const& size); auto finish(dius::SyncFile& output, RenderedCursor const& cursor) -> di::Result<>; @@ -42,6 +45,8 @@ class Renderer { di::VectorWriter<> m_buffer; Size m_size; + di::Vector m_cleanup; + GraphicsRendition m_last_graphics_rendition; di::Optional m_last_hyperlink; u32 m_last_cursor_row { 0 }; diff --git a/lib/include/ttx/terminal.h b/lib/include/ttx/terminal.h index 6bc2cc5..8d83331 100644 --- a/lib/include/ttx/terminal.h +++ b/lib/include/ttx/terminal.h @@ -28,6 +28,11 @@ class Terminal { terminal::Screen screen; di::Optional saved_cursor; CursorStyle cursor_style { CursorStyle::SteadyBar }; + + // Per https://sw.kovidgoyal.net/kitty/keyboard-protocol/#detection-of-support-for-this-protocol, + // the keyboard mode stack and flags are per-screen. + KeyReportingFlags m_key_reporting_flags { KeyReportingFlags::None }; + di::Ring m_key_reporting_flags_stack; }; public: @@ -64,7 +69,7 @@ class Terminal { auto visible_size() const -> Size { return m_available_size; } auto application_cursor_keys_mode() const -> ApplicationCursorKeysMode { return m_application_cursor_keys_mode; } - auto key_reporting_flags() const -> KeyReportingFlags { return m_key_reporting_flags; } + auto key_reporting_flags() const -> KeyReportingFlags { return active_screen().m_key_reporting_flags; } auto alternate_scroll_mode() const -> AlternateScrollMode { return m_alternate_scroll_mode; } auto mouse_protocol() const -> MouseProtocol { return m_mouse_protocol; } @@ -76,6 +81,8 @@ class Terminal { auto bracked_paste_mode() const -> BracketedPasteMode { return m_bracketed_paste_mode; } + void soft_reset(); + void invalidate_all(); auto outgoing_events() -> di::Vector { return di::move(m_outgoing_events); } @@ -160,6 +167,7 @@ class Terminal { void csi_decstbm(Params const& params); void csi_scosc(Params const& params); void csi_scorc(Params const& params); + void csi_decstr(Params const& params); void csi_xtwinops(Params const& params); void csi_set_key_reporting_flags(Params const& params); @@ -189,8 +197,6 @@ class Terminal { di::Optional m_last_graphics_charcter { 0 }; ApplicationCursorKeysMode m_application_cursor_keys_mode { ApplicationCursorKeysMode::Disabled }; - KeyReportingFlags m_key_reporting_flags { KeyReportingFlags::None }; - di::Ring m_key_reporting_flags_stack; AlternateScrollMode m_alternate_scroll_mode { AlternateScrollMode::Disabled }; MouseProtocol m_mouse_protocol { MouseProtocol::None }; diff --git a/lib/include/ttx/terminal/screen.h b/lib/include/ttx/terminal/screen.h index d225507..0fdba17 100644 --- a/lib/include/ttx/terminal/screen.h +++ b/lib/include/ttx/terminal/screen.h @@ -183,7 +183,7 @@ class Screen { // Screen state. RowGroup m_active_rows; - bool m_whole_screen_dirty { false }; + bool m_whole_screen_dirty { true }; // Scroll back ScrollBack m_scroll_back; diff --git a/lib/src/pane.cpp b/lib/src/pane.cpp index f10ebc7..afc1542 100644 --- a/lib/src/pane.cpp +++ b/lib/src/pane.cpp @@ -18,42 +18,43 @@ #include "ttx/utf8_stream_decoder.h" namespace ttx { -static auto spawn_child(di::Vector command, dius::SyncFile& pty, Size const& size) - -> di::Result { +static auto spawn_child(di::Vector command, dius::SyncFile& pty, Size const& size, i32 stdin_fd, + i32 stdout_fd, di::Vector const& close_fds) -> di::Result { auto tty_path = TRY(pty.get_psuedo_terminal_path()); #ifdef __linux__ // On linux, we can set the terminal size in on the controlling pty. On MacOS, we need to do it in // the child. Doing so requires using fork() instead of posix_spawn() when spawning the process, so - // we try to avoid it. Additionally, opening the psudeo terminal implicitly will make it the controlling - // terminal, so there's no need to call ioctl(TIOCSCTTY). + // we try to avoid it. Additionally, opening the psudeo terminal on linux implicitly will make it + // the controlling terminal, so there's no need to call ioctl(TIOCSCTTY). TRY(pty.set_tty_window_size(size.as_window_size())); #endif - return dius::system::Process(command | di::transform(di::to_owned) | di::to()) - .with_new_session() - .with_env("TERM"_ts, "xterm-256color"_ts) - .with_env("COLORTERM"_ts, "truecolor"_ts) - .with_file_open(0, di::move(tty_path), dius::OpenMode::ReadWrite) - .with_file_dup(0, 1) - .with_file_dup(0, 2) + auto result = dius::system::Process(di::move(command)) + .with_new_session() + .with_env("TERM"_ts, "xterm-256color"_ts) + .with_env("COLORTERM"_ts, "truecolor"_ts) + .with_file_open(2, di::move(tty_path), dius::OpenMode::ReadWrite) + .with_file_dup(stdin_fd, 0) + .with_file_dup(stdout_fd, 1) #ifndef __linux__ - .with_tty_window_size(0, size.as_window_size()) - .with_controlling_tty(0) + .with_tty_window_size(2, size.as_window_size()) + .with_controlling_tty(2) #endif - .spawn(); + ; + for (auto close_fd : close_fds) { + result = di::move(result).with_file_close(close_fd); + } + + return di::move(result).spawn(); } auto Pane::create_from_replay(u64 id, di::PathView replay_path, di::Optional save_state_path, - Size const& size, di::Function did_exit, - di::Function did_update, - di::Function)> did_selection, - di::Function apc_passthrough) -> di::Result> { + Size const& size, PaneHooks hooks) -> di::Result> { auto replay_file = TRY(dius::open_sync(replay_path, dius::OpenMode::Readonly)); // When replaying content, there is no need for any threads, a psudeo terminal or a sub-process. - auto pane = di::make_box(id, dius::SyncFile(), size, dius::system::ProcessHandle(), di::move(did_exit), - di::move(did_update), di::move(did_selection), di::move(apc_passthrough)); + auto pane = di::make_box(id, dius::SyncFile(), size, dius::system::ProcessHandle(), di::move(hooks)); // Allow the terminal to use CSI 8; height; width; t. Normally this would be ignored since application // shouldn't control this. @@ -77,8 +78,8 @@ auto Pane::create_from_replay(u64 id, di::PathView replay_path, di::Optional(event)) { - if (pane->m_apc_passthrough) { - pane->m_apc_passthrough(ev->data); + if (pane->m_hooks.apc_passthrough) { + pane->m_hooks.apc_passthrough(ev->data); } return true; } @@ -92,8 +93,8 @@ auto Pane::create_from_replay(u64 id, di::PathView replay_path, di::Optionalm_did_selection) { - pane->m_did_selection(ev.data.span()); + if (pane->m_hooks.did_selection) { + pane->m_hooks.did_selection(ev.data.span()); } }), di::move(event)); @@ -112,12 +113,9 @@ auto Pane::create_from_replay(u64 id, di::PathView replay_path, di::Optional did_exit, - di::Function did_update, di::Function)> did_selection, - di::Function apc_passthrough) -> di::Result> { +auto Pane::create(u64 id, CreatePaneArgs args, Size const& size) -> di::Result> { if (args.replay_path) { - return create_from_replay(id, *args.replay_path, di::move(args.save_state_path), size, di::move(did_exit), - di::move(did_update), di::move(did_selection), di::move(apc_passthrough)); + return create_from_replay(id, *args.replay_path, di::move(args.save_state_path), size, di::move(args.hooks)); } auto capture_file = di::Optional {}; @@ -130,16 +128,35 @@ auto Pane::create(u64 id, CreatePaneArgs args, Size const& size, di::Function(id, di::move(pty_controller), size, process, di::move(did_exit), - di::move(did_update), di::move(did_selection), di::move(apc_passthrough)); + + // This logic allows piping input and output from the shell command. This ends up being very complicated... + auto stdin_fd = 2; + auto stdout_fd = 2; + auto close_fds = di::Vector {}; + auto write_pipes = di::Optional> {}; + auto read_pipes = di::Optional> {}; + if (args.pipe_input) { + write_pipes = TRY(dius::open_pipe(dius::OpenFlags::KeepAfterExec)); + stdin_fd = di::get<0>(write_pipes.value()).file_descriptor(); + close_fds.push_back(di::get<0>(write_pipes.value()).file_descriptor()); + close_fds.push_back(di::get<1>(write_pipes.value()).file_descriptor()); + } + if (args.pipe_output) { + read_pipes = TRY(dius::open_pipe(dius::OpenFlags::KeepAfterExec)); + stdout_fd = di::get<1>(read_pipes.value()).file_descriptor(); + close_fds.push_back(di::get<0>(read_pipes.value()).file_descriptor()); + close_fds.push_back(di::get<1>(read_pipes.value()).file_descriptor()); + } + + auto process = TRY(spawn_child(di::move(args.command), pty_controller, size, stdin_fd, stdout_fd, close_fds)); + auto pane = di::make_box(id, di::move(pty_controller), size, process, di::move(args.hooks)); pane->m_process_thread = TRY(dius::Thread::create([&pane = *pane] mutable { auto guard = di::ScopeExit([&] { pane.m_done.store(true, di::MemoryOrder::Release); - if (pane.m_did_exit) { - pane.m_did_exit(pane); + if (pane.m_hooks.did_exit) { + pane.m_hooks.did_exit(pane); } }); @@ -173,8 +190,8 @@ auto Pane::create(u64 id, CreatePaneArgs args, Size const& size, di::Function(event)) { - if (pane.m_apc_passthrough) { - pane.m_apc_passthrough(ev->data); + if (pane.m_hooks.apc_passthrough) { + pane.m_hooks.apc_passthrough(ev->data); } return true; } @@ -188,31 +205,72 @@ auto Pane::create(u64 id, CreatePaneArgs args, Size const& size, di::Functionm_pipe_writer_thread = TRY(dius::Thread::create( + [&pane = *pane, pipe = di::move(write_pipes).value(), input = di::move(args.pipe_input).value()] mutable { + auto& [read, write] = pipe; + (void) read.close(); + (void) write.write_exactly(di::as_bytes(input.span())); + (void) write.close(); + })); + } + if (args.pipe_output) { + pane->m_pipe_reader_thread = + TRY(dius::Thread::create([&pane = *pane, pipe = di::move(read_pipes).value()] mutable { + auto& [read, write] = pipe; + (void) write.close(); + + auto utf8_decoder = Utf8StreamDecoder {}; + + auto buffer = di::Vector {}; + buffer.resize(16384); + + auto contents = di::String {}; + while (!pane.m_done.load(di::MemoryOrder::Acquire)) { + auto nread = read.read_some(buffer.span()); + if (!nread.has_value()) { + break; + } + + auto utf8_string = utf8_decoder.decode(buffer | di::take(*nread)); + contents.append(utf8_string); + } + + (void) read.close(); + + if (pane.m_hooks.did_finish_output) { + pane.m_hooks.did_finish_output(contents.view()); + } + })); + } + return pane; } auto Pane::create_mock() -> di::Box { auto fake_psuedo_terminal = dius::SyncFile(); - return di::make_box(0, di::move(fake_psuedo_terminal), Size(1, 1), dius::system::ProcessHandle(), nullptr, - nullptr, nullptr, nullptr); + return di::make_box(0, di::move(fake_psuedo_terminal), Size(1, 1), dius::system::ProcessHandle(), + PaneHooks {}); } Pane::~Pane() { // TODO: timeout/skip waiting for processes to die after sending SIGHUP. (void) m_process.signal(dius::Signal::Hangup); + (void) m_pipe_reader_thread.join(); + (void) m_pipe_writer_thread.join(); (void) m_reader_thread.join(); (void) m_process_thread.join(); } @@ -371,8 +429,8 @@ auto Pane::event(MouseEvent const& event) -> bool { terminal.active_screen().screen.clear_selection(); return result; }); - if (!text.empty() && m_did_selection) { - m_did_selection(di::as_bytes(text.span())); + if (!text.empty() && m_hooks.did_selection) { + m_hooks.did_selection(di::as_bytes(text.span())); } return true; } @@ -485,6 +543,12 @@ void Pane::stop_capture() { m_capture.store(false, di::MemoryOrder::Release); } +void Pane::soft_reset() { + m_terminal.with_lock([&](Terminal& terminal) { + terminal.soft_reset(); + }); +} + void Pane::exit() { (void) m_process.signal(dius::Signal::Hangup); } diff --git a/lib/src/popup.cpp b/lib/src/popup.cpp new file mode 100644 index 0000000..9f61382 --- /dev/null +++ b/lib/src/popup.cpp @@ -0,0 +1,47 @@ +#include "ttx/popup.h" + +#include "ttx/layout.h" +#include "ttx/size.h" + +namespace ttx { +struct ResolveSize { + static auto operator()(Size const& total_size, RelatizeSize dimension, bool width) -> u32 { + auto size_dimension = width ? total_size.cols : total_size.rows; + return u32((di::Rational(dimension.raw_value(), max_layout_precision) * size_dimension).round()); + } + + static auto operator()(Size const&, AbsoluteSize dimension, bool) -> u32 { return dimension.raw_value(); } +}; + +auto Popup::layout(Size const& size) -> LayoutEntry { + auto resolve_size = di::bind_front(ResolveSize {}, size); + auto cols = di::visit(di::bind_back(resolve_size, true), layout_config.width); + auto rows = di::visit(di::bind_back(resolve_size, false), layout_config.height); + cols = di::clamp(cols, 1_u32, size.cols); + rows = di::clamp(rows, 1_u32, size.rows); + + auto empty_rows = di::max(size.rows - rows, 0_u32); + auto empty_cols = di::max(size.cols - cols, 0_u32); + + auto layout_size = Size(rows, cols, size.xpixels / cols, size.ypixels / rows); + auto [r, c] = [&] -> di::Tuple { + switch (layout_config.alignment) { + case PopupAlignment::Left: + return { di::divide_round_up(empty_rows, 2u), 0 }; + case PopupAlignment::Right: + return { di::divide_round_up(empty_rows, 2u), empty_cols }; + case PopupAlignment::Top: + return { 0, di::divide_round_up(empty_cols, 2u) }; + case PopupAlignment::Bottom: + return { empty_rows, di::divide_round_up(empty_cols, 2u) }; + case PopupAlignment::Center: + return { di::divide_round_up(empty_rows, 2u), di::divide_round_up(empty_cols, 2u) }; + } + return { 0, 0 }; + }(); + if (pane) { + pane->resize(layout_size); + } + return LayoutEntry { r, c, layout_size, nullptr, pane.get() }; +} +} diff --git a/lib/src/renderer.cpp b/lib/src/renderer.cpp index aee2cd6..041b7dc 100644 --- a/lib/src/renderer.cpp +++ b/lib/src/renderer.cpp @@ -9,6 +9,49 @@ #include "ttx/terminal/escapes/osc_8.h" namespace ttx { +auto Renderer::setup(dius::SyncFile& output) -> di::Result<> { + m_cleanup = {}; + + // Setup - alternate screen buffer. + di::writer_print(output, "\033[?1049h\033[H\033[2J"_sv); + m_cleanup.push_back("\033[?1049l\033[?25h"_s); + + // Setup - disable autowrap. + di::writer_print(output, "\033[?7l"_sv); + m_cleanup.push_back("\033[?7h"_s); + + // Setup - kitty key mode. + di::writer_print(output, "\033[>31u"_sv); + m_cleanup.push_back("\033[(output, "\033[?1003h\033[?1006h"_sv); + m_cleanup.push_back("\033[?1006l\033[?1003l"_s); + + // Setup - enable focus events. + di::writer_print(output, "\033[?1004h"_sv); + m_cleanup.push_back("\033[?1004l"_s); + + // Setup - bracketed paste. + di::writer_print(output, "\033[?2004h"_sv); + m_cleanup.push_back("\033[?2004l"_s); + + auto text = di::move(m_buffer).vector(); + m_buffer = {}; + return output.write_exactly(di::as_bytes(text.span())); +} + +auto Renderer::cleanup(dius::SyncFile& output) -> di::Result<> { + for (auto const& string : m_cleanup | di::reverse) { + di::writer_print(m_buffer, "{}"_sv, string); + } + m_cleanup.clear(); + + auto text = di::move(m_buffer).vector(); + m_buffer = {}; + return output.write_exactly(di::as_bytes(text.span())); +} + void Renderer::start(Size const& size) { m_buffer = {}; m_size = size; diff --git a/lib/src/terminal.cpp b/lib/src/terminal.cpp index b1623db..d4e1b21 100644 --- a/lib/src/terminal.cpp +++ b/lib/src/terminal.cpp @@ -14,6 +14,7 @@ #include "ttx/params.h" #include "ttx/paste_event_io.h" #include "ttx/terminal/escapes/osc_8.h" +#include "ttx/terminal/screen.h" namespace ttx { Terminal::Terminal(u64 id, dius::SyncFile& psuedo_terminal, Size const& size) @@ -181,6 +182,17 @@ void Terminal::on_parser_result(CSI const& csi) { } } + if (csi.intermediate == "!"_sv) { + switch (csi.terminator) { + case 'p': { + csi_decstr(csi.params); + return; + } + default: + return; + } + } + if (!csi.intermediate.empty()) { return; } @@ -978,6 +990,12 @@ void Terminal::csi_scorc(Params const&) { esc_decrc(); } +// Soft Terminal Reset - https://vt100.net/docs/vt510-rm/DECSTR.html +void Terminal::csi_decstr(Params const&) { + // NOTE: this isn't exactly standards compliant. + soft_reset(); +} + // Window manipulation - // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0 void Terminal::csi_xtwinops(Params const& params) { @@ -1041,16 +1059,17 @@ void Terminal::csi_set_key_reporting_flags(Params const& params) { auto flags_u32 = params.get(0); auto mode = params.get(1, 1); + auto& screen = active_screen(); auto flags = KeyReportingFlags(flags_u32) & KeyReportingFlags::All; switch (mode) { case 1: - m_key_reporting_flags = flags; + screen.m_key_reporting_flags = flags; break; case 2: - m_key_reporting_flags |= flags; + screen.m_key_reporting_flags |= flags; break; case 3: - m_key_reporting_flags &= ~flags; + screen.m_key_reporting_flags &= ~flags; break; default: break; @@ -1060,7 +1079,7 @@ void Terminal::csi_set_key_reporting_flags(Params const& params) { // https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement void Terminal::csi_get_key_reporting_flags(Params const&) { (void) m_psuedo_terminal.write_exactly( - di::as_bytes(di::present("\033[?{}u"_sv, u32(m_key_reporting_flags)).value().span())); + di::as_bytes(di::present("\033[?{}u"_sv, u32(active_screen().m_key_reporting_flags)).value().span())); } // https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement @@ -1068,11 +1087,12 @@ void Terminal::csi_push_key_reporting_flags(Params const& params) { auto flags_u32 = params.get(0); auto flags = KeyReportingFlags(flags_u32) & KeyReportingFlags::All; - if (m_key_reporting_flags_stack.size() >= 100) { - m_key_reporting_flags_stack.pop_front(); + auto& screen = active_screen(); + if (screen.m_key_reporting_flags_stack.size() >= 100) { + screen.m_key_reporting_flags_stack.pop_front(); } - m_key_reporting_flags_stack.push_back(m_key_reporting_flags); - m_key_reporting_flags = flags; + screen.m_key_reporting_flags_stack.push_back(screen.m_key_reporting_flags); + screen.m_key_reporting_flags = flags; } // https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement @@ -1081,15 +1101,17 @@ void Terminal::csi_pop_key_reporting_flags(Params const& params) { if (n == 0) { return; } - if (n >= m_key_reporting_flags_stack.size()) { - m_key_reporting_flags_stack.clear(); - m_key_reporting_flags = KeyReportingFlags::None; + + auto& screen = active_screen(); + if (n >= screen.m_key_reporting_flags_stack.size()) { + screen.m_key_reporting_flags_stack.clear(); + screen.m_key_reporting_flags = KeyReportingFlags::None; return; } - auto new_stack_size = m_key_reporting_flags_stack.size() - n; - m_key_reporting_flags = m_key_reporting_flags_stack[new_stack_size]; - m_key_reporting_flags_stack.erase(m_key_reporting_flags_stack.begin() + isize(new_stack_size)); + auto new_stack_size = screen.m_key_reporting_flags_stack.size() - n; + screen.m_key_reporting_flags = screen.m_key_reporting_flags_stack[new_stack_size]; + screen.m_key_reporting_flags_stack.erase(screen.m_key_reporting_flags_stack.begin() + isize(new_stack_size)); } void Terminal::set_visible_size(Size const& size) { @@ -1147,6 +1169,40 @@ auto Terminal::active_screen() const -> ScreenState const& { return m_primary_screen; } +void Terminal::soft_reset() { + // The goal of this routine is to make the terminal usable again after a full-screen + // application crashes without cleaning anything up. This won't fully follow the + // spec for DECSTR (https://vt100.net/docs/vt510-rm/DECSTR.html), becuase it claims + // autowrap should be disabled. Autowrap is on by default in this terminal. + + set_use_alternate_screen_buffer(false); + + active_screen().cursor_style = CursorStyle::SteadyBlock; + active_screen().saved_cursor = {}; + + auto& screen = active_screen().screen; + auto cursor = screen.save_cursor(); + csi_decstbm({}); + screen.set_current_graphics_rendition({}); + screen.set_current_hyperlink({}); + cursor.origin_mode = terminal::OriginMode::Disabled; + screen.restore_cursor(cursor); + screen.invalidate_all(); + + m_allow_80_132_col_mode = false; + m_allow_force_terminal_size = false; + m_auto_wrap_mode = terminal::AutoWrapMode::Enabled; + m_mouse_encoding = MouseEncoding::X10; + m_mouse_protocol = MouseProtocol::None; + active_screen().m_key_reporting_flags_stack.clear(); + active_screen().m_key_reporting_flags = KeyReportingFlags::None; + m_focus_event_mode = FocusEventMode::Disabled; + m_cursor_hidden = false; + m_disable_drawing = false; + + resize(visible_size()); +} + auto Terminal::state_as_escape_sequences() const -> di::String { auto writer = di::VectorWriter<> {}; @@ -1182,10 +1238,30 @@ auto Terminal::state_as_escape_sequences() const -> di::String { di::writer_print(writer, "\033[{} q"_sv, i32(screen.cursor_style)); }; + auto kitty_key_flags = [&](ScreenState const& screen) { + // Kitty key flags + auto first = true; + auto set_kitty_key_flags = [&](KeyReportingFlags flags) { + if (first) { + di::writer_print(writer, "\033[=1;{}u"_sv, i32(flags)); + first = false; + } else { + di::writer_print(writer, "\033[>{}u"_sv, i32(flags)); + } + }; + + for (auto flags : screen.m_key_reporting_flags_stack) { + set_kitty_key_flags(flags); + } + set_kitty_key_flags(screen.m_key_reporting_flags); + }; + print_screen(m_primary_screen); + kitty_key_flags(m_primary_screen); if (m_alternate_screen) { di::writer_print(writer, "\033[?1049h\033[H\033[2J"_sv); print_screen(*m_alternate_screen); + kitty_key_flags(*m_alternate_screen); } } @@ -1214,22 +1290,6 @@ auto Terminal::state_as_escape_sequences() const -> di::String { di::writer_print(writer, "\033[?25l"_sv); } - // Kitty key flags - auto first = true; - auto set_kitty_key_flags = [&](KeyReportingFlags flags) { - if (first) { - di::writer_print(writer, "\033[=1;{}u"_sv, i32(flags)); - first = false; - } else { - di::writer_print(writer, "\033[>{}u"_sv, i32(flags)); - } - }; - - for (auto flags : m_key_reporting_flags_stack) { - set_kitty_key_flags(flags); - } - set_kitty_key_flags(m_key_reporting_flags); - // Alternate scroll mode if (m_alternate_scroll_mode == AlternateScrollMode::Enabled) { di::writer_print(writer, "\033[?1007h"_sv); diff --git a/lib/src/terminal/screen.cpp b/lib/src/terminal/screen.cpp index cd793ea..19a25c5 100644 --- a/lib/src/terminal/screen.cpp +++ b/lib/src/terminal/screen.cpp @@ -98,7 +98,7 @@ void Screen::resize(Size const& size) { if (m_scroll_back_enabled == ScrollBackEnabled::Yes && !m_never_got_input) { // When taking rows from the scroll back, we also need to move the cursor down to accomdate the new rows. auto rows_to_take = di::min(m_scroll_back.total_rows(), size.rows - rows().size()); - m_scroll_back.take_rows(m_active_rows, max_width(), 0, rows_to_take); + m_scroll_back.take_rows(m_active_rows, size.cols, 0, rows_to_take); m_cursor.row += rows_to_take; // In this case, we may need to adjust the visual scroll offset. @@ -116,6 +116,13 @@ void Screen::resize(Size const& size) { m_cursor.row = di::min(m_cursor.row, size.rows - 1); m_cursor.col = di::min(m_cursor.col, size.cols - 1); + // Recompute the cursor text offset. + auto& row_object = rows()[m_cursor.row]; + m_cursor.text_offset = 0; + for (auto const& cell : row_object.cells | di::take(m_cursor.col)) { + m_cursor.text_offset += cell.text_size; + } + // TODO: optimize! invalidate_all(); } @@ -321,6 +328,11 @@ void Screen::insert_blank_characters(u32 count) { ASSERT(text_start); row.text.erase(text_start.value(), row.text.end()); + // Mark any cells which have moved as dirty. + for (auto& cell : row.cells | di::drop(m_cursor.col)) { + cell.stale = false; + } + // Finally, insert the blank cells. Note that to implement bce this would need to // preserve the background color. The cursor position is unchanged, as is // the cursor byte offset. @@ -381,6 +393,11 @@ void Screen::delete_characters(u32 count) { ASSERT(text_end); row.text.erase(text_start.value(), text_end.value()); + // Mark any cells which have moved as dirty. + for (auto& cell : row.cells | di::drop(m_cursor.col + max_to_delete)) { + cell.stale = false; + } + // Insert blank cells at the end of the row. Note that to implement bce this would need to // preserve the background color. The cursor position is unchanged, as is the cursor byte offset. row.cells.resize(max_width()); diff --git a/lib/src/terminal/scroll_back.cpp b/lib/src/terminal/scroll_back.cpp index 08a0c60..97dfaa3 100644 --- a/lib/src/terminal/scroll_back.cpp +++ b/lib/src/terminal/scroll_back.cpp @@ -88,7 +88,9 @@ auto ScrollBack::is_last_group_full() const -> bool { auto ScrollBack::add_group() -> Group& { if (m_groups.size() >= max_groups) { - m_absolute_row_start += m_groups.front().value().group.total_rows(); + auto deleted_rows = m_groups.front().value().group.total_rows(); + m_absolute_row_start += deleted_rows; + m_total_rows -= deleted_rows; m_groups.pop_front(); } return m_groups.emplace_back(); diff --git a/lib/test/cases/git-log-with-scroll.expected.ansi b/lib/test/cases/git-log-with-scroll.expected.ansi index 6dd6a93..448f20f 100644 --- a/lib/test/cases/git-log-with-scroll.expected.ansi +++ b/lib/test/cases/git-log-with-scroll.expected.ansi @@ -1,4 +1,4 @@ -c[?7h❯ git log +c[?7h❯ git log ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ commit ]8;id=1;https://github.com/ColeTrammer/ttx/commit/c5f418c23f45c2b372ac88fd249ce45f79ba9853\c5f418c23f45c2b372ac88fd249ce45f79ba9853]8;;\ (HEAD -> testing, origin/testing) ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -40,4 +40,4 @@ Date: Sat Mar 22 22:02:26 2025 -0700 Date: Sat Mar 22 19:12:19 2025 -0700  meta: fix clang tidy violations -:78[1 q[?1h[=1;0u \ No newline at end of file +:78[1 q[=1;0u[?1h \ No newline at end of file diff --git a/lib/test/cases/git-status.expected.ansi b/lib/test/cases/git-status.expected.ansi index 71d496e..480b666 100644 --- a/lib/test/cases/git-status.expected.ansi +++ b/lib/test/cases/git-status.expected.ansi @@ -1,4 +1,4 @@ -c[?7h +c[?7h @@ -118,4 +118,4 @@ Changes not staged for commit:[    -8[6 q[?1h[=1;0u[?2004h \ No newline at end of file +8[6 q[=1;0u[?1h[?2004h \ No newline at end of file diff --git a/lib/test/cases/hyperlink-demo.expected.ansi b/lib/test/cases/hyperlink-demo.expected.ansi index 31eb99a..64c61b7 100644 --- a/lib/test/cases/hyperlink-demo.expected.ansi +++ b/lib/test/cases/hyperlink-demo.expected.ansi @@ -1,4 +1,4 @@ -c[?7hTests for ]8;id=1;https://bugzilla.gnome.org/show_bug.cgi?id=779734\gnome-terminal #779734]8;;\ and ]8;id=2;https://gitlab.com/gnachman/iterm2/issues/5158\iTerm2 #5158 +c[?7hTests for ]8;id=1;https://bugzilla.gnome.org/show_bug.cgi?id=779734\gnome-terminal #779734]8;;\ and ]8;id=2;https://gitlab.com/gnachman/iterm2/issues/5158\iTerm2 #5158 ]8;;\═════════════════════════════════════════════════  commit ]8;id=3;https://git.gnome.org/browse/vte/commit/?id=a9b0b4c75a6dc7282f7cfcaef71413d69f7f0731\a9b0b4c75a6dc7282f7cfcaef71413d69f7f0731 @@ -12,6 +12,43 @@ Date: Sat Oct 24 00:12:22 2015 +0200 ]8;;\commit ]8;id=6;https://git.gnome.org/browse/vte/commit/?id=6a74baeaabb0a1ce54444611b324338f94721a5c\6a74baeaabb0a1ce54444611b324338f94721a5c ]8;;\Merge: ]8;id=7;https://git.gnome.org/browse/vte/commit/?id=3fac4469de267f662c761ea4f247c8017ced483d\3fac446]8;;\ ]8;id=8;https://git.gnome.org/browse/vte/commit/?id=56ea5810759b9943a4203f9382919f058a66f224\56ea581 ]8;;\Author: Christian Persch <]8;id=9;mailto:chpe@gnome.org\chpe@gnome.org]8;;\> +Date: Mon Apr 27 13:48:52 2015 +0200 + + Merge branch 'work-html' into merge-html + +]8;id=10;file:///var/lib/gconf/defaults/%25gconf-tree.xml\A file with a % sign in its name (escaped as %25) +]8;;\Icons: ]8;id=11;file:///usr/share/icons/Adwaita/256x256/apps/preferences-desktop-theme.png\Theme]8;;\ ]8;id=12;file:///usr/share/icons/Adwaita/256x256/categories/applications-graphics.png\Graphics]8;;\ ]8;id=13;file:///usr/share/icons/Adwaita/256x256/status/starred.png\Star]8;;\ ]8;id=14;file:///usr/share/icons/Adwaita/256x256/actions/system-log-out.png\Exit]8;;\ ]8;id=15;file:///usr/share/icons/Adwaita/512x512/apps/utilities-terminal.png\Terminal +]8;;\Backgrounds: ]8;id=16;file:///usr/share/backgrounds/gnome/Bokeh_Tails.jpg\Bokeh]8;;\ ]8;id=17;file:///usr/share/backgrounds/gnome/Chmiri.jpg\Chmiri]8;;\ ]8;id=18;file:///usr/share/backgrounds/gnome/Dark_Ivy.jpg\Ivy]8;;\ ]8;id=19;file:///usr/share/backgrounds/gnome/Flowerbed.jpg\Flower]8;;\ ]8;id=20;file:///usr/share/backgrounds/gnome/Godafoss_Iceland.jpg\Iceland]8;;\ ]8;id=21;file:///usr/share/backgrounds/gnome/Icescape.jpg\Icescape]8;;\ ]8;id=22;file:///usr/share/backgrounds/gnome/Mirror.jpg\Mirror]8;;\ ]8;id=23;file:///usr/share/backgrounds/gnome/Road.jpg\Road]8;;\ ]8;id=24;file:///usr/share/backgrounds/gnome/Sandstone.jpg\Sandstone]8;;\ ]8;id=25;file:///usr/share/backgrounds/gnome/Stones.jpg\Ston + +]8;id=28;https://en.wikipedia.org/wiki/�\Wiki page of � (unescaped raw Latin-1; invalid UTF-8) +]8;id=29;https://en.wikipedia.org/wiki/Á\Wiki page of Á (unescaped raw UTF-8) +]8;id=30;https://en.wikipedia.org/wiki/%C3%81\Wiki page of Á (escaped as %C3%81) +]8;id=31;https://en.wikipedia.org/wiki/%25\Wiki page of % (escaped as %25) +]8;id=32;http://%d8%a7%d9%84%d9%85%d8%ba%d8%b1%d8%a8.icom.museum\http://المغرب.icom.museum (with URI-escaped domain name) +]8;id=33;http://xn--4wa8awb4637h.org\http://xn--4wa8awb4637h.org (Παν語.org) + +]8;;\Two adjacent links pointing to the same URL: ]8;id=34;http://example.com/foo\foo]8;id=35;http://example.com/foo\foo +]8;;\Two adjacent links pointing to different URLs: ]8;id=36;http://example.com/foo\foo]8;id=37;http://example.com/bar\bar + +]8;;\The same two without closing the first link: ]8;id=38;http://example.com/foo\foo]8;id=39;http://example.com/foo\foo]8;;\ ]8;id=40;http://example.com/foo\foo]8;id=41;http://example.com/bar\bar + +]8;;\A URL wrapping to the next line, and a trailing whitespace: ]8;id=42;http://example.com/foobar\foo +bar + +]8;id=43;http://example.com/colors\Multi-colour link also tests that "\e[m" or "\e[0m" does not terminate the link + +]8;;\Soft reset "\e[!p" resets attributes and terminates link: ]8;id=44;http://example.com/softreset\foo]8;;\bar + +]8;id=45;http://example.com/width\Some CJK and combining accents: 䀀䀁䀂ćĝm̃n̄o̅ + +]8;;\(Introducing the "under_score" character for even more fun) + +Explicit and implicit link: ]8;id=46;http://example.com/under_score\http://example.com/under_score +]8;;\Explicit and implicit link with different targets: ]8;id=47;http://example.com/explicit_under_score\http://example.com/implicit_under_score +]8;;\Explicit and implicit link, broken into two lines: ]8;id=48;http://example.com/under_score\http://examp +le.com/under_score + +]8;;\Explicitly underlined links ("\e[4m"): @@ -35,101 +72,27 @@ Date: Sat Oct 24 00:12:22 2015 +0200 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Date: Mon Apr 27 13:48:52 2015 +0200 - - Merge branch 'work-html' into merge-html - -]8;id=10;file:///var/lib/gconf/defaults/%25gconf-tree.xml\A file with a % sign in its name (escaped as %25) -]8;;\Icons: ]8;id=11;file:///usr/share/icons/Adwaita/256x256/apps/preferences-desktop-theme.png\Theme]8;;\ ]8;id=12;file:///usr/share/icons/Adwaita/256x256/categories/applications-graphics.png\Graphics]8;;\ ]8;id=13;file:///usr/share/icons/Adwaita/256x256/status/starred.png\Star]8;;\ ]8;id=14;file:///usr/share/icons/Adwaita/256x256/actions/system-log-out.png\Exit]8;;\ ]8;id=15;file:///usr/share/icons/Adwaita/512x512/apps/utilities-terminal.png\Terminal -]8;;\Backgrounds: ]8;id=16;file:///usr/share/backgrounds/gnome/Bokeh_Tails.jpg\Bokeh]8;;\ ]8;id=17;file:///usr/share/backgrounds/gnome/Chmiri.jpg\Chmiri]8;;\ ]8;id=18;file:///usr/share/backgrounds/gnome/Dark_Ivy.jpg\Ivy]8;;\ ]8;id=19;file:///usr/share/backgrounds/gnome/Flowerbed.jpg\Flower]8;;\ ]8;id=20;file:///usr/share/backgrounds/gnome/Godafoss_Iceland.jpg\Iceland]8;;\ ]8;id=21;file:///usr/share/backgrounds/gnome/Icescape.jpg\Icescape]8;;\ ]8;id=22;file:///usr/share/backgrounds/gnome/Mirror.jpg\Mirror]8;;\ ]8;id=23;file:///usr/share/backgrounds/gnome/Road.jpg\Road]8;;\ ]8;id=24;file:///usr/share/backgrounds/gnome/Sandstone.jpg\Sandstone]8;;\ ]8;id=25;file:///usr/share/backgrounds/gnome/Stones.jpg\Stones]8;;\ ]8;id=26;file:///usr/share/backgrounds/gnome/Waterfalls.jpg\Waterfalls]8;;\ ]8;id=27;file:///usr/share/backgrounds/gnome/Waves.jpg\Waves - -]8;id=28;https://en.wikipedia.org/wiki/�\Wiki page of � (unescaped raw Latin-1; invalid UTF-8) -]8;id=29;https://en.wikipedia.org/wiki/Á\Wiki page of Á (unescaped raw UTF-8) -]8;id=30;https://en.wikipedia.org/wiki/%C3%81\Wiki page of Á (escaped as %C3%81) -]8;id=31;https://en.wikipedia.org/wiki/%25\Wiki page of % (escaped as %25) -]8;id=32;http://%d8%a7%d9%84%d9%85%d8%ba%d8%b1%d8%a8.icom.museum\http://المغرب.icom.museum (with URI-escaped domain name) -]8;id=33;http://xn--4wa8awb4637h.org\http://xn--4wa8awb4637h.org (Παν語.org) - -]8;;\Two adjacent links pointing to the same URL: ]8;id=34;http://example.com/foo\foo]8;id=35;http://example.com/foo\foo -]8;;\Two adjacent links pointing to different URLs: ]8;id=36;http://example.com/foo\foo]8;id=37;http://example.com/bar\bar - -]8;;\The same two without closing the first link: ]8;id=38;http://example.com/foo\foo]8;id=39;http://example.com/foo\foo]8;;\ ]8;id=40;http://example.com/foo\foo]8;id=41;http://example.com/bar\bar - -]8;;\A URL wrapping to the next line, and a trailing whitespace: ]8;id=42;http://example.com/foobar\foo -bar  - -]8;id=43;http://example.com/colors\Multi-colour link also tests that "\e[m" or "\e[0m" does not terminate the link - -]8;;\Soft reset "\e[!p" resets attributes and terminates link: ]8;id=44;http://example.com/softreset\foobar - -]8;id=45;http://example.com/width\Some CJK and combining accents: 䀀䀁䀂ćĝm̃n̄o̅ - -]8;;\(Introducing the "under_score" character for even more fun) - -Explicit and implicit link: ]8;id=46;http://example.com/under_score\http://example.com/under_score -]8;;\Explicit and implicit link with different targets: ]8;id=47;http://example.com/explicit_under_score\http://example.com/implicit_under_score -]8;;\Explicit and implicit link, broken into two lines: ]8;id=48;http://example.com/under_score\http://examp -le.com/under_score - -]8;;\Explicitly underlined links ("\e[4m"): -Explicit link only: ]8;id=49;http://example.com/under_score\I'm an explicit link with under_score -]8;;\Implicit link only: http://example.com/under_score -Both: ]8;id=50;http://example.com/under_score\http://example.com/under_score - -]8;;\Conflicting explicit and implicit links: http://example.com/foobar-]8;id=51;http://example.com/explicit\explicit]8;;\-rest - -Invisible explicit link: «]8;id=52;http://example.com/invisible\Can you see me?]8;;\» -Invisible implicit link: «http://example.com/how_about_me» - -]8;id=53;asdfghjkl\Explicit link with stupid target - -]8;id=54;http://example.com/.........30........40........50........60........70........80........90.......100\URL of 100 bytes -]8;id=55;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200\URL of 200 bytes -]8;id=56;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500\URL of 500 bytes -]8;id=57;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000\URL of 1000 bytes -]8;id=58;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500\URL of 1500 bytes -]8;id=59;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500......1510......1520......1530......1540......1550......1560......1570......1580......1590......1600......1610......1620......1630......1640......1650......1660......1670......1680......1690......1700......1710......1720......1730......1740......1750......1760......1770......1780......1790......1800......1810......1820......1830......1840......1850......1860......1870......1880......1890......1900......1910......1920......1930......1940......1950......1960......1970......1980......1990......2000\URL of 2000 bytes -]8;id=60;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500......1510......1520......1530......1540......1550......1560......1570......1580......1590......1600......1610......1620......1630......1640......1650......1660......1670......1680......1690......1700......1710......1720......1730......1740......1750......1760......1770......1780......1790......1800......1810......1820......1830......1840......1850......1860......1870......1880......1890......1900......1910......1920......1930......1940......1950......1960......1970......1980......1990......2000......2010......2020......2030......2040......2050......2060......2070......2080...\URL of 2083 bytes -]8;;\URL of 2084 bytes - -]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\ID of 250 bytes once,]8;;\ ]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\twice -ID of 251 bytes once,]8;;\ ]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\twice - -78]8;;\[6 q[=1;0u \ No newline at end of file +Explicit link only: ]8;id=49;http://example.com/under_score\I'm an explicit link with under_score +]8;;\Implicit link only: http://example.com/under_score +Both: ]8;id=50;http://example.com/under_score\http://example.com/under_score + +]8;;\Conflicting explicit and implicit links: http://example.com/foobar-]8;id=51;http://example.com/explicit\explicit]8;;\-rest + +Invisible explicit link: «]8;id=52;http://example.com/invisible\Can you see me?]8;;\» +Invisible implicit link: «http://example.com/how_about_me» + +]8;id=53;asdfghjkl\Explicit link with stupid target + +]8;id=54;http://example.com/.........30........40........50........60........70........80........90.......100\URL of 100 bytes +]8;id=55;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200\URL of 200 bytes +]8;id=56;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500\URL of 500 bytes +]8;id=57;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000\URL of 1000 bytes +]8;id=58;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500\URL of 1500 bytes +]8;id=59;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500......1510......1520......1530......1540......1550......1560......1570......1580......1590......1600......1610......1620......1630......1640......1650......1660......1670......1680......1690......1700......1710......1720......1730......1740......1750......1760......1770......1780......1790......1800......1810......1820......1830......1840......1850......1860......1870......1880......1890......1900......1910......1920......1930......1940......1950......1960......1970......1980......1990......2000\URL of 2000 bytes +]8;id=60;http://example.com/.........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230.......240.......250.......260.......270.......280.......290.......300.......310.......320.......330.......340.......350.......360.......370.......380.......390.......400.......410.......420.......430.......440.......450.......460.......470.......480.......490.......500.......510.......520.......530.......540.......550.......560.......570.......580.......590.......600.......610.......620.......630.......640.......650.......660.......670.......680.......690.......700.......710.......720.......730.......740.......750.......760.......770.......780.......790.......800.......810.......820.......830.......840.......850.......860.......870.......880.......890.......900.......910.......920.......930.......940.......950.......960.......970.......980.......990......1000......1010......1020......1030......1040......1050......1060......1070......1080......1090......1100......1110......1120......1130......1140......1150......1160......1170......1180......1190......1200......1210......1220......1230......1240......1250......1260......1270......1280......1290......1300......1310......1320......1330......1340......1350......1360......1370......1380......1390......1400......1410......1420......1430......1440......1450......1460......1470......1480......1490......1500......1510......1520......1530......1540......1550......1560......1570......1580......1590......1600......1610......1620......1630......1640......1650......1660......1670......1680......1690......1700......1710......1720......1730......1740......1750......1760......1770......1780......1790......1800......1810......1820......1830......1840......1850......1860......1870......1880......1890......1900......1910......1920......1930......1940......1950......1960......1970......1980......1990......2000......2010......2020......2030......2040......2050......2060......2070......2080...\URL of 2083 bytes +]8;;\URL of 2084 bytes + +]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\ID of 250 bytes once,]8;;\ ]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\twice +ID of 251 bytes once,]8;;\ ]8;id=........10........20........30........40........50........60........70........80........90.......100.......110.......120.......130.......140.......150.......160.......170.......180.......190.......200.......210.......220.......230;http://example.com/id\twice + +78]8;;\[2 q[=1;0u \ No newline at end of file diff --git a/lib/test/cases/nix-output-monitor.expected.ansi b/lib/test/cases/nix-output-monitor.expected.ansi index 1fe21d7..0282854 100644 --- a/lib/test/cases/nix-output-monitor.expected.ansi +++ b/lib/test/cases/nix-output-monitor.expected.ansi @@ -1,4 +1,4 @@ -c[?7h +c[?7h @@ -112,4 +112,4 @@ ttx-lib-dius-runtime> -- Build files have been written to: /build/bxdc05nah9    -8[6 q[?25l[=1;0u \ No newline at end of file +8[6 q[=1;0u[?25l \ No newline at end of file diff --git a/lib/test/cases/nvim-basic.expected.ansi b/lib/test/cases/nvim-basic.expected.ansi index e6a7f6b..97abdba 100644 --- a/lib/test/cases/nvim-basic.expected.ansi +++ b/lib/test/cases/nvim-basic.expected.ansi @@ -1,4 +1,4 @@ -c[?7h +c[?7h @@ -28,7 +28,7 @@    -8[1 q[?1049h +8[1 q[=1;0u[?1049h @@ -58,4 +58,4 @@  2 │ 11 #include "ttx/mouse_event.h"  3 # Show output via ttx│ 12 #include "ttx/paste_event.h"  4 "$TTX_BUILD_DIR/ttx" -r "$expected"│ 13 #include "ttx/terminal_input.h"  1/8  (12%) ⠇ indexing clangd - NORMAL   testing  󰙲 input.cpp gj  utf-8 |   Top 1:1 8[2 q[?1h[=1;0u[>1u[?1002h[?1006h[?1004h[?2004h \ No newline at end of file + NORMAL   testing  󰙲 input.cpp gj  utf-8 |   Top 1:1 8[2 q[=1;0u[>1u[?1h[?1002h[?1006h[?1004h[?2004h \ No newline at end of file diff --git a/lib/test/cases/vttest-1-1.expected.ansi b/lib/test/cases/vttest-1-1.expected.ansi index 4740820..7b8a73f 100644 --- a/lib/test/cases/vttest-1-1.expected.ansi +++ b/lib/test/cases/vttest-1-1.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3l[?7h +c[?40h[?3l[?7h    diff --git a/lib/test/cases/vttest-1-2.expected.ansi b/lib/test/cases/vttest-1-2.expected.ansi index 234cafe..9720d0e 100644 --- a/lib/test/cases/vttest-1-2.expected.ansi +++ b/lib/test/cases/vttest-1-2.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3h[?7h +c[?40h[?3h[?7h diff --git a/lib/test/cases/vttest-1-3.expected.ansi b/lib/test/cases/vttest-1-3.expected.ansi index 41a6981..0763250 100644 --- a/lib/test/cases/vttest-1-3.expected.ansi +++ b/lib/test/cases/vttest-1-3.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3l[?7h +c[?40h[?3l[?7h    diff --git a/lib/test/cases/vttest-1-4.expected.ansi b/lib/test/cases/vttest-1-4.expected.ansi index 0753477..f7d1778 100644 --- a/lib/test/cases/vttest-1-4.expected.ansi +++ b/lib/test/cases/vttest-1-4.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3h[?7h +c[?40h[?3h[?7h    diff --git a/lib/test/cases/vttest-1-5.expected.ansi b/lib/test/cases/vttest-1-5.expected.ansi index 6bfebc7..c98bb8e 100644 --- a/lib/test/cases/vttest-1-5.expected.ansi +++ b/lib/test/cases/vttest-1-5.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3l[?7h +c[?40h[?3l[?7h diff --git a/lib/test/cases/vttest-1-6.expected.ansi b/lib/test/cases/vttest-1-6.expected.ansi index 92caf67..565df2a 100644 --- a/lib/test/cases/vttest-1-6.expected.ansi +++ b/lib/test/cases/vttest-1-6.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3l[?7h +c[?40h[?3l[?7h diff --git a/lib/test/cases/vttest-11-1-2-3.expected.ansi b/lib/test/cases/vttest-11-1-2-3.expected.ansi index 0d7330b..434b943 100644 --- a/lib/test/cases/vttest-11-1-2-3.expected.ansi +++ b/lib/test/cases/vttest-11-1-2-3.expected.ansi @@ -1,4 +1,4 @@ -c[?7h +c[?7h diff --git a/lib/test/cases/vttest-11-7-2.expected.ansi b/lib/test/cases/vttest-11-7-2.expected.ansi index 97e64ab..359e8f4 100644 --- a/lib/test/cases/vttest-11-7-2.expected.ansi +++ b/lib/test/cases/vttest-11-7-2.expected.ansi @@ -1,4 +1,4 @@ -c[?7h +c[?7h diff --git a/lib/test/cases/vttest-2-1.expected.ansi b/lib/test/cases/vttest-2-1.expected.ansi index 01f2340..bd519af 100644 --- a/lib/test/cases/vttest-2-1.expected.ansi +++ b/lib/test/cases/vttest-2-1.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3l[?7h +c[?40h[?3l[?7h diff --git a/lib/test/cases/vttest-2-10.expected.ansi b/lib/test/cases/vttest-2-10.expected.ansi index 8bc0c25..f7e5bb4 100644 --- a/lib/test/cases/vttest-2-10.expected.ansi +++ b/lib/test/cases/vttest-2-10.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-11.expected.ansi b/lib/test/cases/vttest-2-11.expected.ansi index b89e3e9..16486b9 100644 --- a/lib/test/cases/vttest-2-11.expected.ansi +++ b/lib/test/cases/vttest-2-11.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-12.expected.ansi b/lib/test/cases/vttest-2-12.expected.ansi index 8b8fb51..d1c120c 100644 --- a/lib/test/cases/vttest-2-12.expected.ansi +++ b/lib/test/cases/vttest-2-12.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-13.expected.ansi b/lib/test/cases/vttest-2-13.expected.ansi index 97b8ab3..e07c2ef 100644 --- a/lib/test/cases/vttest-2-13.expected.ansi +++ b/lib/test/cases/vttest-2-13.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-14.expected.ansi b/lib/test/cases/vttest-2-14.expected.ansi index 7839374..d89528d 100644 --- a/lib/test/cases/vttest-2-14.expected.ansi +++ b/lib/test/cases/vttest-2-14.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 @@ -160,4 +160,4 @@ vanilla   Light background. Push 7 -8[6 q[?5h[=1;0u \ No newline at end of file +8[6 q[=1;0u[?5h \ No newline at end of file diff --git a/lib/test/cases/vttest-2-15.expected.ansi b/lib/test/cases/vttest-2-15.expected.ansi index c0bd4f9..433e82d 100644 --- a/lib/test/cases/vttest-2-15.expected.ansi +++ b/lib/test/cases/vttest-2-15.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-2.expected.ansi b/lib/test/cases/vttest-2-2.expected.ansi index aab8462..87be5e4 100644 --- a/lib/test/cases/vttest-2-2.expected.ansi +++ b/lib/test/cases/vttest-2-2.expected.ansi @@ -1,4 +1,4 @@ -cHHHHHHHHHHHHH[?7h +cHHHHHHHHHHHHH[?7h diff --git a/lib/test/cases/vttest-2-3.expected.ansi b/lib/test/cases/vttest-2-3.expected.ansi index 9c25334..5385fdf 100644 --- a/lib/test/cases/vttest-2-3.expected.ansi +++ b/lib/test/cases/vttest-2-3.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3hHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3hHHHHHHHHHHHHHHHHH[?7h    @@ -81,4 +81,4 @@    -8[6 q[?5h[=1;0u \ No newline at end of file +8[6 q[=1;0u[?5h \ No newline at end of file diff --git a/lib/test/cases/vttest-2-4.expected.ansi b/lib/test/cases/vttest-2-4.expected.ansi index 632bc0a..3cd6973 100644 --- a/lib/test/cases/vttest-2-4.expected.ansi +++ b/lib/test/cases/vttest-2-4.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h @@ -44,4 +44,4 @@    -8[6 q[?5h[=1;0u \ No newline at end of file +8[6 q[=1;0u[?5h \ No newline at end of file diff --git a/lib/test/cases/vttest-2-5.expected.ansi b/lib/test/cases/vttest-2-5.expected.ansi index 3a2050d..cede144 100644 --- a/lib/test/cases/vttest-2-5.expected.ansi +++ b/lib/test/cases/vttest-2-5.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3hHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3hHHHHHHHHHHHHHHHHH[?7h diff --git a/lib/test/cases/vttest-2-6.expected.ansi b/lib/test/cases/vttest-2-6.expected.ansi index 5b91d75..56bff15 100644 --- a/lib/test/cases/vttest-2-6.expected.ansi +++ b/lib/test/cases/vttest-2-6.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h diff --git a/lib/test/cases/vttest-2-7.expected.ansi b/lib/test/cases/vttest-2-7.expected.ansi index 77c6902..af59d6f 100644 --- a/lib/test/cases/vttest-2-7.expected.ansi +++ b/lib/test/cases/vttest-2-7.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-8.expected.ansi b/lib/test/cases/vttest-2-8.expected.ansi index c005368..1486d38 100644 --- a/lib/test/cases/vttest-2-8.expected.ansi +++ b/lib/test/cases/vttest-2-8.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/vttest-2-9.expected.ansi b/lib/test/cases/vttest-2-9.expected.ansi index 688c4bc..3e1b933 100644 --- a/lib/test/cases/vttest-2-9.expected.ansi +++ b/lib/test/cases/vttest-2-9.expected.ansi @@ -1,4 +1,4 @@ -c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h +c[?40h[?3lHHHHHHHHHHHHHHHHH[?7h[?6h Soft scroll up region [12..13] size 2 Line 1 Soft scroll up region [12..13] size 2 Line 2 Soft scroll up region [12..13] size 2 Line 3 diff --git a/lib/test/cases/zsh-eza.expected.ansi b/lib/test/cases/zsh-eza.expected.ansi index 52732a8..800bc10 100644 --- a/lib/test/cases/zsh-eza.expected.ansi +++ b/lib/test/cases/zsh-eza.expected.ansi @@ -1,4 +1,4 @@ -c[?7h❯ ls -l +c[?7h❯ ls -l Permissions Size User Date Modified Git Name drwxr-xr-x - colet 5 seconds -I  ]8;id=1;file:///persist/home/Workspace/cpp/ttx/build\build @@ -31,4 +31,4 @@ drwxr-xr ]8;;\.rw-r--r-- 5.8k colet 56 minutes -M  ]8;id=14;file:///persist/home/Workspace/cpp/ttx/justfile\justfile ]8;;\.rw-r--r-- 1.3k colet 2 weeks --  ]8;id=15;file:///persist/home/Workspace/cpp/ttx/LICENSE\LICENSE ]8;;\.rw-r--r-- 2.6k colet 5 days -- 󰂺 ]8;id=16;file:///persist/home/Workspace/cpp/ttx/README.md\README.md -]8;;\❯ 7 ttx on  testing8[6 q[?1h[=1;0u[?2004h \ No newline at end of file +]8;;\❯ 7 ttx on  testing8[6 q[=1;0u[?1h[?2004h \ No newline at end of file diff --git a/lib/test/src/test_popup.cpp b/lib/test/src/test_popup.cpp new file mode 100644 index 0000000..7ada948 --- /dev/null +++ b/lib/test/src/test_popup.cpp @@ -0,0 +1,88 @@ +#include "di/test/prelude.h" +#include "ttx/layout.h" +#include "ttx/popup.h" +#include "ttx/size.h" + +namespace popup { +using namespace ttx; + +static auto alignments() { + auto size = Size { 50, 60, 600, 50000 }; + + struct Case { + PopupLayout input {}; + LayoutEntry expected {}; + }; + + auto make_input = [&](PopupAlignment alignment, i64 height_percent, i64 width_percent) { + return PopupLayout { + .alignment = alignment, + .width = RelatizeSize(max_layout_precision / 100 * width_percent), + .height = RelatizeSize(max_layout_precision / 100 * height_percent), + }; + }; + + auto make_fixed_input = [&](PopupAlignment alignment, u32 height, u32 width) { + return PopupLayout { + .alignment = alignment, + .width = AbsoluteSize(width), + .height = AbsoluteSize(height), + }; + }; + + auto make_entry = [&](u32 row, u32 col, u32 rows, u32 cols) { + return LayoutEntry { + .row = row, + .col = col, + .size = Size(rows, cols, size.xpixels / cols, size.ypixels / rows), + }; + }; + + auto cases = di::Array { + // Fixed + Case { + .input = make_input(PopupAlignment::Center, 50, 50), + .expected = make_entry(13, 15, 25, 30), + }, + Case { + .input = make_input(PopupAlignment::Bottom, 50, 50), + .expected = make_entry(25, 15, 25, 30), + }, + Case { + .input = make_input(PopupAlignment::Top, 50, 50), + .expected = make_entry(0, 15, 25, 30), + }, + Case { + .input = make_input(PopupAlignment::Left, 50, 50), + .expected = make_entry(13, 0, 25, 30), + }, + Case { + .input = make_input(PopupAlignment::Right, 50, 50), + .expected = make_entry(13, 30, 25, 30), + }, + // Too small + Case { + .input = make_input(PopupAlignment::Center, 1, 1), + .expected = make_entry(25, 30, 1, 1), + }, + // Absolute + Case { + .input = make_fixed_input(PopupAlignment::Center, 25, 30), + .expected = make_entry(13, 15, 25, 30), + }, + // Too big + Case { + .input = make_fixed_input(PopupAlignment::Center, 100, 100), + .expected = make_entry(0, 0, 50, 60), + }, + }; + + for (auto const& [input, expected] : cases) { + auto popup = Popup { nullptr, input }; + auto result = popup.layout(size); + ASSERT_EQ(result, expected); + } +}; + +TEST(popup, alignments) +} diff --git a/meta/nix/packages.nix b/meta/nix/packages.nix index 0c77d8c..ad42406 100644 --- a/meta/nix/packages.nix +++ b/meta/nix/packages.nix @@ -1,4 +1,4 @@ -{ inputs, ... }: +{ inputs, lib, ... }: { perSystem = { pkgs, system, ... }: @@ -24,7 +24,7 @@ ]; }; - mkAppPackage = + mkUnwrappedPackage = stdenv: name: dep: stdenv.mkDerivation { name = "${name}-${version}"; @@ -41,6 +41,30 @@ buildInputs = [ dep ]; }; + mkWrappedPackage = + stdenv: name: dep: fzf: + let + runtimeDeps = [ fzf ]; + in + stdenv.mkDerivation { + name = "${name}-${version}"; + version = version; + + nativeBuildInputs = with pkgs; [ + makeBinaryWrapper + ]; + + unpackPhase = "true"; + installPhase = '' + mkdir -p $out/bin + cp ${dep}/bin/* $out/bin + ''; + postFixup = '' + wrapProgram $out/bin/ttx \ + --suffix PATH : ${lib.makeBinPath runtimeDeps} + ''; + }; + mkApp = pkg: { type = "app"; program = "${pkg}/bin/ttx"; @@ -48,8 +72,14 @@ ttx-lib = mkLibPackage pkgs.stdenv "ttx-lib" "dius"; ttx-lib-dius-runtime = mkLibPackage pkgs.stdenv "ttx-lib-dius-runtime" "dius-runtime"; - ttx = mkAppPackage pkgs.stdenv "ttx" ttx-lib; - ttx-dius-runtime = mkAppPackage pkgs.stdenv "ttx-dius-runtime" ttx-lib-dius-runtime; + ttx-unwrapped = mkUnwrappedPackage pkgs.stdenv "ttx-unwrapped" ttx-lib; + ttx-dius-runtime-unwrapped = + mkUnwrappedPackage pkgs.stdenv "ttx-dius-runtime-unwrapped" + ttx-lib-dius-runtime; + ttx = mkWrappedPackage pkgs.stdenv "ttx" ttx-unwrapped pkgs.fzf; + ttx-dius-runtime = + mkWrappedPackage pkgs.stdenv "ttx-dius-runtime" ttx-dius-runtime-unwrapped + pkgs.fzf; ttx-app = mkApp ttx; ttx-app-dius-runtime = mkApp ttx-dius-runtime; diff --git a/src/action.h b/src/action.h index 4d7e8fb..e26937e 100644 --- a/src/action.h +++ b/src/action.h @@ -9,7 +9,7 @@ struct ActionContext { KeyEvent const& key_event; di::Synchronized& layout_state; RenderThread& render_thread; - di::Vector const& command; + di::Vector const& command; di::Atomic& done; }; diff --git a/src/actions.cpp b/src/actions.cpp index bc99dbb..013f3ce 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -2,6 +2,7 @@ #include "action.h" #include "di/format/prelude.h" +#include "fzf.h" #include "tab.h" #include "ttx/layout.h" @@ -75,7 +76,55 @@ auto create_tab() -> Action { .apply = [](ActionContext const& context) { context.layout_state.with_lock([&](LayoutState& state) { - (void) state.add_tab({ .command = di::clone(context.command) }, context.render_thread); + for (auto& session : state.active_session()) { + (void) state.add_tab(session, { .command = di::clone(context.command) }, context.render_thread); + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto rename_tab() -> Action { + return { + .description = "Rename the current active tab"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + if (!session.active_tab()) { + return; + } + auto& tab = session.active_tab().value(); + + auto [create_pane_args, popup_layout] = Fzf() + .as_text_box() + .with_title("Rename Tab"_s) + .with_prompt("Name"_s) + .with_query(tab.name().to_owned()) + .popup_args(); + create_pane_args.hooks.did_finish_output = di::make_function( + [&layout_state = context.layout_state, &tab, + &render_thread = context.render_thread](di::StringView contents) { + while (contents.ends_with(U'\n')) { + contents = contents.substr(contents.begin(), --contents.end()); + } + if (contents.empty()) { + return; + } + layout_state.with_lock([&](LayoutState&) { + // NOTE: we take the layout state lock to prevent data races. Also, the tab is + // guaranteed to still be alive because tabs won't be killed until all their panes + // have exited. And we put the popup in the tab. + tab.set_name(contents.to_owned()); + }); + render_thread.request_render(); + }); + if (auto tab = state.active_tab()) { + (void) state.popup_pane(session, tab.value(), popup_layout, di::move(create_pane_args), + context.render_thread); + } + } }); context.render_thread.request_render(); }, @@ -89,8 +138,254 @@ auto switch_tab(usize index) -> Action { .apply = [index](ActionContext const& context) { context.layout_state.with_lock([&](LayoutState& state) { - if (auto tab = state.tabs().at(index - 1)) { - state.set_active_tab(tab.value().get()); + for (auto& session : state.active_session()) { + if (auto tab = session.tabs().at(index - 1)) { + state.set_active_tab(session, tab.value().get()); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto switch_next_tab() -> Action { + return { + .description = "Switch to the next tab by numeric index"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + for (auto& tab : session.active_tab()) { + auto tabs = session.tabs() | di::transform(&di::Box::get); + auto it = di::find(tabs, &tab); + if (it == tabs.end()) { + return; + } + auto index = usize(it - tabs.begin()); + index++; + index %= tabs.size(); + state.set_active_tab(session, tabs[isize(index)]); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto switch_prev_tab() -> Action { + return { + .description = "Switch to the previous tab by numeric index"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + for (auto& tab : session.active_tab()) { + auto tabs = session.tabs() | di::transform(&di::Box::get); + auto it = di::find(tabs, &tab); + if (it == tabs.end()) { + return; + } + auto index = usize(it - tabs.begin()); + index += tabs.size(); + index--; + index %= tabs.size(); + state.set_active_tab(session, tabs[isize(index)]); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto find_tab() -> Action { + return { + .description = "Find a tab in the current session by name using fzf"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + auto tab_names = di::Vector(); + if (auto session = state.active_session()) { + for (auto [i, tab] : session.value().tabs() | di::enumerate) { + tab_names.push_back(*di::present("{} {}"_sv, i + 1, tab->name())); + } + + auto [create_pane_args, popup_layout] = Fzf() + .with_prompt("Switch to tab"_s) + .with_title("Tabs"_s) + .with_input(di::move(tab_names)) + .popup_args(); + create_pane_args.hooks.did_finish_output = di::make_function( + [&layout_state = context.layout_state, &render_thread = context.render_thread, + &session](di::StringView contents) { + // Try to parse the first index. This will fail if contents is empty. + auto maybe_tab_index = di::parse_partial(contents); + if (!maybe_tab_index || maybe_tab_index == 0) { + return; + } + auto tab_index = maybe_tab_index.value() - 1; + layout_state.with_lock([&](LayoutState& state) { + if (auto tab = session.value().tabs().at(tab_index)) { + state.set_active_tab(session.value(), tab.value().get()); + } + }); + render_thread.request_render(); + }); + if (auto tab = state.active_tab()) { + (void) state.popup_pane(session.value(), tab.value(), popup_layout, + di::move(create_pane_args), context.render_thread); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto create_session() -> Action { + return { + .description = "Create a new session"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + (void) state.add_session({ .command = di::clone(context.command) }, context.render_thread); + }); + context.render_thread.request_render(); + }, + }; +} + +auto rename_session() -> Action { + return { + .description = "Rename the current active session"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + auto [create_pane_args, popup_layout] = Fzf() + .as_text_box() + .with_title("Rename Session"_s) + .with_prompt("Name"_s) + .with_query(session.name().to_owned()) + .popup_args(); + create_pane_args.hooks.did_finish_output = di::make_function( + [&layout_state = context.layout_state, &session, + &render_thread = context.render_thread](di::StringView contents) { + while (contents.ends_with(U'\n')) { + contents = contents.substr(contents.begin(), --contents.end()); + } + if (contents.empty()) { + return; + } + layout_state.with_lock([&](LayoutState&) { + // NOTE: we take the layout state lock to prevent data races. Also, the session is + // guaranteed to still be alive because sessions won't be killed until all their + // panes have exited. And we put the popup in the session. + session.set_name(contents.to_owned()); + }); + render_thread.request_render(); + }); + if (auto tab = state.active_tab()) { + (void) state.popup_pane(session, tab.value(), popup_layout, di::move(create_pane_args), + context.render_thread); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto switch_next_session() -> Action { + return { + .description = "Switch to the next session by creation order"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + auto sessions = state.sessions() | di::transform([](Session& session) { + return &session; + }); + auto it = di::find(sessions, &session); + if (it == sessions.end()) { + return; + } + auto index = usize(it - sessions.begin()); + index++; + index %= sessions.size(); + state.set_active_session(sessions[isize(index)]); + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto switch_prev_session() -> Action { + return { + .description = "Switch to the previous session by creation order"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& session : state.active_session()) { + auto sessions = state.sessions() | di::transform([](Session& session) { + return &session; + }); + auto it = di::find(sessions, &session); + if (it == sessions.end()) { + return; + } + auto index = usize(it - sessions.begin()); + index += sessions.size(); + index--; + index %= sessions.size(); + state.set_active_session(sessions[isize(index)]); + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto find_session() -> Action { + return { + .description = "Find a session by name using fzf"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + auto session_names = di::Vector(); + for (auto [i, session] : state.sessions() | di::enumerate) { + session_names.push_back(*di::present("{} {}"_sv, i + 1, session.name())); + } + + auto [create_pane_args, popup_layout] = Fzf() + .with_prompt("Switch to session"_s) + .with_title("Sessions"_s) + .with_input(di::move(session_names)) + .popup_args(); + create_pane_args.hooks.did_finish_output = di::make_function( + [&layout_state = context.layout_state, + &render_thread = context.render_thread](di::StringView contents) { + // Try to parse the first index. This will fail if contents is empty. + auto maybe_session_index = di::parse_partial(contents); + if (!maybe_session_index || maybe_session_index == 0) { + return; + } + auto session_index = maybe_session_index.value() - 1; + layout_state.with_lock([&](LayoutState& state) { + if (auto session = state.sessions().at(session_index)) { + state.set_active_session(&session.value()); + } + }); + render_thread.request_render(); + }); + if (auto session = state.active_session()) { + if (auto tab = state.active_tab()) { + (void) state.popup_pane(session.value(), tab.value(), popup_layout, + di::move(create_pane_args), context.render_thread); + } } }); context.render_thread.request_render(); @@ -152,17 +447,53 @@ auto exit_pane() -> Action { }; } +auto soft_reset() -> Action { + return { + .description = "Soft reset the active pane"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + if (auto pane = state.active_pane()) { + pane->soft_reset(); + } + }); + context.render_thread.request_render(); + }, + }; +} + +auto toggle_full_screen_pane() -> Action { + return { + .description = "Toggle full screen for the active pane"_s, + .apply = + [](ActionContext const& context) { + context.layout_state.with_lock([&](LayoutState& state) { + for (auto& pane : state.active_pane()) { + auto& tab = *state.active_tab(); + if (&pane == state.full_screen_pane().data()) { + tab.set_full_screen_pane(nullptr); + } else { + tab.set_full_screen_pane(&pane); + } + } + }); + context.render_thread.request_render(); + }, + }; +} + auto add_pane(Direction direction) -> Action { return { .description = *di::present("Add a new pane, in a {} position relative to the active pane"_sv, direction), .apply = [direction](ActionContext const& context) { context.layout_state.with_lock([&](LayoutState& state) { - if (!state.active_tab()) { - return; + for (auto& session : state.active_session()) { + for (auto& tab : session.active_tab()) { + (void) state.add_pane(session, tab, { .command = di::clone(context.command) }, direction, + context.render_thread); + } } - (void) state.add_pane(*state.active_tab(), { .command = di::clone(context.command) }, direction, - context.render_thread); }); context.render_thread.request_render(); }, diff --git a/src/actions.h b/src/actions.h index 994ed20..0ad3919 100644 --- a/src/actions.h +++ b/src/actions.h @@ -10,11 +10,22 @@ auto reset_mode() -> Action; auto navigate(NavigateDirection direction) -> Action; auto resize(ResizeDirection direction, i32 amount_in_cells) -> Action; auto create_tab() -> Action; +auto rename_tab() -> Action; auto switch_tab(usize index) -> Action; +auto switch_next_tab() -> Action; +auto switch_prev_tab() -> Action; +auto find_tab() -> Action; +auto create_session() -> Action; +auto rename_session() -> Action; +auto switch_next_session() -> Action; +auto switch_prev_session() -> Action; +auto find_session() -> Action; auto quit() -> Action; auto save_state(di::Path path) -> Action; auto stop_capture() -> Action; auto exit_pane() -> Action; +auto soft_reset() -> Action; +auto toggle_full_screen_pane() -> Action; auto add_pane(Direction direction) -> Action; auto scroll(Direction direction, i32 amount_in_cells) -> Action; auto send_to_pane() -> Action; diff --git a/src/fzf.cpp b/src/fzf.cpp new file mode 100644 index 0000000..3768180 --- /dev/null +++ b/src/fzf.cpp @@ -0,0 +1,54 @@ +#include "fzf.h" + +#include "di/util/construct.h" +#include "ttx/pane.h" +#include "ttx/popup.h" + +namespace ttx { +auto Fzf::popup_args() && -> di::Tuple { + // Setup the pipes for fzf. + auto create_pane_args = CreatePaneArgs { + .pipe_input = m_input | di::join_with(U'\n') | di::to(), + .pipe_output = true, + }; + + // Build the command string based on the configuration. For now, most + // fields are hard-coded. + create_pane_args.command = di::Array { + "fzf"_ts, "--border"_ts, "--layout"_ts, "reverse"_ts, + "--info"_ts, "inline-right"_ts, "--no-multi"_ts, "--cycle"_ts, + } | di::to(); + if (m_prompt) { + create_pane_args.command.push_back("--prompt"_ts); + + // Add '> ' as a prompt indicator + auto prompt_string = *di::present("{}> "_sv, m_prompt.value()); + create_pane_args.command.push_back(prompt_string.span() | di::transform(di::construct) | + di::to()); + } + if (m_title) { + create_pane_args.command.push_back("--border-label"_ts); + + // Add spacing for padding + auto label_string = *di::present(" {} "_sv, m_title.value()); + create_pane_args.command.push_back(label_string.span() | di::transform(di::construct) | + di::to()); + } + if (m_query) { + create_pane_args.command.push_back("--query"_ts); + create_pane_args.command.push_back(m_query.value().span() | di::transform(di::construct) | + di::to()); + } + if (m_no_info) { + create_pane_args.command.push_back("--no-info"_ts); + } + if (m_no_separator) { + create_pane_args.command.push_back("--no-separator"_ts); + } + if (m_print_query) { + create_pane_args.command.push_back("--print-query"_ts); + } + + return { di::move(create_pane_args), m_layout }; +} +} diff --git a/src/fzf.h b/src/fzf.h new file mode 100644 index 0000000..a3bc2f4 --- /dev/null +++ b/src/fzf.h @@ -0,0 +1,81 @@ +#pragma once + +#include "ttx/pane.h" +#include "ttx/popup.h" + +namespace ttx { +class Fzf { +public: + auto with_prompt(di::String prompt) && -> Fzf { + m_prompt = di::move(prompt); + return di::move(*this); + } + + auto with_input(di::Vector input) && -> Fzf { + m_input = di::move(input); + return di::move(*this); + } + + auto with_title(di::String title) && -> Fzf { + m_title = di::move(title); + return di::move(*this); + } + + auto with_query(di::String query) && -> Fzf { + m_query = di::move(query); + return di::move(*this); + } + + auto with_no_info(bool no_info = true) && -> Fzf { + m_no_info = no_info; + return di::move(*this); + } + + auto with_no_separator(bool no_separator = true) && -> Fzf { + m_no_separator = no_separator; + return di::move(*this); + } + + auto with_print_query(bool print_query = true) && -> Fzf { + m_print_query = print_query; + return di::move(*this); + } + + auto with_alignment(PopupAlignment alignment) && -> Fzf { + m_layout.alignment = alignment; + return di::move(*this); + } + + auto with_width(PopupSize width) && -> Fzf { + m_layout.width = width; + return di::move(*this); + } + + auto with_height(PopupSize height) && -> Fzf { + m_layout.height = height; + return di::move(*this); + } + + /// @brief Convience method which configures fzf like a text box + auto as_text_box() && -> Fzf { + return di::move(*this) + .with_alignment(PopupAlignment::Top) + .with_height(AbsoluteSize(3)) + .with_no_info() + .with_no_separator() + .with_print_query(); + } + + auto popup_args() && -> di::Tuple; + +private: + di::Optional m_prompt; + di::Optional m_title; + di::Optional m_query; + di::Vector m_input; + PopupLayout m_layout; + bool m_no_info { false }; + bool m_no_separator { false }; + bool m_print_query { false }; +}; +} diff --git a/src/input.cpp b/src/input.cpp index 7942f77..8f5c572 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -15,7 +15,7 @@ #include "ttx/utf8_stream_decoder.h" namespace ttx { -auto InputThread::create(di::Vector command, di::Vector key_binds, +auto InputThread::create(di::Vector command, di::Vector key_binds, di::Synchronized& layout_state, RenderThread& render_thread) -> di::Result> { auto result = di::make_box(di::move(command), di::move(key_binds), layout_state, render_thread); @@ -25,7 +25,7 @@ auto InputThread::create(di::Vector command, di::Vect return result; } -InputThread::InputThread(di::Vector command, di::Vector key_binds, +InputThread::InputThread(di::Vector command, di::Vector key_binds, di::Synchronized& layout_state, RenderThread& render_thread) : m_key_binds(di::move(key_binds)) , m_command(di::move(command)) @@ -119,14 +119,37 @@ void InputThread::handle_event(MouseEvent const& event) { } auto& tab = *state.active_tab(); + auto ev = event.translate({ 0, u32(-!state.hide_status_bar()) }, state.size()); + + // Check if we're hitting any popup with the mouse. + auto row = event.position().in_cells().y(); + auto col = event.position().in_cells().x(); + for (auto entry : tab.popup_layout()) { + if (row >= entry.row && row < entry.row + entry.size.rows && col >= entry.col && + col < entry.col + entry.size.cols) { + if (ev.type() != MouseEventType::Move) { + tab.set_active(entry.pane); + } + if (entry.pane->event(ev.translate({ -entry.col, -entry.row }, state.size()))) { + m_render_thread.request_render(); + } + return; + } + } + // Check if the event interests with any pane. for (auto const& entry : - tab.layout_tree()->hit_test(event.position().in_cells().y(), event.position().in_cells().x())) { - if (event.type() != MouseEventType::Move) { + tab.layout_tree()->hit_test(ev.position().in_cells().y(), ev.position().in_cells().x())) { + if (ev.type() != MouseEventType::Move) { + // Set the pane the user just clicked on as active. tab.set_active(entry.pane); + // If we had a popup, exit it as the user clicked out. + for (auto popup_entry : tab.popup_layout()) { + popup_entry.pane->exit(); + } } if (entry.pane == tab.active().data()) { - if (entry.pane->event(event.translate({ -entry.col, -entry.row }, state.size()))) { + if (entry.pane->event(ev.translate({ -entry.col, -entry.row }, state.size()))) { m_render_thread.request_render(); } } diff --git a/src/input.h b/src/input.h index 14482dc..c845e97 100644 --- a/src/input.h +++ b/src/input.h @@ -13,11 +13,11 @@ class RenderThread; class InputThread { public: - static auto create(di::Vector command, di::Vector key_binds, + static auto create(di::Vector command, di::Vector key_binds, di::Synchronized& layout_state, RenderThread& render_thread) -> di::Result>; - explicit InputThread(di::Vector command, di::Vector key_binds, + explicit InputThread(di::Vector command, di::Vector key_binds, di::Synchronized& layout_state, RenderThread& render_thread); ~InputThread(); @@ -35,7 +35,7 @@ class InputThread { InputMode m_mode { InputMode::Insert }; di::Vector m_key_binds; - di::Vector m_command; + di::Vector m_command; di::Atomic m_done { false }; di::Synchronized& m_layout_state; RenderThread& m_render_thread; diff --git a/src/key_bind.cpp b/src/key_bind.cpp index 7f4b5e5..b3d284f 100644 --- a/src/key_bind.cpp +++ b/src/key_bind.cpp @@ -15,6 +15,16 @@ static auto make_switch_tab_binds(di::Vector& result) { .action = switch_tab(i + 1), }); } + result.push_back({ + .key = Key::P, + .mode = InputMode::Normal, + .action = switch_prev_tab(), + }); + result.push_back({ + .key = Key::N, + .mode = InputMode::Normal, + .action = switch_next_tab(), + }); } static auto make_navigate_binds(di::Vector& result, InputMode mode, InputMode next_mode) { @@ -94,6 +104,11 @@ static auto make_replay_key_binds() -> di::Vector { .mode = InputMode::Insert, .action = scroll(Direction::Horizontal, -1), }); + result.push_back({ + .key = Key::Z, + .mode = InputMode::Insert, + .action = toggle_full_screen_pane(), + }); make_navigate_binds(result, InputMode::Insert, InputMode::Insert); return result; @@ -143,6 +158,22 @@ auto make_key_binds(Key prefix, di::Path save_state_path, bool replay_mode) -> d .mode = InputMode::Normal, .action = quit(), }); + result.push_back({ + .key = Key::F, + .mode = InputMode::Normal, + .action = find_tab(), + }); + result.push_back({ + .key = Key::Comma, + .mode = InputMode::Normal, + .action = rename_tab(), + }); + result.push_back({ + .key = Key::R, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = soft_reset(), + }); result.push_back({ .key = Key::I, .modifiers = Modifiers::Shift, @@ -161,11 +192,52 @@ auto make_key_binds(Key prefix, di::Path save_state_path, bool replay_mode) -> d .action = exit_pane(), }); result.push_back({ - .key = Key::BackSlash, - .modifiers = Modifiers::Shift, + .key = Key::Z, .mode = InputMode::Normal, - .action = add_pane(Direction::Horizontal), + .action = toggle_full_screen_pane(), }); + result.push_back({ + .key = Key::C, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = create_session(), + }), + result.push_back({ + .key = Key::_4, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = rename_session(), + }), + result.push_back({ + .key = Key::C, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = create_session(), + }), + result.push_back({ + .key = Key::F, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = find_session(), + }), + result.push_back({ + .key = Key::_9, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = switch_prev_session(), + }), + result.push_back({ + .key = Key::_0, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = switch_next_session(), + }), + result.push_back({ + .key = Key::BackSlash, + .modifiers = Modifiers::Shift, + .mode = InputMode::Normal, + .action = add_pane(Direction::Horizontal), + }); result.push_back({ .key = Key::Minus, .mode = InputMode::Normal, diff --git a/src/layout_state.cpp b/src/layout_state.cpp index 695c9cb..383f2e5 100644 --- a/src/layout_state.cpp +++ b/src/layout_state.cpp @@ -1,5 +1,9 @@ #include "layout_state.h" +#include "render.h" +#include "session.h" +#include "ttx/pane.h" + namespace ttx { LayoutState::LayoutState(Size const& size, bool hide_status_bar) : m_size(size), m_hide_status_bar(hide_status_bar) {} @@ -10,95 +14,117 @@ void LayoutState::layout(di::Optional size) { m_size = size.value(); } - if (!m_active_tab) { + if (!m_active_session) { return; } if (hide_status_bar()) { // Now status bar when forcing a single pane. - m_active_tab->layout(m_size, 0, 0); + m_active_session->layout(m_size); } else { - m_active_tab->layout(m_size.rows_shrinked(1), 1, 0); + m_active_session->layout(m_size.rows_shrinked(1)); } } -auto LayoutState::set_active_tab(Tab* tab) -> bool { - if (m_active_tab == tab) { +auto LayoutState::set_active_session(Session* session) -> bool { + if (m_active_session == session) { return false; } - // Update tab with the new active status, and force layout - // when switching ot a new tab. This is needed because size - // changes are only sent to the active tab, so the old layout - // could be stale. - if (m_active_tab) { - m_active_tab->set_is_active(false); + if (m_active_session) { + m_active_session->set_is_active(false); } - m_active_tab = tab; - if (m_active_tab) { - m_active_tab->set_is_active(true); + m_active_session = session; + if (m_active_session) { + m_active_session->set_is_active(true); + // Force a layout() when switching which session is rendered, + // since we resize things only when rendered. layout(); } return true; } -void LayoutState::remove_tab(Tab& tab) { - // For now, ASSERT() there are no panes in the tab. If there were, we'd +auto LayoutState::set_active_tab(Session& session, Tab* tab) -> bool { + set_active_session(&session); + return session.set_active_tab(tab); +} + +void LayoutState::remove_tab(Session& session, Tab& tab) { + session.remove_tab(tab); + if (session.empty()) { + remove_session(session); + } +} + +auto LayoutState::remove_pane(Session& session, Tab& tab, Pane* pane) -> di::Box { + auto result = session.remove_pane(tab, pane); + if (session.empty()) { + remove_session(session); + } + return result; +} + +void LayoutState::remove_session(Session& session) { + // For now, ASSERT() there are no panes in the session. If there were, we'd // need to make sure not to destroy the panes while we hold the lock. - ASSERT(tab.empty()); - - // Clear active tab. - if (m_active_tab == &tab) { - auto* it = di::find(m_tabs, &tab, &di::Box::get); - if (it == m_tabs.end()) { - set_active_tab(m_tabs.at(0).transform(&di::Box::get).value_or(nullptr)); - } else if (m_tabs.size() == 1) { - set_active_tab(nullptr); + ASSERT(session.empty()); + + // Clear active session. + if (m_active_session == &session) { + auto* it = di::find(m_sessions, &session, [](Session const& session) { + return &session; + }); + if (it == m_sessions.end()) { + set_active_session(m_sessions.at(0).data()); + } else if (m_sessions.size() == 1) { + set_active_session(nullptr); } else { - auto index = usize(it - m_tabs.begin()); - if (index == m_tabs.size() - 1) { - set_active_tab(m_tabs[index - 1].get()); + auto index = usize(it - m_sessions.begin()); + if (index == m_sessions.size() - 1) { + set_active_session(&m_sessions[index - 1]); } else { - set_active_tab(m_tabs[index + 1].get()); + set_active_session(&m_sessions[index + 1]); } } } - // Delete tab. - di::erase_if(m_tabs, [&](di::Box const& pointer) { - return pointer.get() == &tab; + // Delete session. + di::erase_if(m_sessions, [&](Session const& item) { + return &item == &session; }); } -auto LayoutState::remove_pane(Tab& tab, Pane* pane) -> di::Box { - auto result = tab.remove_pane(pane); - if (tab.empty()) { - remove_tab(tab); - } else if (result && &tab == m_active_tab) { - layout(); - } - return result; +auto LayoutState::add_pane(Session& session, Tab& tab, CreatePaneArgs args, Direction direction, + RenderThread& render_thread) -> di::Result<> { + set_active_session(&session); + return session.add_pane(tab, m_next_pane_id++, di::move(args), direction, render_thread); } -auto LayoutState::add_pane(Tab& tab, CreatePaneArgs args, Direction direction, RenderThread& render_thread) - -> di::Result<> { - if (hide_status_bar()) { - return tab.add_pane(m_next_pane_id++, m_size, 0, 0, di::move(args), direction, render_thread); - } - return tab.add_pane(m_next_pane_id++, m_size.rows_shrinked(1), 1, 0, di::move(args), direction, render_thread); +auto LayoutState::popup_pane(Session& session, Tab& tab, PopupLayout const& popup_layout, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<> { + set_active_session(&session); + return session.popup_pane(tab, m_next_pane_id++, popup_layout, di::move(args), render_thread); } -auto LayoutState::add_tab(CreatePaneArgs args, RenderThread& render_thread) -> di::Result<> { - auto name = args.replay_path ? "capture"_s - : di::back(di::PathView(args.command[0])).value_or(""_tsv) | di::transform([](char c) { - return c32(c); - }) | di::to(); - auto tab = di::make_box(di::move(name)); - TRY(add_pane(*tab, di::move(args), Direction::None, render_thread)); +auto LayoutState::add_tab(Session& session, CreatePaneArgs args, RenderThread& render_thread) -> di::Result<> { + set_active_session(&session); + return session.add_tab(di::move(args), m_next_pane_id++, render_thread); +} - set_active_tab(tab.get()); - m_tabs.push_back(di::move(tab)); +auto LayoutState::add_session(CreatePaneArgs args, RenderThread& render_thread) -> di::Result<> { + auto name = di::to_string(m_next_session_id++); + auto& session = m_sessions.emplace_back(di::move(name)); + return add_tab(session, di::move(args), render_thread); +} - return {}; +auto LayoutState::active_session() const -> di::Optional { + if (!m_active_session) { + return {}; + } + return *m_active_session; +} + +auto LayoutState::active_tab() const -> di::Optional { + return active_session().and_then(&Session::active_tab); } auto LayoutState::active_pane() const -> di::Optional { @@ -107,4 +133,11 @@ auto LayoutState::active_pane() const -> di::Optional { } return active_tab()->active(); } + +auto LayoutState::full_screen_pane() const -> di::Optional { + if (!active_tab()) { + return {}; + } + return active_tab()->full_screen_pane(); +} } diff --git a/src/layout_state.h b/src/layout_state.h index 6404e66..e0c8ff7 100644 --- a/src/layout_state.h +++ b/src/layout_state.h @@ -1,8 +1,10 @@ #pragma once #include "di/container/vector/vector.h" +#include "session.h" #include "tab.h" #include "ttx/layout.h" +#include "ttx/popup.h" namespace ttx { class LayoutState { @@ -10,31 +12,38 @@ class LayoutState { explicit LayoutState(Size const& size, bool hide_status_bar); void layout(di::Optional size = {}); - auto set_active_tab(Tab* tab) -> bool; - void remove_tab(Tab& tab); - auto remove_pane(Tab& tab, Pane* pane) -> di::Box; - - auto add_pane(Tab& tab, CreatePaneArgs args, Direction direction, RenderThread& render_thread) -> di::Result<>; - auto add_tab(CreatePaneArgs args, RenderThread& render_thread) -> di::Result<>; - - auto empty() const -> bool { return m_tabs.empty(); } - auto tabs() -> di::Vector>& { return m_tabs; } - auto active_tab() const -> di::Optional { - if (!m_active_tab) { - return {}; - } - return *m_active_tab; - } + auto set_active_tab(Session& session, Tab* tab) -> bool; + void remove_tab(Session& session, Tab& tab); + auto remove_pane(Session& session, Tab& tab, Pane* pane) -> di::Box; + + auto add_pane(Session& session, Tab& tab, CreatePaneArgs args, Direction direction, RenderThread& render_thread) + -> di::Result<>; + auto popup_pane(Session& session, Tab& tab, PopupLayout const& popup_layout, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<>; + auto add_tab(Session& session, CreatePaneArgs args, RenderThread& render_thread) -> di::Result<>; + + auto sessions() -> di::Vector& { return m_sessions; } + auto add_session(CreatePaneArgs args, RenderThread& render_thread) -> di::Result<>; + void remove_session(Session& session); + auto set_active_session(Session* session) -> bool; + auto active_session() const -> di::Optional; + + auto empty() const -> bool { return m_sessions.empty(); } + auto active_tab() const -> di::Optional; auto active_pane() const -> di::Optional; + auto full_screen_pane() const -> di::Optional; auto size() const -> Size { return m_size; } auto hide_status_bar() const -> bool { return m_hide_status_bar; } + auto active_popup() const -> di::Optional; + private: Size m_size; - di::Vector> m_tabs {}; - Tab* m_active_tab { nullptr }; + di::Vector m_sessions; + Session* m_active_session { nullptr }; u64 m_next_pane_id { 1 }; + u64 m_next_session_id { 1 }; bool m_hide_status_bar { false }; }; } diff --git a/src/render.cpp b/src/render.cpp index ef9876b..98c4c26 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -35,8 +35,12 @@ void RenderThread::render_thread() { }); auto renderer = Renderer(); + auto _ = di::ScopeExit([&] { + (void) renderer.cleanup(dius::stdin); + }); auto deadline = dius::SteadyClock::now(); + auto do_setup = true; for (;;) { while (deadline < dius::SteadyClock::now()) { deadline += di::Milliseconds(25); // 50 FPS @@ -65,11 +69,16 @@ void RenderThread::render_thread() { if (auto ev = di::get_if(event)) { // Do layout. m_layout_state.lock()->layout(ev.value()); + + // Force doing a resetting the terminal mode on SIGWINCH. + // This is to enable make putting ttx in a "dumb" session + // persistence program (like dtach) work correctly. + do_setup = true; } else if (auto ev = di::get_if(event)) { // Exit pane. - auto should_exit = m_layout_state.with_lock([&](LayoutState& state) { - state.remove_pane(*ev->tab, ev->pane); - return state.empty(); + auto [pane, should_exit] = m_layout_state.with_lock([&](LayoutState& state) { + auto pane = state.remove_pane(*ev->session, *ev->tab, ev->pane); + return di::Tuple { di::move(pane), state.empty() }; }); if (should_exit) { return; @@ -86,6 +95,12 @@ void RenderThread::render_thread() { } } + // Do terminal setup if requested. + if (do_setup) { + (void) renderer.setup(dius::stdin); + do_setup = false; + } + // Do render. do_render(renderer); } @@ -106,6 +121,7 @@ struct Render { di::Optional& cursor; Tab& tab; LayoutState& state; + bool have_status_bar { false }; void operator()(di::Box const& node) { (*this)(*node); } @@ -117,14 +133,14 @@ struct Render { auto [row, col, size] = di::visit(PositionAndSize {}, child); renderer.set_bound(0, 0, state.size().cols, state.size().rows); if (node.direction == Direction::Horizontal) { - for (auto r : di::range(row, row + size.rows)) { + for (auto r : di::range(row + have_status_bar, row + have_status_bar + size.rows)) { auto code_point = U'│'; renderer.put_text(code_point, r, col - 1); } } else if (node.direction == Direction::Vertical) { for (auto c : di::range(col, col + size.cols)) { auto code_point = U'─'; - renderer.put_text(code_point, row - 1, c); + renderer.put_text(code_point, row + have_status_bar - 1, c); } } } @@ -135,9 +151,10 @@ struct Render { } void operator()(LayoutEntry const& entry) { - renderer.set_bound(entry.row, entry.col, entry.size.cols, entry.size.rows); + renderer.set_bound(entry.row + have_status_bar, entry.col, entry.size.cols, entry.size.rows); auto pane_cursor = entry.pane->draw(renderer); if (entry.pane == tab.active().data()) { + pane_cursor.cursor_row += have_status_bar; pane_cursor.cursor_row += entry.row; pane_cursor.cursor_col += entry.col; cursor = pane_cursor; @@ -163,22 +180,49 @@ void RenderThread::do_render(Renderer& renderer) { // Status bar. if (!state.hide_status_bar()) { - auto text = di::enumerate(state.tabs()) | di::transform(di::uncurry([&](usize i, di::Box const& tab) { - return *di::present("[{}{} {}]"_sv, tab.get() == active_tab.data() ? U'*' : U' ', i + 1, - tab->name()); - })) | - di::join_with(U' ') | di::to(); - renderer.clear_row(0); - renderer.put_text(di::to_string(m_input_status.mode).view(), 0, 0, - GraphicsRendition { - .font_weight = FontWeight::Bold, - }); - renderer.put_text(text.view(), 0, 7); + for (auto& session : state.active_session()) { + auto text = di::enumerate(session.tabs()) | + di::transform(di::uncurry([&](usize i, di::Box const& tab) { + auto sign = U' '; + if (tab.get() == active_tab.data()) { + if (tab->full_screen_pane()) { + sign = U'+'; + } else { + sign = U'*'; + } + } + return *di::present("[{}{} {}]"_sv, sign, i + 1, tab->name()); + })) | + di::join_with(U' ') | di::to(); + renderer.clear_row(0); + renderer.put_text(di::to_string(m_input_status.mode).view(), 0, 0, + GraphicsRendition { + .font_weight = FontWeight::Bold, + }); + renderer.put_text(text.view(), 0, 7); + + // TODO: horizontal scrolling on overflow + + // TODO: this code isn't correct if the session name contains any multi-code point grapheme clusters. + // TODO: handle case where session name is longer than the status bar width. + auto session_text = *di::present("[{}]"_sv, session.name()); + renderer.put_text(session_text.view(), 0, state.size().cols - di::distance(session_text)); + } } auto cursor = di::Optional {}; - Render(renderer, cursor, tab, state)(*tree); + // First render all panes in the layout tree. + auto render_fn = Render(renderer, cursor, tab, state, !state.hide_status_bar()); + render_fn(*tree); + + // If there is a popup, render it. + for (auto popup_layout : tab.popup_layout()) { + // For now, always invalidate the popup since we don't have proper damage tracking when + // panes overlap. + popup_layout.pane->invalidate_all(); + render_fn(popup_layout); + } (void) renderer.finish(dius::stdin, cursor.value_or({ .hidden = true })); }); diff --git a/src/render.h b/src/render.h index 6e9c68e..9308146 100644 --- a/src/render.h +++ b/src/render.h @@ -10,6 +10,7 @@ namespace ttx { struct PaneExited { + Session* session = nullptr; Tab* tab = nullptr; Pane* pane = nullptr; }; diff --git a/src/session.cpp b/src/session.cpp new file mode 100644 index 0000000..d3c9bce --- /dev/null +++ b/src/session.cpp @@ -0,0 +1,135 @@ +#include "session.h" + +#include "ttx/pane.h" + +namespace ttx { +void Session::layout(di::Optional size) { + if (!size) { + size = m_size; + } else { + m_size = size.value(); + } + + if (!m_active_tab) { + return; + } + m_active_tab->layout(m_size); +} + +auto Session::set_active_tab(Tab* tab) -> bool { + if (m_active_tab == tab) { + return false; + } + + // Update tab with the new active status, only in cases + // where this session is active. + if (is_active() && m_active_tab) { + m_active_tab->set_is_active(false); + } + m_active_tab = tab; + if (is_active() && m_active_tab) { + m_active_tab->set_is_active(true); + layout(); + } + return true; +} + +void Session::remove_tab(Tab& tab) { + // For now, ASSERT() there are no panes in the tab. If there were, we'd + // need to make sure not to destroy the panes while we hold the lock. + ASSERT(tab.empty()); + + // Clear active tab. + if (m_active_tab == &tab) { + auto* it = di::find(m_tabs, &tab, &di::Box::get); + if (it == m_tabs.end()) { + set_active_tab(m_tabs.at(0).transform(&di::Box::get).value_or(nullptr)); + } else if (m_tabs.size() == 1) { + set_active_tab(nullptr); + } else { + auto index = usize(it - m_tabs.begin()); + if (index == m_tabs.size() - 1) { + set_active_tab(m_tabs[index - 1].get()); + } else { + set_active_tab(m_tabs[index + 1].get()); + } + } + } + + // Delete tab. + di::erase_if(m_tabs, [&](di::Box const& pointer) { + return pointer.get() == &tab; + }); +} + +auto Session::remove_pane(Tab& tab, Pane* pane) -> di::Box { + auto result = tab.remove_pane(pane); + if (tab.empty()) { + remove_tab(tab); + } else if (result && &tab == m_active_tab) { + layout(); + } + return result; +} + +auto Session::add_pane(Tab& tab, u64 pane_id, CreatePaneArgs args, Direction direction, RenderThread& render_thread) + -> di::Result<> { + return tab.add_pane(pane_id, m_size, di::move(args), direction, render_thread); +} + +auto Session::popup_pane(Tab& tab, u64 pane_id, PopupLayout const& popup_layout, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<> { + return tab.popup_pane(pane_id, popup_layout, m_size, di::move(args), render_thread); +} + +auto Session::add_tab(CreatePaneArgs args, u64 pane_id, RenderThread& render_thread) -> di::Result<> { + auto name = args.replay_path ? "capture"_s + : di::back(di::PathView(args.command[0])).value_or(""_tsv) | di::transform([](char c) { + return c32(c); + }) | di::to(); + auto tab = di::make_box(this, di::move(name)); + TRY(add_pane(*tab, pane_id, di::move(args), Direction::None, render_thread)); + + set_active_tab(tab.get()); + m_tabs.push_back(di::move(tab)); + + return {}; +} + +auto Session::active_tab() const -> di::Optional { + if (!m_active_tab) { + return {}; + } + return *m_active_tab; +} + +auto Session::active_pane() const -> di::Optional { + if (!active_tab()) { + return {}; + } + return active_tab()->active(); +} + +auto Session::full_screen_pane() const -> di::Optional { + if (!active_tab()) { + return {}; + } + return active_tab()->full_screen_pane(); +} + +auto Session::set_is_active(bool b) -> bool { + if (m_is_active == b) { + return false; + } + + // Send focus in/out events appropriately. + if (is_active() && m_active_tab) { + m_active_tab->set_is_active(false); + } + m_is_active = b; + if (is_active() && m_active_tab) { + m_active_tab->set_is_active(true); + } + return true; +} +} diff --git a/src/session.h b/src/session.h new file mode 100644 index 0000000..599fc3a --- /dev/null +++ b/src/session.h @@ -0,0 +1,45 @@ +#pragma once + +#include "di/container/vector/vector.h" +#include "tab.h" +#include "ttx/popup.h" + +namespace ttx { +/// @brief Represents a "session" (like in tmux) +class Session { +public: + explicit Session(di::String name) : m_name(di::move(name)) {} + + void layout(di::Optional size = {}); + auto set_active_tab(Tab* tab) -> bool; + void remove_tab(Tab& tab); + auto remove_pane(Tab& tab, Pane* pane) -> di::Box; + + void set_name(di::String name) { m_name = di::move(name); } + auto name() const -> di::StringView { return m_name; } + + auto add_pane(Tab& tab, u64 pane_id, CreatePaneArgs args, Direction direction, RenderThread& render_thread) + -> di::Result<>; + auto popup_pane(Tab& tab, u64 pane_id, PopupLayout const& popup_layout, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<>; + auto add_tab(CreatePaneArgs args, u64 pane_id, RenderThread& render_thread) -> di::Result<>; + + auto empty() const -> bool { return m_tabs.empty(); } + auto tabs() -> di::Vector>& { return m_tabs; } + auto active_tab() const -> di::Optional; + + auto active_pane() const -> di::Optional; + auto full_screen_pane() const -> di::Optional; + auto size() const -> Size { return m_size; } + + auto is_active() const -> bool { return m_is_active; } + auto set_is_active(bool b) -> bool; + +private: + di::String m_name; + Size m_size; + di::Vector> m_tabs {}; + Tab* m_active_tab { nullptr }; + bool m_is_active { false }; +}; +} diff --git a/src/tab.cpp b/src/tab.cpp index a030222..3241fb9 100644 --- a/src/tab.cpp +++ b/src/tab.cpp @@ -3,11 +3,27 @@ #include "di/serialization/base64.h" #include "dius/print.h" #include "render.h" +#include "ttx/direction.h" +#include "ttx/popup.h" namespace ttx { -void Tab::layout(Size const& size, u32 row, u32 col) { +void Tab::layout(Size const& size) { m_size = size; - m_layout_tree = m_layout_root.layout(size, row, col); + + if (m_popup) { + m_popup_layout = m_popup.value().layout(size); + } + + if (m_full_screen_pane) { + // In full screen mode, circumvent ordinary layout. + m_full_screen_pane->resize(m_size); + m_layout_tree = + di::make_box(0, 0, size, di::Vector, LayoutEntry>> {}, nullptr, + &m_layout_root, Direction::None); + m_layout_tree->children.emplace_back(LayoutEntry { 0, 0, size, m_layout_tree.get(), m_full_screen_pane }); + } else { + m_layout_tree = m_layout_root.layout(size, 0, 0); + } invalidate_all(); } @@ -18,23 +34,38 @@ void Tab::invalidate_all() { } auto Tab::remove_pane(Pane* pane) -> di::Box { + // Clear full screen pane. The caller makes sure to call layout() for us. + if (m_full_screen_pane == pane) { + m_full_screen_pane = nullptr; + } + + if (pane) { + di::erase(m_panes_ordered_by_recency, pane); + } + // Clear active pane. if (m_active == pane) { - if (pane) { - di::erase(m_panes_ordered_by_recency, pane); - } auto candidates = m_panes_ordered_by_recency | di::transform([](Pane* pane) { return pane; }); set_active(candidates.front().value_or(nullptr)); } + // Clean the popup information if this pane was a popup. In this case, + // we don't return try to remove the pane from the layout tree. + if (m_popup && m_popup.value().pane.get() == pane) { + auto result = di::move(m_popup).value().pane; + m_popup_layout = {}; + m_popup = {}; + return result; + } + return m_layout_root.remove_pane(pane); } -auto Tab::add_pane(u64 pane_id, Size const& size, u32 row, u32 col, CreatePaneArgs args, Direction direction, - RenderThread& render_thread) -> di::Result<> { - auto [new_layout, pane_layout, pane_out] = m_layout_root.split(size, row, col, m_active, direction); +auto Tab::add_pane(u64 pane_id, Size const& size, CreatePaneArgs args, Direction direction, RenderThread& render_thread) + -> di::Result<> { + auto [new_layout, pane_layout, pane_out] = m_layout_root.split(size, 0, 0, m_active, direction); if (!pane_layout || !pane_out || pane_layout->size == Size {}) { // NOTE: this happens when the visible terminal size is too small. @@ -42,24 +73,7 @@ auto Tab::add_pane(u64 pane_id, Size const& size, u32 row, u32 col, CreatePaneAr return di::Unexpected(di::BasicError::InvalidArgument); } - auto maybe_pane = Pane::create( - pane_id, di::move(args), pane_layout->size, - [this, &render_thread](Pane& pane) { - render_thread.push_event(PaneExited(this, &pane)); - }, - [&render_thread](Pane&) { - render_thread.request_render(); - }, - [&render_thread](di::Span data) { - auto base64 = di::Base64View(data); - auto string = *di::present("\033]52;;{}\033\\"_sv, base64); - render_thread.push_event(WriteString(di::move(string))); - }, - [&render_thread](di::StringView apc_data) { - // Pass-through APC commands to host terminal. This makes kitty graphics "work". - auto string = *di::present("\033_{}\033\\"_sv, apc_data); - render_thread.push_event(WriteString(di::move(string))); - }); + auto maybe_pane = make_pane(pane_id, di::move(args), size, render_thread); if (!maybe_pane) { m_layout_root.remove_pane(nullptr); return di::Unexpected(di::move(maybe_pane).error()); @@ -73,6 +87,32 @@ auto Tab::add_pane(u64 pane_id, Size const& size, u32 row, u32 col, CreatePaneAr return {}; } +auto Tab::popup_pane(u64 pane_id, PopupLayout const& popup_layout, Size const& size, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<> { + // Prevent creating more than 1 popup. + if (m_popup) { + return di::Unexpected(di::BasicError::InvalidArgument); + } + m_popup = Popup { + .pane = nullptr, + .layout_config = popup_layout, + }; + m_popup_layout = m_popup.value().layout(size); + + auto maybe_pane = make_pane(pane_id, di::move(args), m_popup_layout.value().size, render_thread); + if (!maybe_pane) { + m_popup = {}; + m_popup_layout = {}; + return di::Unexpected(di::move(maybe_pane).error()); + } + m_popup.value().pane = di::move(maybe_pane).value(); + m_popup_layout.value().pane = m_popup.value().pane.get(); + + set_active(m_popup.value().pane.get()); + invalidate_all(); + return {}; +} + void Tab::navigate(NavigateDirection direction) { auto layout_entry = m_layout_tree->find_pane(m_active); if (!layout_entry) { @@ -128,11 +168,34 @@ void Tab::navigate(NavigateDirection direction) { } } +auto Tab::set_full_screen_pane(Pane* pane) -> bool { + if (m_full_screen_pane == pane) { + return false; + } + + if (pane == nullptr) { + m_full_screen_pane = nullptr; + layout(m_size); + return true; + } + + m_full_screen_pane = pane; + set_active(pane); + layout(m_size); + return true; +} + auto Tab::set_active(Pane* pane) -> bool { if (m_active == pane) { return false; } + // Clear full screen pane, if said pane is no longer focused. + if (m_full_screen_pane && m_full_screen_pane != pane) { + m_full_screen_pane = nullptr; + layout(m_size); + } + // Unfocus the old pane, and focus the new pane. if (is_active() && m_active) { m_active->event(FocusEvent::focus_out()); @@ -163,4 +226,33 @@ auto Tab::set_is_active(bool b) -> bool { } return true; } + +auto Tab::make_pane(u64 pane_id, CreatePaneArgs args, Size const& size, RenderThread& render_thread) + -> di::Result> { + if (!args.hooks.did_exit) { + args.hooks.did_exit = [this, &render_thread](Pane& pane) { + render_thread.push_event(PaneExited(m_session, this, &pane)); + }; + } + if (!args.hooks.did_update) { + args.hooks.did_update = [&render_thread](Pane&) { + render_thread.request_render(); + }; + } + if (!args.hooks.did_selection) { + args.hooks.did_selection = [&render_thread](di::Span data) { + auto base64 = di::Base64View(data); + auto string = *di::present("\033]52;;{}\033\\"_sv, base64); + render_thread.push_event(WriteString(di::move(string))); + }; + } + if (!args.hooks.apc_passthrough) { + args.hooks.apc_passthrough = [&render_thread](di::StringView apc_data) { + // Pass-through APC commands to host terminal. This makes kitty graphics "work". + auto string = *di::present("\033_{}\033\\"_sv, apc_data); + render_thread.push_event(WriteString(di::move(string))); + }; + } + return Pane::create(pane_id, di::move(args), size); +} } diff --git a/src/tab.h b/src/tab.h index 04a9658..4f00bad 100644 --- a/src/tab.h +++ b/src/tab.h @@ -4,6 +4,7 @@ #include "di/reflect/prelude.h" #include "ttx/layout.h" #include "ttx/pane.h" +#include "ttx/popup.h" namespace ttx { class RenderThread; @@ -21,19 +22,23 @@ constexpr auto tag_invoke(di::Tag, di::InPlaceType, di::enumerator<"Down", Down>); } +class Session; + // Corresponds to tmux window. -struct Tab { +class Tab { public: - explicit Tab(di::String name) : m_name(di::move(name)) {} + explicit Tab(Session* session, di::String name) : m_session(session), m_name(di::move(name)) {} - void layout(Size const& size, u32 row, u32 col); + void layout(Size const& size); void invalidate_all(); // Returns the removed pane, if found. auto remove_pane(Pane* pane) -> di::Box; - auto add_pane(u64 pane_id, Size const& size, u32 row, u32 col, CreatePaneArgs args, Direction direction, - RenderThread& render_thread) -> di::Result<>; + auto add_pane(u64 pane_id, Size const& size, CreatePaneArgs args, Direction direction, RenderThread& render_thread) + -> di::Result<>; + auto popup_pane(u64 pane_id, PopupLayout const& popup_layout, Size const& size, CreatePaneArgs args, + RenderThread& render_thread) -> di::Result<>; void navigate(NavigateDirection direction); @@ -41,7 +46,9 @@ struct Tab { auto set_active(Pane* pane) -> bool; auto name() const -> di::StringView { return m_name; } - auto empty() const -> bool { return m_layout_root.empty(); } + auto empty() const -> bool { return m_layout_root.empty() && !m_popup; } + + void set_name(di::String name) { m_name = di::move(name); } auto layout_group() -> LayoutGroup& { return m_layout_root; } auto layout_tree() const -> di::Optional { @@ -61,9 +68,23 @@ struct Tab { auto panes() const -> di::Ring const& { return m_panes_ordered_by_recency; } auto set_is_active(bool b) -> bool; - auto is_active() -> bool { return m_is_active; } + auto is_active() const -> bool { return m_is_active; } + + auto full_screen_pane() const -> di::Optional { + if (!m_full_screen_pane) { + return {}; + } + return *m_full_screen_pane; + } + auto set_full_screen_pane(Pane* pane) -> bool; + + auto popup_layout() const -> di::Optional { return m_popup_layout; } private: + auto make_pane(u64 pane_id, CreatePaneArgs args, Size const& size, RenderThread& render_thread) + -> di::Result>; + + Session* m_session { nullptr }; Size m_size; di::String m_name; LayoutGroup m_layout_root {}; @@ -71,5 +92,8 @@ struct Tab { di::Ring m_panes_ordered_by_recency {}; bool m_is_active { false }; Pane* m_active { nullptr }; + Pane* m_full_screen_pane { nullptr }; + di::Optional m_popup; + di::Optional m_popup_layout; }; } diff --git a/src/ttx.cpp b/src/ttx.cpp index a0b422b..040c888 100644 --- a/src/ttx.cpp +++ b/src/ttx.cpp @@ -1,24 +1,13 @@ #include "di/cli/parser.h" -#include "di/container/ring/ring.h" #include "di/container/string/string_view.h" #include "di/io/writer_print.h" -#include "di/serialization/base64.h" #include "di/sync/synchronized.h" -#include "di/util/construct.h" -#include "dius/condition_variable.h" #include "dius/main.h" #include "dius/sync_file.h" #include "dius/system/process.h" -#include "dius/thread.h" -#include "dius/tty.h" #include "input.h" #include "layout_state.h" #include "render.h" -#include "ttx/focus_event.h" -#include "ttx/layout.h" -#include "ttx/pane.h" -#include "ttx/terminal_input.h" -#include "ttx/utf8_stream_decoder.h" namespace ttx { struct Args { @@ -69,6 +58,7 @@ static auto main(Args& args) -> di::Result { dius::eprintln("error: ttx requires at least 1 argument to know what file to replay"_sv); return di::Unexpected(di::BasicError::InvalidArgument); } + auto command = args.command | di::transform(di::to_owned) | di::to(); // Setup - log to file. [[maybe_unused]] auto& log = dius::stderr = TRY(dius::open_sync("/tmp/ttx.log"_pv, dius::OpenMode::WriteClobber)); @@ -79,49 +69,13 @@ static auto main(Args& args) -> di::Result { } // Setup - initial state and terminal size. - auto initial_size = args.headless ? Size { 25, 80, 25 * 16, 80 * 16 } + auto initial_size = args.headless ? Size { 24, 80, 24 * 16, 80 * 16 } : Size::from_window_size(TRY(dius::stdin.get_tty_window_size())); auto layout_state = di::Synchronized(LayoutState(initial_size, args.hide_status_bar)); // Setup - raw mode auto _ = args.headless ? di::ScopeExit(di::Function([] {})) : TRY(dius::stdin.enter_raw_mode()); - // Setup - alternate screen buffer. - di::writer_print(dius::stdin, "\033[?1049h\033[H\033[2J"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[?1049l\033[?25h"_sv); - }); - - // Setup - disable autowrap. - di::writer_print(dius::stdin, "\033[?7l"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[?7h"_sv); - }); - - // Setup - kitty key mode. - di::writer_print(dius::stdin, "\033[>31u"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[(dius::stdin, "\033[?1003h\033[?1006h"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[?1006l\033[?1003l"_sv); - }); - - // Setup - enable focus events. - di::writer_print(dius::stdin, "\033[?1004h"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[?1004l"_sv); - }); - - // Setup - bracketed paste. - di::writer_print(dius::stdin, "\033[?2004h"_sv); - auto _ = di::ScopeExit([&] { - di::writer_print(dius::stdin, "\033[?2004l"_sv); - }); - // Setup - block SIGWINCH. TRY(dius::system::mask_signal(dius::Signal::WindowChange)); @@ -141,8 +95,7 @@ static auto main(Args& args) -> di::Result { }); // Setup - input thread. - auto input_thread = - TRY(InputThread::create(di::clone(args.command), di::move(key_binds), layout_state, *render_thread)); + auto input_thread = TRY(InputThread::create(di::clone(command), di::move(key_binds), layout_state, *render_thread)); auto _ = di::ScopeExit([&] { input_thread->request_exit(); }); @@ -151,9 +104,18 @@ static auto main(Args& args) -> di::Result { auto _ = di::ScopeExit([&] { layout_state.with_lock([&](LayoutState& state) { while (!state.empty()) { - auto& tab = **state.tabs().front(); - for (auto* pane : tab.panes()) { - state.remove_pane(tab, pane); + auto& session = *state.sessions().front(); + while (!session.empty()) { + auto last_tab = session.tabs().size() == 1; + auto& tab = **session.tabs().front(); + for (auto* pane : tab.panes()) { + state.remove_pane(session, tab, pane); + } + // We must explicitly check this because the session object is destroyed + // after the last tab is removed. + if (last_tab) { + break; + } } } }); @@ -164,7 +126,7 @@ static auto main(Args& args) -> di::Result { auto& state = layout_state.get_assuming_no_concurrent_accesses(); for (auto replay_path : args.command) { if (state.empty()) { - TRY(state.add_tab( + TRY(state.add_session( { .replay_path = di::PathView(replay_path).to_owned(), .save_state_path = args.save_state_path.transform(di::to_owned), @@ -172,14 +134,15 @@ static auto main(Args& args) -> di::Result { *render_thread)); } else { // Horizontal split (means vertical layout) - TRY(state.add_pane(*state.active_tab(), { .replay_path = di::PathView(replay_path).to_owned() }, - Direction::Vertical, *render_thread)); + TRY(state.add_pane(*state.active_session(), *state.active_tab(), + { .replay_path = di::PathView(replay_path).to_owned() }, Direction::Vertical, + *render_thread)); } } } else { - TRY(layout_state.get_assuming_no_concurrent_accesses().add_tab( + TRY(layout_state.get_assuming_no_concurrent_accesses().add_session( { - .command = di::clone(args.command), + .command = di::clone(command), .capture_command_output_path = args.capture_command_output_path.transform(di::to_owned), }, *render_thread));