diff --git a/CMakeLists.txt b/CMakeLists.txt index aa4a7ac..ba71a22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,11 +58,16 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp + ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.hpp ${GIT2CPP_SOURCE_DIR}/utils/common.cpp ${GIT2CPP_SOURCE_DIR}/utils/common.hpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.cpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp + ${GIT2CPP_SOURCE_DIR}/utils/output.cpp ${GIT2CPP_SOURCE_DIR}/utils/output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp + ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.cpp diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 3fa1746..c295c7f 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -7,6 +7,7 @@ #include #include "log_subcommand.hpp" +#include "../utils/terminal_pager.hpp" #include "../wrapper/repository_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" @@ -90,6 +91,8 @@ void log_subcommand::run() git_revwalk_new(&walker, repo); git_revwalk_push_head(walker); + terminal_pager pager; + std::size_t i=0; git_oid commit_oid; while (!git_revwalk_next(&commit_oid, walker) && i + +/** + * ANSI escape codes. + * Use `termcolor` for colours. + */ +namespace ansi_code +{ + // Constants. + const std::string bel = "\a"; // ASCII 7, used for audio/visual feedback. + const std::string cursor_to_top = "\e[H"; + const std::string erase_screen = "\e[2J"; + + const std::string enable_alternative_buffer = "\e[?1049h"; + const std::string disable_alternative_buffer = "\e[?1049l"; + + const std::string hide_cursor = "\e[?25l"; + const std::string show_cursor = "\e[?25h"; + + // Functions. + std::string cursor_to_row(size_t row); + + bool is_escape_char(char ch); + + bool is_down_arrow(std::string str); + bool is_up_arrow(std::string str); +} diff --git a/src/utils/output.cpp b/src/utils/output.cpp new file mode 100644 index 0000000..71584b1 --- /dev/null +++ b/src/utils/output.cpp @@ -0,0 +1,23 @@ +#include "output.hpp" + +// OS-specific libraries. +#include + +alternative_buffer::alternative_buffer() +{ + tcgetattr(fileno(stdin), &m_previous_termios); + auto new_termios = m_previous_termios; + // Disable canonical mode (buffered I/O) and echo from stdin to stdout. + new_termios.c_lflag &= (~ICANON & ~ECHO); + tcsetattr(fileno(stdin), TCSANOW, &new_termios); + + std::cout << ansi_code::enable_alternative_buffer; +} + +alternative_buffer::~alternative_buffer() +{ + std::cout << ansi_code::disable_alternative_buffer; + + // Restore previous termios settings. + tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); +} diff --git a/src/utils/output.hpp b/src/utils/output.hpp index 173c2a5..803c20d 100644 --- a/src/utils/output.hpp +++ b/src/utils/output.hpp @@ -1,8 +1,12 @@ #pragma once #include +#include "ansi_code.hpp" #include "common.hpp" +// OS-specific libraries. +#include + // Scope object to hide the cursor. This avoids // cursor twinkling when rewritting the same line // too frequently. @@ -10,11 +14,24 @@ struct cursor_hider : noncopyable_nonmovable { cursor_hider() { - std::cout << "\e[?25l"; + std::cout << ansi_code::hide_cursor; } ~cursor_hider() { - std::cout << "\e[?25h"; + std::cout << ansi_code::show_cursor; } }; + +// Scope object to use alternative output buffer for +// fullscreen interactive terminal input/output. +class alternative_buffer : noncopyable_nonmovable +{ +public: + alternative_buffer(); + + ~alternative_buffer(); + +private: + struct termios m_previous_termios; +}; diff --git a/src/utils/terminal_pager.cpp b/src/utils/terminal_pager.cpp new file mode 100644 index 0000000..e7fe551 --- /dev/null +++ b/src/utils/terminal_pager.cpp @@ -0,0 +1,221 @@ +#include +#include +#include +#include +#include + +// OS-specific libraries. +#include + +#include + +#include "ansi_code.hpp" +#include "output.hpp" +#include "terminal_pager.hpp" + +terminal_pager::terminal_pager() + : m_rows(0), m_columns(0), m_start_row_index(0) +{ + maybe_grab_cout(); +} + +terminal_pager::~terminal_pager() +{ + release_cout(); +} + +std::string terminal_pager::get_input() const +{ + // Blocks until input received. + std::string str; + char ch; + std::cin.get(ch); + str += ch; + + if (ansi_code::is_escape_char(ch)) // Start of ANSI escape sequence. + { + do + { + std::cin.get(ch); + str += ch; + } while (!std::isalpha(ch)); // ANSI escape sequence ends with a letter. + } + + return str; +} + +void terminal_pager::maybe_grab_cout() +{ + // Unfortunately need to access _internal namespace of termcolor to check if a tty. + if (termcolor::_internal::is_atty(std::cout)) + { + // Should we do anything with cerr? + m_cout_rdbuf = std::cout.rdbuf(&m_stringbuf); + } + else + { + m_cout_rdbuf = std::cout.rdbuf(); + } +} + +bool terminal_pager::process_input(std::string input) +{ + if (input.size() == 0) + { + return true; + } + + switch (input[0]) + { + case 'q': + case 'Q': + return true; // Exit pager. + case 'u': + case 'U': + scroll(true, true); // Up a page. + return false; + case 'd': + case 'D': + case ' ': + scroll(false, true); // Down a page. + return false; + case '\n': + scroll(false, false); // Down a line. + return false; + case '\e': // ANSI escape sequence. + // Cannot switch on a std::string. + if (ansi_code::is_up_arrow(input)) + { + scroll(true, false); // Up a line. + return false; + } + else if (ansi_code::is_down_arrow(input)) + { + scroll(false, false); // Down a line. + return false; + } + } + + std::cout << ansi_code::bel; + return false; +} + +void terminal_pager::release_cout() +{ + std::cout.rdbuf(m_cout_rdbuf); +} + +void terminal_pager::render_terminal() const +{ + auto end_row_index = m_start_row_index + m_rows - 1; + + std::cout << ansi_code::erase_screen; + std::cout << ansi_code::cursor_to_top; + + for (size_t i = m_start_row_index; i < end_row_index; i++) + { + if (i >= m_lines.size()) + { + break; + } + std::cout << m_lines[i] << std::endl; + } + + std::cout << ansi_code::cursor_to_row(m_rows); // Move cursor to bottom row of terminal. + std::cout << ":"; +} + +void terminal_pager::scroll(bool up, bool page) +{ + update_terminal_size(); + const auto old_start_row_index = m_start_row_index; + size_t offset = page ? m_rows - 1 : 1; + + if (up) + { + // Care needed to avoid underflow of unsigned size_t. + if (m_start_row_index >= offset) + { + m_start_row_index -= offset; + } + else + { + m_start_row_index = 0; + } + } + else + { + m_start_row_index += offset; + auto end_row_index = m_start_row_index + m_rows - 1; + if (end_row_index > m_lines.size()) + { + m_start_row_index = m_lines.size() - (m_rows - 1); + } + } + + if (m_start_row_index == old_start_row_index) + { + std::cout << ansi_code::bel; + } + else + { + render_terminal(); + } +} + +void terminal_pager::show() +{ + release_cout(); + + split_input_at_newlines(m_stringbuf.view()); + + update_terminal_size(); + if (m_rows == 0 || m_lines.size() <= m_rows - 1) + { + // Don't need to use pager, can display directly. + for (auto line : m_lines) + { + std::cout << line << std::endl; + } + m_lines.clear(); + return; + } + + alternative_buffer alt_buffer; + + m_start_row_index = 0; + render_terminal(); + + bool stop = false; + do + { + stop = process_input(get_input()); + } while (!stop); + + m_lines.clear(); + m_start_row_index = 0; +} + +void terminal_pager::split_input_at_newlines(std::string_view str) +{ + auto split = str | std::ranges::views::split('\n') + | std::ranges::views::transform([](auto&& range) { + return std::string(range.begin(), std::ranges::distance(range)); + }); + m_lines = std::vector{split.begin(), split.end()}; +} + +void terminal_pager::update_terminal_size() +{ + struct winsize size; + int err = ioctl(fileno(stdout), TIOCGWINSZ, &size); + if (err == 0) + { + m_rows = size.ws_row; + m_columns = size.ws_col; + } + else + { + m_rows = m_columns = 0; + } +} diff --git a/src/utils/terminal_pager.hpp b/src/utils/terminal_pager.hpp new file mode 100644 index 0000000..8c710a1 --- /dev/null +++ b/src/utils/terminal_pager.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +/** + * Terminal pager that displays output written to stdout one page at a time, allowing the user to + * interactively scroll up and down. If cout is not a tty or the output is shorter than a single + * terminal page it does nothing. + * + * It expects all of cout to be written before the first page is displayed, so it does not pipe from + * cout which would be a more complicated implementation allowing the first page to be displayed + * before all of the output is written. This may need to be reconsidered if we need more performant + * handling of slow subcommands such as `git2cpp log` of repos with long histories. + * + * Keys handled: + * d, space scroll down a page + * u scroll up a page + * q quit pager + * down arrow, enter, return scroll down a line + * up arrow scroll up a line + * + * Emits a BEL (ASCII 7) for unrecognised keys or attempts to scroll too far, which is used by some + * terminals for visual and/or audible feedback. + * + * Does not respond to a change of terminal size whilst it is waiting for input, but it will the + * next time the output is scrolled. + */ +class terminal_pager +{ +public: + terminal_pager(); + + ~terminal_pager(); + + void show(); + +private: + std::string get_input() const; + + void maybe_grab_cout(); + + // Return true if should stop pager. + bool process_input(std::string input); + + void release_cout(); + + void render_terminal() const; + + void scroll(bool up, bool page); + + void split_input_at_newlines(std::string_view str); + + void update_terminal_size(); + + + std::stringbuf m_stringbuf; + std::streambuf* m_cout_rdbuf; + std::vector m_lines; + size_t m_rows, m_columns; + size_t m_start_row_index; +};