Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ set(GIT2CPP_SRC
${GIT2CPP_SOURCE_DIR}/utils/git_exception.cpp
${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp
${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
Expand Down
5 changes: 5 additions & 0 deletions src/subcommand/log_subcommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <termcolor/termcolor.hpp>

#include "log_subcommand.hpp"
#include "../utils/terminal_pager.hpp"
#include "../wrapper/repository_wrapper.hpp"
#include "../wrapper/commit_wrapper.hpp"

Expand Down Expand Up @@ -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<m_max_count_flag)
Expand All @@ -100,4 +103,6 @@ void log_subcommand::run()
}

git_revwalk_free(walker);

pager.show();
}
239 changes: 239 additions & 0 deletions src/utils/terminal_pager.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <iostream>
#include <ranges>

// OS-specific libraries.
#include <sys/ioctl.h>
#include <termios.h>

#include <termcolor/termcolor.hpp>

#include "terminal_pager.hpp"

terminal_pager::terminal_pager()
: m_grabbed(false), 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 (ch == '\e') // 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 (!m_grabbed && termcolor::_internal::is_atty(std::cout))
{
// Should we do anything with cerr?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably capture std::cerr too as by default (at least on some platforms) it outputs to the same place as std::cout

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do need a strategy for what to do with cerr, but I don't know what is best yet.

Currently cerr will be written to the normal terminal buffer, so it won't be seen whilst the alternative buffer is being used but it reappears when the alternative buffer is disabled. This is perhaps not a good solution, but I don't think it is too bad for a first implementation. Some other options:

  1. Capture it and sent it to the same place as cout, it will appear in the output flow in the alternative buffer. We'd probably need to colour it to make is easily visible as it could be anywhere.
  2. Capture it and display it at the bottom of the alternative buffer for better visibility, but then there might be quite a lot of information displayed.
  3. Capture it and if anything at all is written to it we could avoid using the pager at all, just display the errors in the normal terminal flow.

Copy link
Member

@JohanMabille JohanMabille Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a preference for option 1, but I think we can discuss it in a dedicated issue and solve it in a dedicated PR so that it does not block this one, WDYT?

m_cout_rdbuf = std::cout.rdbuf(m_oss.rdbuf());
m_grabbed = true;
}
}

bool terminal_pager::process_input(const 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 (input == "\e[A" || input == "\e[1A]") // Up arrow.
{
scroll(true, false); // Up a line.
return false;
}
else if (input == "\e[B" || input == "\e[1B]") // Down arrow.
{
scroll(false, false); // Down a line.
return false;
}
}

std::cout << '\a'; // Emit BEL for visual feedback.
return false;
}

void terminal_pager::release_cout()
{
if (m_grabbed)
{
std::cout.rdbuf(m_cout_rdbuf);
m_grabbed = false;
}
}

void terminal_pager::render_terminal() const
{
auto end_row_index = m_start_row_index + m_rows - 1;

std::cout << "\e[2J"; // Erase screen.
std::cout << "\e[H"; // 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 << "\e[" << m_rows << "H"; // 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)
{
// No change, emit BEL for visual feedback.
std::cout << '\a';
}
else
{
render_terminal();
}
}

void terminal_pager::show()
{
if (!m_grabbed)
{
return;
}

release_cout();

split_input_at_newlines(m_oss.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;
}

struct termios old_termios;
tcgetattr(fileno(stdin), &old_termios);
auto new_termios = old_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 << "\e[?1049h"; // Enable alternative buffer.

m_start_row_index = 0;
render_terminal();

bool stop = false;
do
{
stop = process_input(get_input());
} while (!stop);

std::cout << "\e[?1049l"; // Disable alternative buffer.

// Restore original termios settings.
tcsetattr(fileno(stdin), TCSANOW, &old_termios);

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<std::string>{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;
}
}
63 changes: 63 additions & 0 deletions src/utils/terminal_pager.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#pragma once

#include <vector>
#include <sstream>

/**
* 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(const 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();


bool m_grabbed;
std::ostringstream m_oss;
std::streambuf* m_cout_rdbuf;
std::vector<std::string> m_lines;
size_t m_rows, m_columns;
size_t m_start_row_index;
};