Skip to content

Commit fcc0fad

Browse files
committed
--play-game reads keys only in the terminal's foreground, and a key sequence with parameters is decoded whole
A background job that changed the terminal's mode would be stopped by SIGTTOU; the game is off there and the line that says so names the foreground. The key decoder reads the parameter bytes of a control sequence before its final byte, so Ctrl-Up is Up and F5 is no key, where the tail of either was read as keys; the decoder is exported and has unit tests. MCPP_PROGRESS is compared without case when a game is chosen, as it is when an animation is chosen.
1 parent f95d8e7 commit fcc0fad

6 files changed

Lines changed: 87 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ records one lock entry per identity again.
2828
a speed of its own. The best round is stated after `Finished`. Keys are
2929
read without echo; Ctrl-C still stops the build, and the terminal's mode is
3030
restored when the build ends or is interrupted. Where standard input or
31-
output is not a terminal, one line says why and the build proceeds
32-
(e2e 845).
31+
output is not a terminal, or mcpp runs as a background job, one line says
32+
why and the build proceeds (e2e 845). An arrow with a modifier is read as
33+
the arrow, and a sequence for any other key is skipped whole
34+
(`TerminalKeys` unit tests).
3335
- **`last N running`.** Once ninja has no step left to start (its `%u`
3436
reaches 0), the status row states how many steps remain, all of them
3537
running.

‎docs/09-commands-by-scenario.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ The game runs at its own speed; the counts beside it state the build. Keys
324324
are read without echo, and Ctrl-C still stops the build. The terminal's mode
325325
is restored when the build ends or is interrupted; a process killed outright
326326
cannot restore it, and `stty sane` does. The game needs standard input and
327-
standard output on a terminal; otherwise one line says why, and the build
328-
proceeds.
327+
standard output on a terminal, with mcpp in its foreground (not a background
328+
job); otherwise one line says why, and the build proceeds.
329329

330330
`--verbose` names every package: `Fresh` for those with nothing to do, and
331331
`Compiled` with the steps and span of each that did work. It also states each

‎docs/zh/09-commands-by-scenario.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ $ mcpp build --play-game=snake
285285
- **速度与计数**:游戏以自身的速度运行,旁边的计数陈述构建的进展。
286286
- **按键读取**:按键不回显;Ctrl-C 仍然中断构建。
287287
- **终端模式**:构建结束或被中断时,终端模式会恢复;被强制杀死的进程无法恢复,此时可执行 `stty sane`。
288-
- **使用条件**:游戏要求标准输入和标准输出都是终端;否则写出一行说明原因,构建照常进行。
288+
- **使用条件**:游戏要求标准输入和标准输出都是终端,且 mcpp 位于该终端的前台(不是后台作业);否则写出一行说明原因,构建照常进行。
289289

290290
`--verbose` 列出每个包:无事可做的包记为 `Fresh`,做了事的包记为 `Compiled`,并给出其步骤数和耗时跨度。它还给出每个构建程序的编译与运行时间,并按 ninja 的报告打印每个步骤(`[f/t] <命令>` 及其输出)。`--quiet` 不输出以上内容。机器输出(`--message-format json`)不变。
291291

‎modules/platform/src/terminal.cppm‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,17 @@ bool unicode_capable();
114114
// the space bar, and `wasd` as a second set of arrows.
115115
enum class Key { Up, Down, Left, Right, Space };
116116

