Add an interactive terminal UI behind -tui.

The console log takes the screen, with a Lua REPL and a status line under it.
F5 toggles run/pause, PgUp/PgDn scroll the log, Ctrl-C quits. -tui refuses to
start unless stdin and stdout are a terminal; -no-ui stays the headless path.
This commit is contained in:
Rix
2026-10-01 13:34:54 -07:00
parent c0300e4e43
commit 290ee2c6b9
5 changed files with 342 additions and 3 deletions
+13 -3
View File
@@ -38,6 +38,7 @@
#include "gui/gui.h"
#include "lua/extra.h"
#include "lua/luawrapper.h"
#include "main/terminalui.h"
#include "main/textui.h"
#include "spu/interface.h"
#include "support/binpath.h"
@@ -224,7 +225,7 @@ int pcsxMain(int argc, char **argv) {
PCSX::UvThreadOp::UvThread uvThread;
#if defined(_WIN32) || defined(_WIN64)
if (args.get<bool>("stdout") || args.get<bool>("no-ui") || args.get<bool>("cli")) {
if (args.get<bool>("stdout") || args.get<bool>("no-ui") || args.get<bool>("cli") || args.get<bool>("tui")) {
if (AllocConsole()) {
freopen("CONIN$", "r", stdin);
freopen("CONOUT$", "w", stdout);
@@ -278,8 +279,17 @@ int pcsxMain(int argc, char **argv) {
PCSX::g_emulator = emulator;
auto &favorites = emulator->settings.get<PCSX::Emulator::SettingOpenDialogFavorites>().value;
s_ui = args.get<bool>("no-ui") || args.get<bool>("cli") ? reinterpret_cast<PCSX::UI *>(new PCSX::TUI())
: reinterpret_cast<PCSX::UI *>(new PCSX::GUI(favorites));
if (args.get<bool>("tui")) {
if (!PCSX::TerminalUI::isTerminal()) {
fmt::print(stderr, "-tui needs a terminal on both stdin and stdout; use -no-ui for headless runs.\n");
return 1;
}
s_ui = new PCSX::TerminalUI();
} else if (args.get<bool>("no-ui") || args.get<bool>("cli")) {
s_ui = new PCSX::TUI();
} else {
s_ui = new PCSX::GUI(favorites);
}
// Settings will be loaded after this initialization.
s_ui->init([&emulator, &args, &system]() {
// Start tweaking / sanitizing settings a bit, while continuing to parse the command line
+245
View File
@@ -0,0 +1,245 @@
/***************************************************************************
* Copyright (C) 2026 PCSX-Redux authors *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. *
***************************************************************************/
#include "main/terminalui.h"
#include <chrono>
#include <thread>
#include "core/psxemulator.h"
#include "core/r3000a.h"
#include "core/system.h"
#include "fmt/format.h"
#include "ftxui/component/app.hpp"
#include "ftxui/component/component.hpp"
#include "ftxui/component/event.hpp"
#include "ftxui/component/loop.hpp"
#include "ftxui/dom/elements.hpp"
#include "ftxui/screen/terminal.hpp"
#include "lua/luawrapper.h"
#ifdef _WIN32
#include <io.h>
#define isatty _isatty
#define fileno _fileno
#else
#include <unistd.h>
#endif
namespace {
constexpr size_t c_maxLogLines = 10000;
constexpr size_t c_maxLuaLines = 1000;
constexpr int c_luaPaneHeight = 10;
} // namespace
PCSX::TerminalUI::TerminalUI() {}
PCSX::TerminalUI::~TerminalUI() { close(); }
bool PCSX::TerminalUI::isTerminal() { return isatty(fileno(stdin)) && isatty(fileno(stdout)); }
void PCSX::TerminalUI::appendLines(std::deque<Line>& dest, std::string& pending, const std::string& msg, bool error) {
pending += msg;
size_t start = 0;
size_t nl;
while ((nl = pending.find('\n', start)) != std::string::npos) {
dest.push_back({pending.substr(start, nl - start), error});
start = nl + 1;
}
pending.erase(0, start);
}
bool PCSX::TerminalUI::addLog(LogClass logClass, const std::string& msg) {
{
std::lock_guard<std::mutex> lock(m_mutex);
appendLines(m_log, m_logPending, msg, false);
while (m_log.size() > c_maxLogLines) m_log.pop_front();
}
if (m_app) m_app->RequestAnimationFrame();
return true;
}
void PCSX::TerminalUI::addLuaLog(const std::string& msg, bool error) {
{
std::lock_guard<std::mutex> lock(m_mutex);
std::string pending;
appendLines(m_lua, pending, msg + "\n", error);
while (m_lua.size() > c_maxLuaLines) m_lua.pop_front();
}
if (m_app) m_app->RequestAnimationFrame();
}
void PCSX::TerminalUI::addNotification(const std::string& notification) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_notification = notification;
}
if (m_app) m_app->RequestAnimationFrame();
}
void PCSX::TerminalUI::execute(const std::string& cmd) {
if (cmd.empty()) return;
if (m_history.empty() || m_history.back() != cmd) m_history.push_back(cmd);
m_historyPos = m_history.size();
addLuaLog("> " + cmd, false);
auto L = *g_emulator->m_lua;
const int top = L.gettop();
try {
System::setCrashReportTag("user_lua", "console");
// Try it as an expression first, so typing `PCSX.getRegisters().pc` shows the value.
// Probe quietly: L.load() reports its own syntax errors, and a failed probe is expected.
std::string expr = "return " + cmd;
if (luaL_loadbuffer(L.getState(), expr.data(), expr.size(), "console:") != 0) {
L.pop();
L.load(cmd, "console:", false);
}
int n = L.pcall();
for (int i = n; i > 0; i--) {
std::string value;
if (L.type(-i) == LUA_TNUMBER) {
value = fmt::format("{}", L.tonumber(-i));
} else {
value = L.tostring(-i);
}
addLuaLog(value, false);
}
} catch (std::exception& e) {
addLuaLog(e.what(), true);
}
if (L.gettop() > top) L.pop(L.gettop() - top);
}
void PCSX::TerminalUI::historyMove(int delta) {
if (m_history.empty()) return;
if (delta < 0 && m_historyPos > 0) m_historyPos--;
if (delta > 0 && m_historyPos < m_history.size()) m_historyPos++;
m_input = m_historyPos < m_history.size() ? m_history[m_historyPos] : "";
}
void PCSX::TerminalUI::init(std::function<void()> applyArguments) {
loadSettings();
applyArguments();
finishLoadSettings();
using namespace ftxui;
m_app = std::make_unique<App>(App::Fullscreen());
m_app->TrackMouse(false);
m_app->HandlePipedInput(false);
InputOption inputOption;
inputOption.multiline = false;
inputOption.on_enter = [this]() {
std::string cmd = m_input;
m_input.clear();
execute(cmd);
};
auto input = Input(&m_input, "Lua", inputOption);
auto renderLines = [](const std::deque<Line>& lines, int height, int scroll) {
Elements out;
int end = static_cast<int>(lines.size()) - scroll;
int start = std::max(0, end - height);
for (int i = start; i < end; i++) {
auto t = text(lines[i].text);
out.push_back(lines[i].error ? t | color(Color::Red) : t);
}
return out;
};
auto layout = Renderer(input, [this, input, renderLines]() {
std::lock_guard<std::mutex> lock(m_mutex);
auto dim = Terminal::Size();
// Log gets everything left after the Lua pane, the separator, the input line and the status line.
int logHeight = std::max(1, dim.dimy - c_luaPaneHeight - 3);
bool running = g_system->running();
auto status = hbox({
text(running ? " RUNNING " : " PAUSED ") | inverted,
text(fmt::format(" pc={:08x} ", g_emulator->m_cpu->m_regs.pc)),
text(m_logScroll ? fmt::format(" scrolled -{} ", m_logScroll) : ""),
filler(),
text(m_notification),
text(" F5 run/pause PgUp/PgDn scroll Ctrl-C quit "),
});
return vbox({
vbox(renderLines(m_log, logHeight, m_logScroll)) | size(HEIGHT, EQUAL, logHeight),
separator(),
vbox(renderLines(m_lua, c_luaPaneHeight, 0)) | size(HEIGHT, EQUAL, c_luaPaneHeight),
hbox({text("> "), input->Render() | flex}),
status,
});
});
layout = CatchEvent(layout, [this](Event event) {
if (event == Event::ArrowUp) {
historyMove(-1);
return true;
}
if (event == Event::ArrowDown) {
historyMove(1);
return true;
}
if (event == Event::PageUp || event == Event::PageDown) {
std::lock_guard<std::mutex> lock(m_mutex);
int page = std::max(1, Terminal::Size().dimy / 2);
m_logScroll += event == Event::PageUp ? page : -page;
m_logScroll = std::clamp(m_logScroll, 0, std::max(0, static_cast<int>(m_log.size()) - 1));
return true;
}
if (event == Event::F5) {
if (g_system->running()) {
g_system->pause();
} else {
g_system->resume();
}
return true;
}
return false;
});
m_loop = std::make_unique<Loop>(m_app.get(), layout);
}
void PCSX::TerminalUI::setLua(Lua L) { setLuaCommon(L); }
void PCSX::TerminalUI::close() {
// The loop owns the terminal state; destroying it restores the screen.
m_loop.reset();
m_app.reset();
}
void PCSX::TerminalUI::update(bool vsync) {
tick();
if (m_loop) {
// Nothing else asks for a redraw while the CPU runs, so the status line would sit still.
auto now = std::chrono::steady_clock::now();
if (g_system->running() && now - m_lastRedraw >= std::chrono::milliseconds(100)) {
m_lastRedraw = now;
m_app->RequestAnimationFrame();
}
m_loop->RunOnce();
if (m_loop->HasQuitted()) {
close();
g_system->quit();
return;
}
}
if (!g_system->running()) {
using namespace std::chrono_literals;
std::this_thread::sleep_for(10ms);
}
}
+76
View File
@@ -0,0 +1,76 @@
/***************************************************************************
* Copyright (C) 2026 PCSX-Redux authors *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. *
***************************************************************************/
#pragma once
#include <chrono>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "core/ui.h"
namespace ftxui {
class App;
class Loop;
} // namespace ftxui
namespace PCSX {
// Interactive text mode, selected with -tui when both stdin and stdout are a terminal.
// The console log takes the screen, with a Lua REPL underneath it.
class TerminalUI : public UI {
public:
TerminalUI();
~TerminalUI();
static bool isTerminal();
bool addLog(LogClass logClass, const std::string& msg) override;
void addLuaLog(const std::string& msg, bool error) override;
void init(std::function<void()> applyArguments) override;
void setLua(Lua L) override;
void close() override;
void update(bool vsync = false) override;
void addNotification(const std::string& notification) override;
private:
struct Line {
std::string text;
bool error = false;
};
void appendLines(std::deque<Line>& dest, std::string& pending, const std::string& msg, bool error);
void execute(const std::string& cmd);
void historyMove(int delta);
std::mutex m_mutex;
std::deque<Line> m_log;
std::deque<Line> m_lua;
std::string m_logPending;
std::string m_input;
std::vector<std::string> m_history;
size_t m_historyPos = 0;
int m_logScroll = 0;
std::string m_notification;
std::chrono::steady_clock::time_point m_lastRedraw;
std::unique_ptr<ftxui::App> m_app;
std::unique_ptr<ftxui::Loop> m_loop;
};
} // namespace PCSX
+2
View File
@@ -123,10 +123,12 @@
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="..\..\src\main\main.cc" />
<ClCompile Include="..\..\src\main\terminalui.cc" />
<ClCompile Include="..\..\src\main\textui.cc" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\..\src\main\main.h" />
<ClInclude Include="..\..\src\main\terminalui.h" />
<ClInclude Include="..\..\src\main\textui.h" />
</ItemGroup>
<ItemGroup>
+6
View File
@@ -18,6 +18,9 @@
<ClCompile Include="..\..\src\main\main.cc">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\..\src\main\terminalui.cc">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\..\src\main\textui.cc">
<Filter>Source Files</Filter>
</ClCompile>
@@ -26,6 +29,9 @@
<ClInclude Include="..\..\src\main\main.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\..\src\main\terminalui.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\..\src\main\textui.h">
<Filter>Header Files</Filter>
</ClInclude>