Skip to content

Commit 97cf63b

Browse files
committed
Implement terminal_pager for log subcommand
1 parent fe44bbd commit 97cf63b

File tree

4 files changed

+308
-0
lines changed

4 files changed

+308
-0
lines changed

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ set(GIT2CPP_SRC
6363
${GIT2CPP_SOURCE_DIR}/utils/git_exception.cpp
6464
${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp
6565
${GIT2CPP_SOURCE_DIR}/utils/output.hpp
66+
${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp
67+
${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp
6668
${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp
6769
${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.hpp
6870
${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.cpp

src/subcommand/log_subcommand.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <termcolor/termcolor.hpp>
88

99
#include "log_subcommand.hpp"
10+
#include "../utils/terminal_pager.hpp"
1011
#include "../wrapper/repository_wrapper.hpp"
1112
#include "../wrapper/commit_wrapper.hpp"
1213

@@ -90,6 +91,8 @@ void log_subcommand::run()
9091
git_revwalk_new(&walker, repo);
9192
git_revwalk_push_head(walker);
9293

94+
terminal_pager pager;
95+
9396
std::size_t i=0;
9497
git_oid commit_oid;
9598
while (!git_revwalk_next(&commit_oid, walker) && i<m_max_count_flag)
@@ -100,4 +103,6 @@ void log_subcommand::run()
100103
}
101104

102105
git_revwalk_free(walker);
106+
107+
pager.show();
103108
}

src/utils/terminal_pager.cpp

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#include <cctype>
2+
#include <cstdio>
3+
#include <iostream>
4+
#include <ranges>
5+
6+
// OS-specific libraries.
7+
#include <sys/ioctl.h>
8+
#include <termios.h>
9+
10+
#include <termcolor/termcolor.hpp>
11+
12+
#include "terminal_pager.hpp"
13+
14+
terminal_pager::terminal_pager()
15+
: m_grabbed(false), m_rows(0), m_columns(0), m_start_row_index(0)
16+
{
17+
maybe_grab_cout();
18+
}
19+
20+
terminal_pager::~terminal_pager()
21+
{
22+
release_cout();
23+
}
24+
25+
std::string terminal_pager::get_input() const
26+
{
27+
// Blocks until input received.
28+
std::string str;
29+
char ch;
30+
std::cin.get(ch);
31+
str += ch;
32+
33+
if (ch == '\e') // Start of ANSI escape sequence.
34+
{
35+
do
36+
{
37+
std::cin.get(ch);
38+
str += ch;
39+
} while (!std::isalpha(ch)); // ANSI escape sequence ends with a letter.
40+
}
41+
42+
return str;
43+
}
44+
45+
void terminal_pager::maybe_grab_cout()
46+
{
47+
// Unfortunately need to access _internal namespace of termcolor to check if a tty.
48+
if (!m_grabbed && termcolor::_internal::is_atty(std::cout))
49+
{
50+
// Should we do anything with cerr?
51+
m_cout_rdbuf = std::cout.rdbuf(m_oss.rdbuf());
52+
m_grabbed = true;
53+
}
54+
}
55+
56+
bool terminal_pager::process_input(const std::string& input)
57+
{
58+
if (input.size() == 0)
59+
{
60+
return true;
61+
}
62+
63+
switch (input[0])
64+
{
65+
case 'q':
66+
case 'Q':
67+
return true; // Exit pager.
68+
case 'u':
69+
case 'U':
70+
scroll(true, true); // Up a page.
71+
return false;
72+
case 'd':
73+
case 'D':
74+
case ' ':
75+
scroll(false, true); // Down a page.
76+
return false;
77+
case '\n':
78+
scroll(false, false); // Down a line.
79+
return false;
80+
case '\e': // ANSI escape sequence.
81+
// Cannot switch on a std::string.
82+
if (input == "\e[A" || input == "\e[1A]") // Up arrow.
83+
{
84+
scroll(true, false); // Up a line.
85+
return false;
86+
}
87+
else if (input == "\e[B" || input == "\e[1B]") // Down arrow.
88+
{
89+
scroll(false, false); // Down a line.
90+
return false;
91+
}
92+
}
93+
94+
std::cout << '\a'; // Emit BEL for visual feedback.
95+
return false;
96+
}
97+
98+
void terminal_pager::release_cout()
99+
{
100+
if (m_grabbed)
101+
{
102+
std::cout.rdbuf(m_cout_rdbuf);
103+
m_grabbed = false;
104+
}
105+
}
106+
107+
void terminal_pager::render_terminal() const
108+
{
109+
auto end_row_index = m_start_row_index + m_rows - 1;
110+
111+
std::cout << "\e[2J"; // Erase screen.
112+
std::cout << "\e[H"; // Cursor to top.
113+
114+
for (size_t i = m_start_row_index; i < end_row_index; i++)
115+
{
116+
if (i >= m_lines.size())
117+
{
118+
break;
119+
}
120+
std::cout << m_lines[i] << std::endl;
121+
}
122+
123+
std::cout << "\e[" << m_rows << "H"; // Move cursor to bottom row of terminal.
124+
std::cout << ":";
125+
}
126+
127+
void terminal_pager::scroll(bool up, bool page)
128+
{
129+
update_terminal_size();
130+
const auto old_start_row_index = m_start_row_index;
131+
size_t offset = page ? m_rows - 1 : 1;
132+
133+
if (up)
134+
{
135+
// Care needed to avoid underflow of unsigned size_t.
136+
if (m_start_row_index >= offset)
137+
{
138+
m_start_row_index -= offset;
139+
}
140+
else
141+
{
142+
m_start_row_index = 0;
143+
}
144+
}
145+
else
146+
{
147+
m_start_row_index += offset;
148+
auto end_row_index = m_start_row_index + m_rows - 1;
149+
if (end_row_index > m_lines.size())
150+
{
151+
m_start_row_index = m_lines.size() - (m_rows - 1);
152+
}
153+
}
154+
155+
if (m_start_row_index == old_start_row_index)
156+
{
157+
// No change, emit BEL for visual feedback.
158+
std::cout << '\a';
159+
}
160+
else
161+
{
162+
render_terminal();
163+
}
164+
}
165+
166+
void terminal_pager::show()
167+
{
168+
if (!m_grabbed)
169+
{
170+
return;
171+
}
172+
173+
release_cout();
174+
175+
split_input_at_newlines(m_oss.view());
176+
177+
update_terminal_size();
178+
if (m_rows == 0 || m_lines.size() <= m_rows - 1)
179+
{
180+
// Don't need to use pager, can display directly.
181+
for (auto line : m_lines)
182+
{
183+
std::cout << line << std::endl;
184+
}
185+
m_lines.clear();
186+
return;
187+
}
188+
189+
struct termios old_termios;
190+
tcgetattr(fileno(stdin), &old_termios);
191+
auto new_termios = old_termios;
192+
// Disable canonical mode (buffered I/O) and echo from stdin to stdout.
193+
new_termios.c_lflag &= (~ICANON & ~ECHO);
194+
tcsetattr(fileno(stdin), TCSANOW, &new_termios);
195+
196+
std::cout << "\e[?1049h"; // Enable alternative buffer.
197+
198+
m_start_row_index = 0;
199+
render_terminal();
200+
201+
bool stop = false;
202+
do
203+
{
204+
stop = process_input(get_input());
205+
} while (!stop);
206+
207+
std::cout << "\e[?1049l"; // Disable alternative buffer.
208+
209+
// Restore original termios settings.
210+
tcsetattr(fileno(stdin), TCSANOW, &old_termios);
211+
212+
m_lines.clear();
213+
m_start_row_index = 0;
214+
}
215+
216+
void terminal_pager::split_input_at_newlines(std::string_view str)
217+
{
218+
auto split = str | std::ranges::views::split('\n')
219+
| std::ranges::views::transform([](auto&& range) {
220+
return std::string(range.begin(), std::ranges::distance(range));
221+
});
222+
m_lines = std::vector<std::string>{split.begin(), split.end()};
223+
}
224+
225+
void terminal_pager::update_terminal_size()
226+
{
227+
struct winsize size;
228+
int err = ioctl(fileno(stdout), TIOCGWINSZ, &size);
229+
if (err == 0)
230+
{
231+
m_rows = size.ws_row;
232+
m_columns = size.ws_col;
233+
}
234+
else
235+
{
236+
m_rows = m_columns = 0;
237+
}
238+
}

src/utils/terminal_pager.hpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#pragma once
2+
3+
#include <vector>
4+
#include <sstream>
5+
6+
/**
7+
* Terminal pager that displays output written to stdout one page at a time, allowing the user to
8+
* interactively scroll up and down. If cout is not a tty or the output is shorter than a single
9+
* terminal page it does nothing.
10+
*
11+
* It expects all of cout to be written before the first page is displayed, so it does not pipe from
12+
* cout which would be a more complicated implementation allowing the first page to be displayed
13+
* before all of the output is written. This may need to be reconsidered if we need more performant
14+
* handling of slow subcommands such as `git2cpp log` of repos with long histories.
15+
*
16+
* Keys handled:
17+
* d, space scroll down a page
18+
* u scroll up a page
19+
* q quit pager
20+
* down arrow, enter, return scroll down a line
21+
* up arrow scroll up a line
22+
*
23+
* Emits a BEL (ASCII 7) for unrecognised keys or attempts to scroll too far, which is used by some
24+
* terminals for visual and/or audible feedback.
25+
*
26+
* Does not respond to a change of terminal size whilst it is waiting for input, but it will the
27+
* next time the output is scrolled.
28+
*/
29+
class terminal_pager
30+
{
31+
public:
32+
terminal_pager();
33+
34+
~terminal_pager();
35+
36+
void show();
37+
38+
private:
39+
std::string get_input() const;
40+
41+
void maybe_grab_cout();
42+
43+
// Return true if should stop pager.
44+
bool process_input(const std::string& input);
45+
46+
void release_cout();
47+
48+
void render_terminal() const;
49+
50+
void scroll(bool up, bool page);
51+
52+
void split_input_at_newlines(std::string_view str);
53+
54+
void update_terminal_size();
55+
56+
57+
bool m_grabbed;
58+
std::ostringstream m_oss;
59+
std::streambuf* m_cout_rdbuf;
60+
std::vector<std::string> m_lines;
61+
size_t m_rows, m_columns;
62+
size_t m_start_row_index;
63+
};

0 commit comments

Comments
 (0)