117+
// The keys in `pending`, the bytes a POSIX terminal sent: an arrow is
118+
// `ESC [ A` to `ESC [ D`, `ESC O A` to `ESC O D` in application mode, or
119+
// `ESC [ 1 ; 5 A` and the like with a modifier; a sequence for any other key
120+
// is skipped whole. An incomplete sequence at the end is left in `pending`.
121+
std::vector<Key> decode_keys(std::string& pending);
122+
117123
// KEYS READ FROM THE TERMINAL FOR THE LIFETIME OF THE OBJECT, without echo and
118124
// without waiting for a line end; Ctrl-C still interrupts. Active only when
119-
// standard input and standard output are both terminals. The mode the
125+
// standard input and standard output are both terminals and, on POSIX, mcpp
126+
// is in the terminal's foreground process group: a background job that
127+
// changed the terminal's mode would be stopped by SIGTTOU. The mode the
120128
// terminal had is restored when the object is destroyed, and by the signal
121129
// handler if a signal ends mcpp first (POSIX: `unixproc::guard_terminal_mode`;
122130
// Windows: a console control handler).
@@ -368,28 +376,32 @@ BOOL WINAPI restore_input_mode(DWORD) {
368376
}
369377
#endif
370378

371-
// The keys in `bytes` (POSIX): an arrow is `ESC [ A` to `ESC [ D`, or with
372-
// `O` in place of `[` in application mode. An incomplete sequence at the end
373-
// is left in `pending`.
379+
} // namespace
380+
374381
std::vector<Key> decode_keys(std::string& pending) {
375382
std::vector<Key> keys;
376383
std::size_t i = 0;
377384
while (i < pending.size()) {
378385
const char c = pending[i];
379386
if (c == '\x1b') {
380-
if (i + 2 >= pending.size()) break; // wait for the rest
381-
if (pending[i + 1] == '[' || pending[i + 1] == 'O') {
382-
switch (pending[i + 2]) {
383-
case 'A': keys.push_back(Key::Up); break;
384-
case 'B': keys.push_back(Key::Down); break;
385-
case 'C': keys.push_back(Key::Right); break;
386-
case 'D': keys.push_back(Key::Left); break;
387-
default: break;
388-
}
389-
i += 3;
390-
continue;
387+
if (i + 1 >= pending.size()) break; // wait for the rest
388+
if (pending[i + 1] != '[' && pending[i + 1] != 'O') { ++i; continue; }
389+
// Parameter and intermediate bytes (0x20 to 0x3F), then one final
390+
// byte (0x40 to 0x7E) that names the key.
391+
std::size_t end = i + 2;
392+
while (end < pending.size()
393+
&& static_cast<unsigned char>(pending[end]) >= 0x20
394+
&& static_cast<unsigned char>(pending[end]) <= 0x3F)
395+
++end;
396+
if (end >= pending.size()) break; // wait for the final byte
397+
switch (pending[end]) {
398+
case 'A': keys.push_back(Key::Up); break;
399+
case 'B': keys.push_back(Key::Down); break;
400+
case 'C': keys.push_back(Key::Right); break;
401+
case 'D': keys.push_back(Key::Left); break;
402+
default: break;
391403
}
392-
++i;
404+
i = end + 1;
393405
continue;
394406
}
395407
switch (c) {
@@ -406,8 +418,6 @@ std::vector<Key> decode_keys(std::string& pending) {
406418
return keys;
407419
}
408420

409-
} // namespace
410-
411421
KeyInput::KeyInput() {
412422
if (!is_terminal(Stream::Out)) return;
413423
#if defined(_WIN32)
@@ -422,7 +432,7 @@ KeyInput::KeyInput() {
422432
if (!::SetConsoleMode(h, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT))) return;
423433
active_ = true;
424434
#elif defined(__unix__) || defined(__APPLE__)
425-
if (::isatty(0) == 0) return;
435+
if (::isatty(0) == 0 || ::tcgetpgrp(0) != ::getpgrp()) return;
426436
struct termios mode{};
427437
if (::tcgetattr(0, &mode) != 0) return;
428438
mcpp::platform::unixproc::guard_terminal_mode(0);

‎src/build/progress.cppm‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,8 +1053,8 @@ std::unique_ptr<screen::Animation> choose_animation() {
10531053

10541054
// `--play-game[=NAME]` (revision 3, §5.14): the CLI publishes the request as
10551055
// MCPP_PLAY_GAME (`random` or a name). The game needs what the screen needs,
1056-
// and keys: standard input and standard output on a terminal. Otherwise it is
1057-
// off, and one line says why.
1056+
// and keys: standard input and standard output on a terminal, with mcpp in
1057+
// its foreground. Otherwise it is off, and one line says why.
10581058
void choose_game(Report& r, std::vector<std::string>& notes) {
10591059
auto want = mcpp::platform::env::get("MCPP_PLAY_GAME").value_or("");
10601060
if (want.empty()) return;
@@ -1068,7 +1068,8 @@ void choose_game(Report& r, std::vector<std::string>& notes) {
10681068
want, known));
10691069
want = "random";
10701070
}
1071-
const auto progress = mcpp::platform::env::get("MCPP_PROGRESS").value_or("");
1071+
auto progress = mcpp::platform::env::get("MCPP_PROGRESS").value_or("");
1072+
for (auto& c : progress) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
10721073
if (mcpp::ui::is_quiet() || !mcpp::ui::live_progress() || progress == "plain" || progress == "off"
10731074
|| !mcpp::platform::terminal::unicode_capable()) {
10741075
notes.push_back("--play-game: the status row's screen is off here (a terminal that "
@@ -1077,7 +1078,8 @@ void choose_game(Report& r, std::vector<std::string>& notes) {
10771078
}
10781079
auto keys = std::make_unique<mcpp::platform::terminal::KeyInput>();
10791080
if (!keys->active()) {
1080-
notes.push_back("--play-game: standard input is not a terminal, so no key can be read");
1081+
notes.push_back("--play-game: standard input is not a terminal in the foreground, "
1082+
"so no key can be read");
10811083
return;
10821084
}
10831085
const auto seed = static_cast<std::uint64_t>(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.platform.terminal;
5+
6+
// The keys a game reads from a POSIX terminal
7+
// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14): the
8+
// decoder turns the bytes the terminal sent into keys, skips a sequence for
9+
// any other key whole, and keeps an incomplete sequence for the next read.
10+
11+
using mcpp::platform::terminal::Key;
12+
using mcpp::platform::terminal::decode_keys;
13+
14+
TEST(TerminalKeys, ArrowsInBothModesAndTheSecondSet) {
15+
std::string in = "\x1b[A\x1b[B\x1bOC\x1bOD wasd";
16+
const auto keys = decode_keys(in);
17+
const std::vector<Key> want = {Key::Up, Key::Down, Key::Right, Key::Left, Key::Space,
18+
Key::Up, Key::Left, Key::Down, Key::Right};
19+
EXPECT_EQ(keys, want);
20+
EXPECT_TRUE(in.empty());
21+
}
22+
23+
TEST(TerminalKeys, AModifiedArrowIsOneKeyAndOtherSequencesAreSkippedWhole) {
24+
// Ctrl-Up, then F5 (`ESC [ 1 5 ~`), then Home in application mode.
25+
std::string in = "\x1b[1;5A\x1b[15~\x1bOH";
26+
const auto keys = decode_keys(in);
27+
const std::vector<Key> want = {Key::Up};
28+
EXPECT_EQ(keys, want) << "a parameter byte or a final byte was read as a key";
29+
EXPECT_TRUE(in.empty());
30+
}
31+
32+
TEST(TerminalKeys, AnIncompleteSequenceWaitsForTheNextRead) {
33+
std::string in = "d\x1b[1;";
34+
EXPECT_EQ(decode_keys(in), std::vector<Key>{Key::Right});
35+
EXPECT_EQ(in, "\x1b[1;");
36+
in += "2B";
37+
EXPECT_EQ(decode_keys(in), std::vector<Key>{Key::Down});
38+
EXPECT_TRUE(in.empty());
39+
40+
std::string esc = "\x1b";
41+
EXPECT_TRUE(decode_keys(esc).empty());
42+
EXPECT_EQ(esc, "\x1b");
43+
esc += "w"; // escape on its own, then a key
44+
EXPECT_EQ(decode_keys(esc), std::vector<Key>{Key::Up});
45+
}

0 commit comments

Comments
 (0)