-
Notifications
You must be signed in to change notification settings - Fork 3
Implement terminal_pager for log subcommand #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
97cf63b
e083e48
44c04be
f7e4643
0edd017
afd276a
b77eb87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably capture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We do need a strategy for what to do with Currently
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
JohanMabille marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
{ | ||
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. | ||
JohanMabille marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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); | ||
JohanMabille marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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; | ||
} | ||
} |
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); | ||
JohanMabille marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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; | ||
JohanMabille marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
std::streambuf* m_cout_rdbuf; | ||
std::vector<std::string> m_lines; | ||
size_t m_rows, m_columns; | ||
size_t m_start_row_index; | ||
}; |
Uh oh!
There was an error while loading. Please reload this page.