diff --git a/CMakeLists.txt b/CMakeLists.txt index 242cb293e1..09af4defc5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ endif(CMAKE_CONFIGURATION_TYPES) option(BUILD_DOCS "Choose whether to build the documentation (requires python and Sphinx)." OFF) option(BUILD_DOCS_NO_HTML "Don't build the HTML docs, only the in-game docs." OFF) option(REMOVE_SYMBOLS_FROM_DF_STUBS "Remove debug symbols from DF stubs. (Reduces libdfhack size to about half but removes a few useful symbols)" ON) +option(DFHACK_SDL_CONSOLE "Use experimental SDL console" ON) macro(CHECK_GCC compiler_path) execute_process(COMMAND ${compiler_path} -dumpversion OUTPUT_VARIABLE GCC_VERSION_OUT) diff --git a/library/CMakeLists.txt b/library/CMakeLists.txt index c43772af58..5271faab93 100644 --- a/library/CMakeLists.txt +++ b/library/CMakeLists.txt @@ -9,6 +9,8 @@ if(UNIX) option(CONSOLE_NO_CATCH "Make the console not catch 'CTRL+C' events for easier debugging." OFF) endif() +option(DFHACK_SDL_CONSOLE "Use experimental SDL console" ON) + # Generation set(CODEGEN_OUT ${dfapi_SOURCE_DIR}/include/df/codegen.out.xml) @@ -117,12 +119,24 @@ set(MAIN_SOURCES file(GLOB_RECURSE TEST_SOURCES LIST_DIRECTORIES false *test.cpp) +list(APPEND TEST_SOURCES Console-posix.cpp SDLConsole_impl.cpp) dfhack_test(dfhack-test "${TEST_SOURCES}") -if(WIN32) - set(CONSOLE_SOURCES Console-windows.cpp) +if (NOT DFHACK_SDL_CONSOLE) + if(WIN32) + set(CONSOLE_SOURCES Console-windows.cpp) + else() + set(CONSOLE_SOURCES Console-posix.cpp) + endif() + set(DFCLIENT_CONSOLE_SOURCES ${CONSOLE_SOURCES}) else() - set(CONSOLE_SOURCES Console-posix.cpp) + if(WIN32) + set(DFCLIENT_CONSOLE_SOURCES Console-windows.cpp) + else() + set(DFCLIENT_CONSOLE_SOURCES Console-posix.cpp) + endif() + set(CONSOLE_SOURCES ${DFCLIENT_CONSOLE_SOURCES}) + list(APPEND CONSOLE_SOURCES Console-sdl.cpp SDLConsole_impl.cpp) endif() set(MAIN_SOURCES_WINDOWS @@ -372,7 +386,9 @@ add_library(dfhack SHARED ${PROJECT_SOURCES}) add_dependencies(dfhack generate_proto_core) add_dependencies(dfhack generate_headers) -add_library(dfhack-client SHARED RemoteClient.cpp ColorText.cpp MiscUtils.cpp Error.cpp ${PROJECT_PROTO_SRCS} ${CONSOLE_SOURCES}) +add_library(dfhack-client SHARED RemoteClient.cpp ColorText.cpp MiscUtils.cpp Error.cpp ${PROJECT_PROTO_SRCS} ${DFCLIENT_CONSOLE_SOURCES}) +# SDLConsole requires to be run with df +target_compile_definitions(dfhack-client PUBLIC DISABLE_SDL_CONSOLE) add_dependencies(dfhack-client dfhack) add_executable(dfhack-run dfhack-run.cpp) diff --git a/library/Console-posix.cpp b/library/Console-posix.cpp index 3b91f3a236..86cb4329dc 100644 --- a/library/Console-posix.cpp +++ b/library/Console-posix.cpp @@ -75,7 +75,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. _res; }) #endif -#include "Console.h" +#include "PosixConsole.h" #include "Hooks.h" using namespace DFHack; @@ -833,14 +833,14 @@ namespace DFHack }; } -Console::Console() +PosixConsole::PosixConsole() { - d = 0; + d = nullptr; inited = false; // we can't create the mutex at this time. the SDL functions aren't hooked yet. wlock = new std::recursive_mutex(); } -Console::~Console() +PosixConsole::~PosixConsole() { assert(!inited); if(wlock) @@ -849,7 +849,7 @@ Console::~Console() delete d; } -bool Console::init(bool dont_redirect) +bool PosixConsole::init(bool dont_redirect) { d = new Private(); // make our own weird streams so our IO isn't redirected @@ -882,7 +882,12 @@ bool Console::init(bool dont_redirect) return true; } -bool Console::shutdown(void) +bool PosixConsole::is_supported() +{ + return !isUnsupportedTerm() && isatty(STDIN_FILENO); +} + +bool PosixConsole::shutdown(void) { if(!d) return true; @@ -894,7 +899,7 @@ bool Console::shutdown(void) return true; } -void Console::begin_batch() +void PosixConsole::begin_batch() { //color_ostream::begin_batch(); @@ -904,7 +909,7 @@ void Console::begin_batch() d->begin_batch(); } -void Console::end_batch() +void PosixConsole::end_batch() { if (inited) d->end_batch(); @@ -912,14 +917,14 @@ void Console::end_batch() wlock->unlock(); } -void Console::flush_proxy() +void PosixConsole::flush_proxy() { std::lock_guard lock{*wlock}; if (inited) d->flush(); } -void Console::add_text(color_value color, const std::string &text) +void PosixConsole::add_text(color_value color, const std::string &text) { std::lock_guard lock{*wlock}; if (inited) @@ -928,7 +933,7 @@ void Console::add_text(color_value color, const std::string &text) fwrite(text.data(), 1, text.size(), stderr); } -int Console::get_columns(void) +int PosixConsole::get_columns(void) { std::lock_guard lock{*wlock}; int ret = Console::FAILURE; @@ -937,7 +942,7 @@ int Console::get_columns(void) return ret; } -int Console::get_rows(void) +int PosixConsole::get_rows(void) { std::lock_guard lock{*wlock}; int ret = Console::FAILURE; @@ -946,28 +951,28 @@ int Console::get_rows(void) return ret; } -void Console::clear() +void PosixConsole::clear() { std::lock_guard lock{*wlock}; if(inited) d->clear(); } -void Console::gotoxy(int x, int y) +void PosixConsole::gotoxy(int x, int y) { std::lock_guard lock{*wlock}; if(inited) d->gotoxy(x,y); } -void Console::cursor(bool enable) +void PosixConsole::cursor(bool enable) { std::lock_guard lock{*wlock}; if(inited) d->cursor(enable); } -int Console::lineedit(const std::string & prompt, std::string & output, CommandHistory & ch) +int PosixConsole::lineedit(const std::string & prompt, std::string & output, CommandHistory & ch) { std::lock_guard lock{*wlock}; int ret = Console::SHUTDOWN; @@ -984,19 +989,19 @@ int Console::lineedit(const std::string & prompt, std::string & output, CommandH return ret; } -void Console::msleep (unsigned int msec) +void PosixConsole::msleep (unsigned int msec) { if (msec > 1000) sleep(msec/1000000); usleep((msec % 1000000) * 1000); } -bool Console::hide() +bool PosixConsole::hide() { //Warmist: don't know if it's possible... return false; } -bool Console::show() +bool PosixConsole::show() { //Warmist: don't know if it's possible... return false; diff --git a/library/Console-sdl.cpp b/library/Console-sdl.cpp new file mode 100644 index 0000000000..4d27950c90 --- /dev/null +++ b/library/Console-sdl.cpp @@ -0,0 +1,397 @@ +/* +https://github.com/peterix/dfhack + +A thread-safe logging console with a line editor. + +Based on linenoise: +linenoise -- guerrilla line editing library against the idea that a +line editing lib needs to be 20,000 lines of C code. + +You can find the latest source code at: + + http://github.com/antirez/linenoise + +Does a number of crazy assumptions that happen to be true in 99.9999% of +the 2010 UNIX computers around. + +------------------------------------------------------------------------ + +Copyright (c) 2010, Salvatore Sanfilippo +Copyright (c) 2010, Pieter Noordhuis +Copyright (c) 2011, Petr Mrázek + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#include +#include +#include + +#include "df/renderer_2d.h" +#include + +#include "SDLConsoleDriver.h" +#include "SDLConsole.h" + +using namespace DFHack; + +using namespace sdl_console; + +struct con_render_hook : df::renderer_2d { + typedef df::renderer_2d interpose_base; + + DEFINE_VMETHOD_INTERPOSE(void, render, ()) + { + SDLConsole::get_console().update(); + INTERPOSE_NEXT(render)(); + } +}; + +IMPLEMENT_VMETHOD_INTERPOSE(con_render_hook, render); + +namespace DFHack +{ +#if 0 + //! Convert a locale defined multibyte coding to UTF-32 string for easier + //! character processing. + static u32string fromLocaleMB(const std::string& str) + { + u32string rv; + u32string::value_type ch; + size_t pos = 0; + ssize_t sz; + std::mbstate_t state{}; + while ((sz = mbrtoc32(&ch,&str[pos], str.size() - pos, &state)) != 0) { + if (sz == -1 || sz == -2) + break; + rv.push_back(ch); + if (sz == -3) /* multi value character */ + continue; + pos += sz; + } + return rv; + } + + //! Convert a UTF-32 string back to locale defined multibyte coding. + static std::string toLocaleMB(const u32string& wstr) + { + std::stringstream ss{}; + char mb[MB_CUR_MAX]; + std::mbstate_t state{}; + const size_t err = -1; + for (auto ch: wstr) { + size_t sz = c32rtomb(mb, ch, &state); + if (sz == err) + break; + ss.write(mb, sz); + } + return ss.str(); + } +#endif + constexpr SDL_Color ANSI_BLACK = {0, 0, 0, 255}; + constexpr SDL_Color ANSI_BLUE = {0, 0, 128, 255}; // non-ANSI + constexpr SDL_Color ANSI_GREEN = {0, 128, 0, 255}; + constexpr SDL_Color ANSI_CYAN = {0, 128, 128, 255}; // non-ANSI + constexpr SDL_Color ANSI_RED = {128, 0, 0, 255}; // non-ANSI + constexpr SDL_Color ANSI_MAGENTA = {128, 0, 128, 255}; + constexpr SDL_Color ANSI_BROWN = {128, 128, 0, 255}; + constexpr SDL_Color ANSI_GREY = {192, 192, 192, 255}; + constexpr SDL_Color ANSI_DARKGREY = {128, 128, 128, 255}; + constexpr SDL_Color ANSI_LIGHTBLUE = {0, 0, 255, 255}; // non-ANSI + constexpr SDL_Color ANSI_LIGHTGREEN = {0, 255, 0, 255}; + constexpr SDL_Color ANSI_LIGHTCYAN = {0, 255, 255, 255}; // non-ANSI + constexpr SDL_Color ANSI_LIGHTRED = {255, 0, 0, 255}; // non-ANSI + constexpr SDL_Color ANSI_LIGHTMAGENTA = {255, 0, 255, 255}; + constexpr SDL_Color ANSI_YELLOW = {255, 255, 0, 255}; // non-ANSI + constexpr SDL_Color ANSI_WHITE = {255, 255, 255, 255}; + + // Function to get the color + SDL_Color getANSIColor(int c) + { + switch (c) + { + case -1: return ANSI_WHITE; + case 0 : return ANSI_BLACK; + case 1 : return ANSI_BLUE; // non-ANSI + case 2 : return ANSI_GREEN; + case 3 : return ANSI_CYAN; // non-ANSI + case 4 : return ANSI_RED; // non-ANSI + case 5 : return ANSI_MAGENTA; + case 6 : return ANSI_BROWN; + case 7 : return ANSI_GREY; + case 8 : return ANSI_DARKGREY; + case 9 : return ANSI_LIGHTBLUE; // non-ANSI + case 10: return ANSI_LIGHTGREEN; + case 11: return ANSI_LIGHTCYAN; // non-ANSI + case 12: return ANSI_LIGHTRED; // non-ANSI + case 13: return ANSI_LIGHTMAGENTA; + case 14: return ANSI_YELLOW; // non-ANSI + case 15: // WHITE + default: return ANSI_WHITE; + } + } + + class Private + { + public: + Private() : con(SDLConsole::get_console()) {}; + virtual ~Private() = default; + private: + + public: + + void print(const char *data) + { + con.write_line(data); + } + + void print_text(color_ostream::color_value clr, const std::string &chunk) + { + con.write_line(chunk, getANSIColor(clr)); + } + + int lineedit(const std::string& prompt, std::string& output, CommandHistory & ch) + { + static bool did_set_history = false; + + // I don't believe this check is necessary. + // unless, somwhow, fiothread is inited before the console. + if (con.state.is_inactive()) { + return Console::RETRY; + } + // kludge. This is the only place to set it? + if (!did_set_history) { + std::vector hist; + ch.getEntries(hist); + con.set_command_history(hist); + did_set_history = true; + } + + if (prompt != this->prompt) { + con.set_prompt(prompt); + this->prompt = prompt; + } + + int ret = con.get_line(output); + if (ret == 0) + return Console::RETRY; + else if (ret == -1) + return Console::SHUTDOWN; + + return ret; + } + /// Position cursor at x,y. 1,1 = top left corner + void gotoxy(int x, int y) + { + } + + /// Set color (ANSI color number) + /// Note: unimplemented because widgets share the same font atlas, + /// which will change color for all widgets. + void color(Console::color_value index) + { + } + + /// Set color (ANSI color number) + /// Note: unimplemented because widgets share the same font atlas, + /// which will change color for all widgets. + void reset_color(void) + { + } + + /// Enable or disable the caret/cursor + void cursor(bool enable = true) + { + } + + SDLConsole &con; + std::string prompt; // current prompt string + }; +} + +SDLConsoleDriver::SDLConsoleDriver() +{ + d = new Private(); + inited.store(false); + // we can't create the mutex at this time. the SDL functions aren't hooked yet. + wlock = new std::recursive_mutex(); +} +SDLConsoleDriver::~SDLConsoleDriver() +{ + assert(!inited); + if(wlock) + delete wlock; + if(d) + delete d; +} + +/** + * FIXME: Two-stage init because we need to initialize on + * the main thread, but interpose isn't available until later. + */ +bool SDLConsoleDriver::init(bool dont_redirect) +{ + static int init_stage = 0; + + if (init_stage == -1) + return false; + + if (init_stage == 1) { + INTERPOSE_HOOK(con_render_hook,render).apply(true); + return true; + } + + inited.store(d->con.init()); + init_stage = inited.load() ? 1 : -1; + + if (!dont_redirect) + { + if (freopen("stdout.log", "w", stdout) == nullptr) { + fputs("Failed to redirect stdout to file\n", stderr); + } + } + return inited.load(); +} + +bool SDLConsoleDriver::shutdown(void) +{ + if (!inited.load()) return true; + d->con.shutdown(); + inited.store(false); + return true; +} + +/* + * This should be for guarding against interleaving prints to the console. + * The begin_batch() and end_batch() pair does the job on its own. + */ +void SDLConsoleDriver::begin_batch() +{ + wlock->lock(); +} + +void SDLConsoleDriver::end_batch() +{ + wlock->unlock(); +} + +/* Don't think we need this? */ +void SDLConsoleDriver::flush_proxy() +{ +} + +void SDLConsoleDriver::add_text(color_value color, const std::string &text) +{ + // I don't think this lock is needed, unless to prevent + // interleaving prints. But we have batch for that? + std::lock_guard g(*wlock); + if(inited.load()) + d->print_text(color, text); + else + fwrite(text.data(), 1, text.size(), stderr); +} + +int SDLConsoleDriver::get_columns(void) +{ + // returns Console::FAILURE if inactive + return d->con.get_columns(); +} + +int SDLConsoleDriver::get_rows(void) +{ + // returns Console::FAILURE if inactive + return d->con.get_rows(); +} + +void SDLConsoleDriver::clear() +{ + d->con.clear(); +} +/* XXX: Not implemented */ +void SDLConsoleDriver::gotoxy(int x, int y) +{ +} +/* XXX: Not implemented */ +void SDLConsoleDriver::cursor(bool enable) +{ + d->cursor(enable); +} + +int SDLConsoleDriver::lineedit(const std::string & prompt, std::string & output, CommandHistory & ch) +{ + // Tell fiothread we are done. + if(!inited.load()) + return Console::SHUTDOWN; + + return d->lineedit(prompt,output,ch); +} + +void SDLConsoleDriver::msleep (unsigned int msec) +{ + std::this_thread::sleep_for(std::chrono::milliseconds(msec)); +} + +bool SDLConsoleDriver::hide() +{ + d->con.hide_window(); + return true; +} + +bool SDLConsoleDriver::show() +{ + d->con.show_window(); + return true; +} + +/* + * We should cleanup() if the console failed after init (unlikely to happen), + * or if commanded to shutdown during run time (but df not exiting). + * + * NOTE: We do not absolutely have to clean up here. It can be done at exit. + * This is for the ability to shutdown and restart the console at run time. + */ +bool SDLConsoleDriver::sdl_event_hook(SDL_Event &e) +{ + auto& con = d->con; + if (con.state.is_active()) { + return con.sdl_event_hook(e); + } else if (con.state.is_shutdown()) { + cleanup(); + } + return false; +} + +/* + * Cleanup must be done from the main thread. + * NOTE: may be able to do this in the destructor instead. + */ +void SDLConsoleDriver::cleanup() +{ + INTERPOSE_HOOK(con_render_hook,render).apply(false); + // destroy() will change console's state to inactive + d->con.destroy(); + inited.store(false); +} diff --git a/library/Console-windows.cpp b/library/Console-windows.cpp index 85934b2f37..be392fa90b 100644 --- a/library/Console-windows.cpp +++ b/library/Console-windows.cpp @@ -50,7 +50,7 @@ POSSIBILITY OF SUCH DAMAGE. #include #include -#include "Console.h" +#include "WindowsConsole.h" #include "Hooks.h" #include #include @@ -409,14 +409,14 @@ namespace DFHack } -Console::Console() +WindowsConsole::WindowsConsole() { d = 0; wlock = 0; inited = false; } -Console::~Console() +WindowsConsole::~WindowsConsole() { } /* @@ -442,7 +442,7 @@ void ForceForegroundWindow(HWND window) } } */ -bool Console::init(bool) +bool WindowsConsole::init(bool) { d = new Private(); int hConHandle; @@ -517,7 +517,7 @@ bool Console::shutdown(void) return true; } -void Console::begin_batch() +void WindowsConsole::begin_batch() { //color_ostream::begin_batch(); @@ -527,7 +527,7 @@ void Console::begin_batch() d->begin_batch(); } -void Console::end_batch() +void WindowsConsole::end_batch() { if (inited) d->end_batch(); @@ -535,21 +535,21 @@ void Console::end_batch() wlock->unlock(); } -void Console::flush_proxy() +void WindowsConsole::flush_proxy() { std::lock_guard lock{*wlock}; if (inited) d->flush(); } -void Console::add_text(color_value color, const std::string &text) +void WindowsConsole::add_text(color_value color, const std::string &text) { std::lock_guard lock{*wlock}; if (inited) d->print_text(color, text); } -int Console::get_columns(void) +int WindowsConsole::get_columns(void) { std::lock_guard lock{*wlock}; int ret = -1; @@ -558,7 +558,7 @@ int Console::get_columns(void) return ret; } -int Console::get_rows(void) +int WindowsConsole::get_rows(void) { std::lock_guard lock{*wlock}; int ret = -1; @@ -567,28 +567,28 @@ int Console::get_rows(void) return ret; } -void Console::clear() +void WindowsConsole::clear() { std::lock_guard lock{*wlock}; if(inited) d->clear(); } -void Console::gotoxy(int x, int y) +void WindowsConsole::gotoxy(int x, int y) { std::lock_guard lock{*wlock}; if(inited) d->gotoxy(x,y); } -void Console::cursor(bool enable) +void WindowsConsole::cursor(bool enable) { std::lock_guard lock{*wlock}; if(inited) d->cursor(enable); } -int Console::lineedit(const std::string & prompt, std::string & output, CommandHistory & ch) +int WindowsConsole::lineedit(const std::string & prompt, std::string & output, CommandHistory & ch) { wlock->lock(); int ret = Console::SHUTDOWN; @@ -598,18 +598,18 @@ int Console::lineedit(const std::string & prompt, std::string & output, CommandH return ret; } -void Console::msleep (unsigned int msec) +void WindowsConsole::msleep (unsigned int msec) { Sleep(msec); } -bool Console::hide() +bool WindowsConsole::hide() { ShowWindow( GetConsoleWindow(), SW_HIDE ); return true; } -bool Console::show() +bool WindowsConsole::show() { ShowWindow( GetConsoleWindow(), SW_RESTORE ); return true; diff --git a/library/Core.cpp b/library/Core.cpp index b67bfdd1d9..55e550576b 100644 --- a/library/Core.cpp +++ b/library/Core.cpp @@ -484,6 +484,19 @@ static bool try_autocomplete(color_ostream &con, const std::string &first, std:: return false; } +void Core::getAutoCompletePossibles(const std::string &first, std::vector &possibles) +{ + std::vector commands; + + get_commands(con, commands); + for (auto &command : commands) { + if (command.substr(0, first.size()) == first) + possibles.push_back(command); + } + if (commands.size() == possibles.size()) + possibles.clear(); +} + bool Core::addScriptPath(std::string path, bool search_before) { std::lock_guard lock(script_path_mutex); @@ -1464,6 +1477,7 @@ Core::~Core() } Core::Core() : + con(getConsole()), d(std::make_unique()), script_path_mutex{}, HotkeyMutex{}, @@ -1501,6 +1515,7 @@ void Core::fatal (std::string output, const char * title) if (output[output.size() - 1] != '\n') out << '\n'; out << "DFHack will now deactivate.\n"; + if(con.isInited()) { con.printerr("%s", out.str().c_str()); @@ -1645,6 +1660,10 @@ bool Core::InitMainThread() { // Init global object pointers df::global::InitGlobals(); + // SDL_CONSOLE FIXME + if(con.init(false)) + std::cerr << "Console init(first).\n"; + perf_counters.reset(); return true; @@ -1688,6 +1707,18 @@ bool Core::InitSimulationThread() std::cerr << "Headless mode not supported on Windows" << std::endl; #endif } +/* + // dump offsets to a file + std::ofstream dump("offsets.log"); + if(!dump.fail()) + { + //dump << vinfo->PrintOffsets(); + dump.close(); + } + */ + // initialize data defs + virtual_identity::Init(this); + if (is_text_mode && !is_headless) { std::cerr << "Console is not available. Use dfhack-run to send commands.\n"; @@ -1700,17 +1731,6 @@ bool Core::InitSimulationThread() std::cerr << "Console is running.\n"; else std::cerr << "Console has failed to initialize!\n"; -/* - // dump offsets to a file - std::ofstream dump("offsets.log"); - if(!dump.fail()) - { - //dump << vinfo->PrintOffsets(); - dump.close(); - } - */ - // initialize data defs - virtual_identity::Init(this); // create config directory if it doesn't already exist if (!Filesystem::mkdir_recursive(CONFIG_PATH)) @@ -2378,6 +2398,7 @@ int Core::Shutdown ( void ) plug_mgr = 0; } // invalidate all modules + con.cleanup(); allModules.clear(); Textures::cleanup(); DFSDL::cleanup(); @@ -2459,6 +2480,7 @@ void Core::setArmokTools(const std::vector &tool_names) { // returns true if the event is handled bool Core::DFH_SDL_Event(SDL_Event* ev) { uint32_t start_ms = p->getTickCount(); + if (getConsole().sdl_event_hook(*ev)) return true; bool ret = doSdlInputEvent(ev); perf_counters.incCounter(perf_counters.total_keybinding_ms, start_ms); return ret; diff --git a/library/SDLConsole.test.cpp b/library/SDLConsole.test.cpp new file mode 100644 index 0000000000..223157e020 --- /dev/null +++ b/library/SDLConsole.test.cpp @@ -0,0 +1,200 @@ +#include "SDLConsole_impl.h" +#include +#include + +using namespace sdl_console; + +TEST(SDLConsole, skip_wspace) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar"; + ret = text::skip_wspace(tstr, 3); + ASSERT_EQ(ret, 5); + + tstr = U"foo "; + ret = text::skip_wspace(tstr, 3); + ASSERT_EQ(ret, tstr.size()-1); + + tstr = U"foo"; + ret = text::skip_wspace(tstr, 9); + ASSERT_EQ(ret, tstr.size()-1); + + tstr = U""; + ret = text::skip_wspace(tstr, 1); + ASSERT_EQ(ret, 0); + + tstr = U"foo"; + ret = text::skip_wspace(tstr, -1); + ASSERT_EQ(ret, tstr.size()-1); +} + +TEST(SDLConsole, skip_wspace_reverse) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar"; + ret = text::skip_wspace_reverse(tstr, 3); + ASSERT_EQ(ret, 2); + + tstr = U"foo bar"; + ret = text::skip_wspace_reverse(tstr, 4); + ASSERT_EQ(ret, 2); + + tstr = U"foo "; + ret = text::skip_wspace_reverse(tstr, 3); + ASSERT_EQ(ret, 2); + + tstr = U"foo"; + ret = text::skip_wspace_reverse(tstr, 9); + ASSERT_EQ(ret, tstr.size()-1); + + tstr = U""; + ret = text::skip_wspace_reverse(tstr, 1); + ASSERT_EQ(ret, 0); + + tstr = U"foo"; + ret = text::skip_wspace_reverse(tstr, -1); + ASSERT_EQ(ret, tstr.size()-1); +} + +TEST(SDLConsole, skip_graph) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar"; + ret = text::skip_graph(tstr, 0); + ASSERT_EQ(ret, 3); + + tstr = U"foo bar"; + ret = text::skip_graph(tstr, 3); + ASSERT_EQ(ret, 3); + + tstr = U"foo bar"; + ret = text::skip_graph(tstr, 5); + ASSERT_EQ(ret, tstr.size()-1); + + tstr = U"foo"; + ret = text::skip_graph(tstr, 9); + ASSERT_EQ(ret, tstr.size()-1); + + tstr = U"foo"; + ret = text::skip_graph(tstr, -1); + ASSERT_EQ(ret, tstr.size()-1); +} + +TEST(SDLConsole, skip_graph_reverse) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar"; + ret = text::skip_graph_reverse(tstr, 5); + ASSERT_EQ(ret, 4); + + tstr = U"foo bar"; + ret = text::skip_graph_reverse(tstr, 2); + ASSERT_EQ(ret, 0); + + tstr = U"foo"; + ret = text::skip_graph_reverse(tstr, 5); + ASSERT_EQ(ret, 0); + + tstr = U"foo"; + ret = text::skip_graph_reverse(tstr, -1); + ASSERT_EQ(ret, 0); +} + +TEST(SDLConsole, find_prev_word) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar baz"; + ret = text::find_prev_word(tstr, 7); + ASSERT_EQ(ret, 6); + + tstr = U"foo bar baz"; + ret = text::find_prev_word(tstr, 2); + ASSERT_EQ(ret, 0); +} + +TEST(SDLConsole, find_next_word) { + std::u32string tstr; + size_t ret; + + tstr = U"foo bar baz"; + ret = text::find_next_word(tstr, 7); + ASSERT_EQ(ret, 8); + + tstr = U"foo bar baz"; + ret = text::find_next_word(tstr, 8); + ASSERT_EQ(ret, tstr.size()-1); +} + +TEST(SDLConsole, find_wspace_range) { + std::u32string tstr; + std::pair ret; + std::pair exp; + + tstr = U" "; + ret = text::find_wspace_range(tstr, 1); + exp.first = 0; + exp.second = 2; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U"foo bar"; + ret = text::find_wspace_range(tstr, 3); + exp.first = 3; + exp.second = 5; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U"foobar "; + ret = text::find_wspace_range(tstr, 6); + exp.first = 6; + exp.second = 7; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U"foobar "; + ret = text::find_wspace_range(tstr, 6); + exp.first = 6; + exp.second = 6; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U""; + ret = text::find_wspace_range(tstr, 0); + exp.first = 0; + exp.second = 0; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + +} + +TEST(SDLConsole, find_text_range) { + std::u32string tstr; + std::pair ret; + std::pair exp; + + tstr = U"foo"; + ret = text::find_text_range(tstr, 0); + exp.first = 0; + exp.second = 2; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U"foo bar"; + ret = text::find_text_range(tstr, 5); + exp.first = 4; + exp.second = 6; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); + + tstr = U"foo bar "; + ret = text::find_text_range(tstr, 5); + exp.first = 4; + exp.second = 6; + ASSERT_EQ(ret.first, exp.first); + ASSERT_EQ(ret.second, exp.second); +} diff --git a/library/SDLConsole_impl.cpp b/library/SDLConsole_impl.cpp new file mode 100644 index 0000000000..1fe24beb1d --- /dev/null +++ b/library/SDLConsole_impl.cpp @@ -0,0 +1,3517 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +//#include + +#include "SDL_pixels.h" +#include "Core.h" +#include "modules/DFSDL.h" +#include "SDLConsole.h" +#include "SDLConsole_impl.h" + +using namespace DFHack; + +namespace sdl_console { + +// These macros to be removed. +#define CONSOLE_SYMBOL_ADDR(sym) nullptr +#define CONSOLE_DECLARE_SYMBOL(sym) decltype(sym)* sym = CONSOLE_SYMBOL_ADDR(sym) + +CONSOLE_DECLARE_SYMBOL(SDL_CaptureMouse); +CONSOLE_DECLARE_SYMBOL(SDL_ConvertSurfaceFormat); +CONSOLE_DECLARE_SYMBOL(SDL_CreateRenderer); +CONSOLE_DECLARE_SYMBOL(SDL_CreateRGBSurface); +CONSOLE_DECLARE_SYMBOL(SDL_CreateRGBSurfaceWithFormat); +CONSOLE_DECLARE_SYMBOL(SDL_CreateTexture); +CONSOLE_DECLARE_SYMBOL(SDL_CreateTextureFromSurface); +CONSOLE_DECLARE_SYMBOL(SDL_CreateWindow); +CONSOLE_DECLARE_SYMBOL(SDL_DestroyRenderer); +CONSOLE_DECLARE_SYMBOL(SDL_DestroyTexture); +CONSOLE_DECLARE_SYMBOL(SDL_DestroyWindow); +CONSOLE_DECLARE_SYMBOL(SDL_free); +CONSOLE_DECLARE_SYMBOL(SDL_FreeSurface); +CONSOLE_DECLARE_SYMBOL(SDL_GetClipboardText); +CONSOLE_DECLARE_SYMBOL(SDL_GetError); +CONSOLE_DECLARE_SYMBOL(SDL_GetEventFilter); +CONSOLE_DECLARE_SYMBOL(SDL_GetModState); +CONSOLE_DECLARE_SYMBOL(SDL_GetRendererOutputSize); +CONSOLE_DECLARE_SYMBOL(SDL_GetWindowFlags); +CONSOLE_DECLARE_SYMBOL(SDL_GetWindowID); +CONSOLE_DECLARE_SYMBOL(SDL_GetTicks64); +CONSOLE_DECLARE_SYMBOL(SDL_HideWindow); +CONSOLE_DECLARE_SYMBOL(SDL_iconv_string); +CONSOLE_DECLARE_SYMBOL(SDL_InitSubSystem); +CONSOLE_DECLARE_SYMBOL(SDL_MapRGB); +CONSOLE_DECLARE_SYMBOL(SDL_memset); +CONSOLE_DECLARE_SYMBOL(SDL_RenderClear); +CONSOLE_DECLARE_SYMBOL(SDL_RenderCopy); +CONSOLE_DECLARE_SYMBOL(SDL_RenderDrawRect); +CONSOLE_DECLARE_SYMBOL(SDL_RenderFillRect); +CONSOLE_DECLARE_SYMBOL(SDL_RenderFillRects); +CONSOLE_DECLARE_SYMBOL(SDL_RenderPresent); +CONSOLE_DECLARE_SYMBOL(SDL_RenderSetIntegerScale); +CONSOLE_DECLARE_SYMBOL(SDL_RenderSetViewport); +//CONSOLE_DECLARE_SYMBOL(SDL_PointInRect); // defined in header +CONSOLE_DECLARE_SYMBOL(SDL_SetClipboardText); +CONSOLE_DECLARE_SYMBOL(SDL_SetColorKey); +CONSOLE_DECLARE_SYMBOL(SDL_SetEventFilter); +CONSOLE_DECLARE_SYMBOL(SDL_SetHint); +CONSOLE_DECLARE_SYMBOL(SDL_SetRenderDrawColor); +CONSOLE_DECLARE_SYMBOL(SDL_SetTextureBlendMode); +CONSOLE_DECLARE_SYMBOL(SDL_SetTextureColorMod); +CONSOLE_DECLARE_SYMBOL(SDL_SetWindowMinimumSize); +CONSOLE_DECLARE_SYMBOL(SDL_SetWindowOpacity); +CONSOLE_DECLARE_SYMBOL(SDL_ShowCursor); +CONSOLE_DECLARE_SYMBOL(SDL_ShowWindow); +CONSOLE_DECLARE_SYMBOL(SDL_StartTextInput); +CONSOLE_DECLARE_SYMBOL(SDL_StopTextInput); +CONSOLE_DECLARE_SYMBOL(SDL_UpperBlit); +CONSOLE_DECLARE_SYMBOL(SDL_UpdateTexture); +CONSOLE_DECLARE_SYMBOL(SDL_QuitSubSystem); + +void bind_sdl_symbols() +{ + static bool didit = false; + if (didit) return; + didit = true; + + struct Symbol { + const char* name; + void** addr; + }; + + #define CONSOLE_ADD_SYMBOL(sym) \ + { \ + #sym, (void**)&sdl_console::sym \ + } + + /* This list must be in parity with CONSOLE_DEFINE_SYMBOL */ + std::vector symbols = { + CONSOLE_ADD_SYMBOL(SDL_CaptureMouse), + CONSOLE_ADD_SYMBOL(SDL_ConvertSurfaceFormat), + CONSOLE_ADD_SYMBOL(SDL_CreateRenderer), + CONSOLE_ADD_SYMBOL(SDL_CreateRGBSurface), + CONSOLE_ADD_SYMBOL(SDL_CreateRGBSurfaceWithFormat), + CONSOLE_ADD_SYMBOL(SDL_CreateTexture), + CONSOLE_ADD_SYMBOL(SDL_CreateTextureFromSurface), + CONSOLE_ADD_SYMBOL(SDL_CreateWindow), + CONSOLE_ADD_SYMBOL(SDL_DestroyRenderer), + CONSOLE_ADD_SYMBOL(SDL_DestroyTexture), + CONSOLE_ADD_SYMBOL(SDL_DestroyWindow), + CONSOLE_ADD_SYMBOL(SDL_free), + CONSOLE_ADD_SYMBOL(SDL_FreeSurface), + CONSOLE_ADD_SYMBOL(SDL_GetClipboardText), + CONSOLE_ADD_SYMBOL(SDL_GetError), + CONSOLE_ADD_SYMBOL(SDL_GetEventFilter), + CONSOLE_ADD_SYMBOL(SDL_GetModState), + CONSOLE_ADD_SYMBOL(SDL_GetRendererOutputSize), + CONSOLE_ADD_SYMBOL(SDL_GetWindowFlags), + CONSOLE_ADD_SYMBOL(SDL_GetWindowID), + CONSOLE_ADD_SYMBOL(SDL_GetTicks64), + CONSOLE_ADD_SYMBOL(SDL_HideWindow), + CONSOLE_ADD_SYMBOL(SDL_iconv_string), + CONSOLE_ADD_SYMBOL(SDL_InitSubSystem), + CONSOLE_ADD_SYMBOL(SDL_MapRGB), + CONSOLE_ADD_SYMBOL(SDL_memset), + CONSOLE_ADD_SYMBOL(SDL_RenderClear), + CONSOLE_ADD_SYMBOL(SDL_RenderCopy), + CONSOLE_ADD_SYMBOL(SDL_RenderDrawRect), + CONSOLE_ADD_SYMBOL(SDL_RenderFillRect), + CONSOLE_ADD_SYMBOL(SDL_RenderFillRects), + CONSOLE_ADD_SYMBOL(SDL_RenderPresent), + CONSOLE_ADD_SYMBOL(SDL_RenderSetIntegerScale), + CONSOLE_ADD_SYMBOL(SDL_RenderSetViewport), +// CONSOLE_ADD_SYMBOL(SDL_PointInRect), // defined in header + CONSOLE_ADD_SYMBOL(SDL_SetClipboardText), + CONSOLE_ADD_SYMBOL(SDL_SetColorKey), + CONSOLE_ADD_SYMBOL(SDL_SetEventFilter), + CONSOLE_ADD_SYMBOL(SDL_SetHint), + CONSOLE_ADD_SYMBOL(SDL_SetRenderDrawColor), + CONSOLE_ADD_SYMBOL(SDL_SetTextureBlendMode), + CONSOLE_ADD_SYMBOL(SDL_SetTextureColorMod), + CONSOLE_ADD_SYMBOL(SDL_SetWindowMinimumSize), + CONSOLE_ADD_SYMBOL(SDL_SetWindowOpacity), + CONSOLE_ADD_SYMBOL(SDL_ShowCursor), + CONSOLE_ADD_SYMBOL(SDL_ShowWindow), + CONSOLE_ADD_SYMBOL(SDL_StartTextInput), + CONSOLE_ADD_SYMBOL(SDL_StopTextInput), + CONSOLE_ADD_SYMBOL(SDL_UpperBlit), + CONSOLE_ADD_SYMBOL(SDL_UpdateTexture), + CONSOLE_ADD_SYMBOL(SDL_QuitSubSystem) + }; + #undef CONSOLE_ADD_SYMBOL + + for (auto& sym : symbols) { + *sym.addr = DFSDL::lookup_DFSDL_Symbol(sym.name); + if (*sym.addr == nullptr) + throw std::runtime_error(std::string("SDLConsole: SDL symbol not found: ") + sym.name); + } +} + + +namespace text { +#if 0 + // From Console-posix + //! Convert a locale defined multibyte coding to UTF-32 string for easier + //! character processing. + // UNUSED + std::u32string from_locale_mb(const std::string& str) + { + std::u32string rv; + std::u32string::value_type ch; + size_t pos = 0; + ssize_t sz; + std::mbstate_t state{}; + while ((sz = std::mbrtoc32(&ch,&str[pos], str.size() - pos, &state)) != 0) { + if (sz == -1 || sz == -2) + break; + rv.push_back(ch); + if (sz == -3) /* multi value character */ + continue; + pos += sz; + } + return rv; + } + + //! Convert a UTF-32 string back to locale defined multibyte coding. + // UNUSED + std::string to_locale_mb(const std::u32string& wstr) + { + std::stringstream ss{}; + char mb[MB_CUR_MAX]; + std::mbstate_t state{}; + const size_t err = -1; + for (auto ch: wstr) { + size_t sz = std::c32rtomb(mb, ch, &state); + if (sz == err) + break; + ss.write(mb, sz); + } + return ss.str(); + } +#endif + + std::string to_utf8(const std::u32string& u32_string) + { + char* conv = sdl_console::SDL_iconv_string("UTF-8", "UTF-32LE", + reinterpret_cast(u32_string.c_str()), + (u32_string.size()+1) * sizeof(char32_t)); + if (!conv) + return "?u8?"; + + std::string result(conv); + sdl_console::SDL_free(conv); + return result; + } + + std::u32string from_utf8(std::string u8_string) + { + char* conv = SDL_iconv_string("UTF-32LE", "UTF-8", + u8_string.c_str(), + u8_string.size() + 1); + if (!conv) + return U"?u8?"; + + std::u32string result(reinterpret_cast(conv)); + sdl_console::SDL_free(conv); + return result; + } + + size_t utf8_strlen(const char* str) + { + size_t count = 0; + size_t i = 0; + while (str[i]) { + unsigned char byte = str[i]; + if ((byte & 0x80) == 0) { + ++i; + } else if ((byte & 0xE0) == 0xC0) { + i += 2; + } else if ((byte & 0xF0) == 0xE0) { + i += 3; + } else if ((byte & 0xF8) == 0xF0) { + i += 4; + } else { + // Invalid byte + ++i; + } + ++count; + } + return count; + } + + void erase_surrogate_pairs(std::u16string& text) { + constexpr char16_t replacement = u'?'; + + for (auto it = text.begin(); it != text.end(); ) { + char16_t current = *it; + + if (current >= 0xD800 && current <= 0xDBFF) { // High surrogate + auto next = std::next(it); + if (next != text.end() && *next >= 0xDC00 && *next <= 0xDFFF) { + *it = replacement; + // Erase pair + it = text.erase(it, std::next(it)); + } else { + *it = replacement; + // Invalid high + it = text.erase(it); + } + } else if (current >= 0xDC00 && current <= 0xDFFF) { + *it = replacement; + // Invalid low + it = text.erase(it); + } else { + ++it; + } + } + } + + bool is_newline(char32_t ch) { + return ch == U'\n' || ch == U'\r'; + } + + bool is_wspace(char32_t ch) { + return ch == U' ' || ch == U'\t'; + } + + std::pair find_range_with_pred(const std::u32string& text, size_t pos, std::function predicate) { + if (text.empty()) return { 0, 0 }; + else if (pos >= text.size()) return { text.size()-1, text.size()-1 }; + + auto left = text.begin() + pos; + auto right = left; + + while (left != text.begin() && predicate(*(left - 1))) + --left; + + auto t = right; + while (right != text.end() && predicate(*right)) + ++right; + + if (t != right) + --right; + + return { + std::distance(text.begin(), left), + std::distance(text.begin(), right) + }; + } + +#if 0 + size_t skip_wspace(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + else if (pos >= text.size()) return text.size() - 1; + + auto it = text.begin() + pos; + while (it != text.end() && is_wspace(*it)) { + ++it; + } + + if (it == text.end()) --it; + return std::distance(text.begin(), it); + } +#endif + + size_t skip_wspace(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + else if (pos >= text.size()) return text.size() - 1; + + return std::distance(text.begin(), + std::ranges::find_if_not(std::ranges::subrange(text.begin() + pos, text.end() - 1), + is_wspace)); + } + + size_t skip_wspace_reverse(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + else if (pos >= text.size()) pos = text.size() - 1; + + auto it = text.begin() + pos; + while (it != text.begin() && is_wspace(*it)) { + --it; + } + return std::distance(text.begin(), it); + } + + /* + size_t skip_graph(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + else if (pos >= text.size()) return text.size() - 1; + + auto it = text.begin() + pos; + while (it != text.end() && !is_wspace(*it)) { + ++it; + } + if (it == text.end()) --it; + return std::distance(text.begin(), it); + }*/ + + size_t skip_graph(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + if (pos >= text.size()) return text.size() - 1; + + return std::distance(text.begin(), + std::ranges::find_if(std::ranges::subrange(text.begin() + pos, text.end() - 1), + is_wspace)); + } + + size_t skip_graph_reverse(const std::u32string& text, size_t pos) { + if (text.empty()) return 0; + else if (pos >= text.size()) pos = text.size() - 1; + + auto it = text.begin() + pos; + while (it != text.begin() && !is_wspace(*it)) { + --it; + } + return std::distance(text.begin(), it); + } + + /* + * Finds the end of the previous word or non-space character in the text, + * starting from `pos`. If `pos` points to a space, it skips consecutive + * spaces to find the previous word. If `pos` is already at a word, it skips + * the current word and trailing spaces to find the next one. Returns the + * position of the end of the previous word or non-space character. + */ + size_t find_prev_word(const std::u32string& text, size_t pos) { + //if (text.empty()) return 0; + //else if (pos >= text.size()) pos = text.size() - 1; + + size_t start = pos; + start = skip_wspace_reverse(text, start); + if (start == pos) { + pos = skip_graph_reverse(text, pos); + pos = skip_wspace_reverse(text, pos); + } else { + pos = start; + } + return pos; + } + + /* + * Finds the start of the next word or non-space character in the text, + * starting from `pos`. If `pos` points to a space, it skips consecutive + * spaces to find the next word. If `pos` is already at a word, it skips + * the current word and trailing spaces to find the next one. Returns the + * position of the start of the next word or non-space character. + */ + size_t find_next_word(const std::u32string& text, size_t pos) { + size_t start = pos; + start = skip_wspace(text, start); + if (start == pos) { + pos = skip_graph(text, pos); + pos = skip_wspace(text, pos); + } else { + pos = start; + } + return pos; + } + + std::pair find_wspace_range(const std::u32string& text, size_t pos) { + return find_range_with_pred(text, pos, [](char32_t ch) { return is_wspace(ch); }); + } + + std::pair find_text_range(const std::u32string& text, size_t pos) { + // Bounds check here since .at() is used below + if (text.empty() || pos >= text.size()) { + return { std::u32string::npos, std::u32string::npos }; + } + + if (is_wspace(text.at(pos))) { + return find_wspace_range(text, pos); + } + return find_range_with_pred(text, pos, [](char32_t ch) { return !is_wspace(ch); }); + } +} + +namespace geometry { +void center_rect(SDL_Rect& r) +{ + r.x = r.x - r.w / 2; + r.y = r.y - r.h / 2; +} + +bool in_rect(int x, int y, SDL_Rect& r) +{ + return ((x >= r.x) && (x < (r.x + r.w)) && (y >= r.y) && (y < (r.y + r.h))); +} + +bool in_rect(SDL_Point& p, SDL_Rect& r) +{ + return SDL_PointInRect(&p, &r); +} + +bool is_y_within_bounds(int y, int y_top, int height) +{ + return (y >= y_top && y <= y_top + height); +} +} // geometry + +/* + * These utility functions provide basic grid-based boundary calculations. + * They are likely better suited for a dedicated Grid class which can + * simplify the logic in components like OutputPane. + */ +namespace grid { +int floor_boundary(int position, int cell_size) +{ + return std::floor((float)(position) / cell_size) * cell_size; +} + +int ceil_boundary(int position, int cell_size) +{ + return std::ceil((float)(position) / cell_size) * cell_size; +} +} + +// Should probably be kept for effecient mapping -- +// although char16_t should probably be used instead of char32_t. +static const std::unordered_map unicode_to_cp437 = { + // Control characters and symbols + /* NULL */ { 0x263A, 0x01 }, { 0x263B, 0x02 }, { 0x2665, 0x03 }, + { 0x2666, 0x04 }, { 0x2663, 0x05 }, { 0x2660, 0x06 }, { 0x2022, 0x07 }, + { 0x25D8, 0x08 }, { 0x25CB, 0x09 }, { 0x25D9, 0x0A }, { 0x2642, 0x0B }, + { 0x2640, 0x0C }, { 0x266A, 0x0D }, { 0x266B, 0x0E }, { 0x263C, 0x0F }, + + { 0x25BA, 0x10 }, { 0x25C4, 0x11 }, { 0x2195, 0x12 }, { 0x203C, 0x13 }, + { 0x00B6, 0x14 }, { 0x00A7, 0x15 }, { 0x25AC, 0x16 }, { 0x21A8, 0x17 }, + { 0x2191, 0x18 }, { 0x2193, 0x19 }, { 0x2192, 0x1A }, { 0x2190, 0x1B }, + { 0x221F, 0x1C }, { 0x2194, 0x1D }, { 0x25B2, 0x1E }, { 0x25BC, 0x1F }, + + // ASCII, no mapping needed + + // Extended Latin characters and others + { 0x2302, 0x7F }, + + { 0x00C7, 0x80 }, { 0x00FC, 0x81 }, { 0x00E9, 0x82 }, { 0x00E2, 0x83 }, + { 0x00E4, 0x84 }, { 0x00E0, 0x85 }, { 0x00E5, 0x86 }, { 0x00E7, 0x87 }, + { 0x00EA, 0x88 }, { 0x00EB, 0x89 }, { 0x00E8, 0x8A }, { 0x00EF, 0x8B }, + { 0x00EE, 0x8C }, { 0x00EC, 0x8D }, { 0x00C4, 0x8E }, { 0x00C5, 0x8F }, + + { 0x00C9, 0x90 }, { 0x00E6, 0x91 }, { 0x00C6, 0x92 }, { 0x00F4, 0x93 }, + { 0x00F6, 0x94 }, { 0x00F2, 0x95 }, { 0x00FB, 0x96 }, { 0x00F9, 0x97 }, + { 0x00FF, 0x98 }, { 0x00D6, 0x99 }, { 0x00DC, 0x9A }, { 0x00A2, 0x9B }, + { 0x00A3, 0x9C }, { 0x00A5, 0x9D }, { 0x20A7, 0x9E }, { 0x0192, 0x9F }, + + { 0x00E1, 0xA0 }, { 0x00ED, 0xA1 }, { 0x00F3, 0xA2 }, { 0x00FA, 0xA3 }, + { 0x00F1, 0xA4 }, { 0x00D1, 0xA5 }, { 0x00AA, 0xA6 }, { 0x00BA, 0xA7 }, + { 0x00BF, 0xA8 }, { 0x2310, 0xA9 }, { 0x00AC, 0xAA }, { 0x00BD, 0xAB }, + { 0x00BC, 0xAC }, { 0x00A1, 0xAD }, { 0x00AB, 0xAE }, { 0x00BB, 0xAF }, + + // Box drawing characters + { 0x2591, 0xB0 }, { 0x2592, 0xB1 }, { 0x2593, 0xB2 }, { 0x2502, 0xB3 }, + { 0x2524, 0xB4 }, { 0x2561, 0xB5 }, { 0x2562, 0xB6 }, { 0x2556, 0xB7 }, + { 0x2555, 0xB8 }, { 0x2563, 0xB9 }, { 0x2551, 0xBA }, { 0x2557, 0xBB }, + { 0x255D, 0xBC }, { 0x255C, 0xBD }, { 0x255B, 0xBE }, { 0x2510, 0xBF }, + + { 0x2514, 0xC0 }, { 0x2534, 0xC1 }, { 0x252C, 0xC2 }, { 0x251C, 0xC3 }, + { 0x2500, 0xC4 }, { 0x253C, 0xC5 }, { 0x255E, 0xC6 }, { 0x255F, 0xC7 }, + { 0x255A, 0xC8 }, { 0x2554, 0xC9 }, { 0x2569, 0xCA }, { 0x2566, 0xCB }, + { 0x2560, 0xCC }, { 0x2550, 0xCD }, { 0x256C, 0xCE }, { 0x2567, 0xCF }, + + { 0x2568, 0xD0 }, { 0x2564, 0xD1 }, { 0x2565, 0xD2 }, { 0x2559, 0xD3 }, + { 0x2558, 0xD4 }, { 0x2552, 0xD5 }, { 0x2553, 0xD6 }, { 0x256B, 0xD7 }, + { 0x256A, 0xD8 }, { 0x2518, 0xD9 }, { 0x250C, 0xDA }, { 0x2588, 0xDB }, + { 0x2584, 0xDC }, { 0x258C, 0xDD }, { 0x2590, 0xDE }, { 0x2580, 0xDF }, + + // Mathematical symbols and others + { 0x03B1, 0xE0 }, { 0x00DF, 0xE1 }, { 0x0393, 0xE2 }, { 0x03C0, 0xE3 }, + { 0x03A3, 0xE4 }, { 0x03C3, 0xE5 }, { 0x00B5, 0xE6 }, { 0x03C4, 0xE7 }, + { 0x03A6, 0xE8 }, { 0x0398, 0xE9 }, { 0x03A9, 0xEA }, { 0x03B4, 0xEB }, + { 0x221E, 0xEC }, { 0x03C6, 0xED }, { 0x03B5, 0xEE }, { 0x2229, 0xEF }, + + { 0x2261, 0xF0 }, { 0x00B1, 0xF1 }, { 0x2265, 0xF2 }, { 0x2264, 0xF3 }, + { 0x2320, 0xF4 }, { 0x2321, 0xF5 }, { 0x00F7, 0xF6 }, { 0x2248, 0xF7 }, + { 0x00B0, 0xF8 }, { 0x2219, 0xF9 }, { 0x00B7, 0xFA }, { 0x221A, 0xFB }, + { 0x207F, 0xFC }, { 0x00B2, 0xFD }, { 0x25A0, 0xFE }, { 0x00A0, 0xFF }, +}; + + +#if 0 +class Logger { +public: + explicit Logger(const std::string& prefix) + : prefix_(prefix) {} + + void log_error(const std::string& message) { + log("ERROR", message); + } + + void log_status(const std::string& message) { + log("STATUS", message); + } + + void log_message(const std::string& message) { + log("MESSAGE", message); + } + +private: + // Log with a prefix (e.g., ERROR, STATUS) and include the app name + void log(const std::string& level, const std::string& message) { + std::lock_guard lock(mutex); + + auto now = std::chrono::system_clock::now(); + auto time = std::chrono::system_clock::to_time_t(now); + + std::cerr << "[" << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S") + << "] " + << "[" << prefix_ << "] " + << "[" << level << "] " << message << std::endl; + } + + std::string prefix_; + std::mutex mutex; +}; +#endif + +enum class ScrollDirection { + up, + down, + page_up, + page_down +}; + +/* + * SDL_EventType has storage uint32, but + * only uses up to uint16 for use by its internal arrays. This leaves + * plenty of room for custom types. + */ +struct InternalEventType { + enum Type : Uint32 { + new_command_input = SDL_LASTEVENT + 1, + new_input, + clicked, + font_size_changed, + value_changed, + text_selection_changed, + }; +}; + +enum class TextEntryType { + input, + output +}; + +namespace colors { + // Default palette. Needs more. Needs configurable. + const SDL_Color white = { 255, 255, 255, 255 }; +// const SDL_Color lightgray = { 211, 211, 211, 255 }; + const SDL_Color mediumgray = { 65, 65, 65, 255 }; +// const SDL_Color charcoal = { 54, 69, 79, 255 }; + const SDL_Color darkgray = { 27, 27, 27, 255 }; + + const SDL_Color mauve = { 100,68,84, 255}; + const SDL_Color gold = { 247,193,41, 255}; + const SDL_Color teal = { 94, 173, 146, 255}; +} + +void render_texture( + SDL_Renderer* renderer, + SDL_Texture* texture, + const SDL_Rect& dst); + +int set_draw_color(SDL_Renderer*, const SDL_Color&); + +/* + * Manages the lifetime of thread-specific, long-term SDL resources. + * This class should not be used to store temporary resources. + * + * - Long-term resources: SDL objects (e.g., textures, renderers, windows) + * that persist beyond the scope of a single function or operation and are shared + * across multiple parts of a thread. This class ensures they are properly tracked + * and destroyed when no longer needed. + * + * - Temporary resources: SDL objects created and used entirely within the scope + * of a function or operation (e.g., a texture generated to render a single frame + * or a surface used for immediate computation). Such resources should be managed + * directly within the components that create them and do not need to be tracked + * by this class. + */ +class SDLThreadSpecificData { +public: + using Texture = std::unique_ptr; + using Renderer = std::unique_ptr; + using Window = std::unique_ptr; + + SDLThreadSpecificData() = default; + + SDL_Texture* CreateTextureFromSurface(SDL_Renderer* r, SDL_Surface* s) + { + auto p = sdl_console::SDL_CreateTextureFromSurface(r, s); + if (!p) return nullptr; + textures_.emplace_back(make_unique_texture(p)); + return p; + } + + SDL_Texture* CreateTexture(SDL_Renderer* r, + Uint32 format, int access, + int w, int h) + { + auto p = sdl_console::SDL_CreateTexture(r, format, access, w, h); + if (!p) return nullptr; + textures_.emplace_back(make_unique_texture(p)); + return p; + } + + void DestroyTexture(SDL_Texture *texture) + { + auto it = std::ranges::find_if(textures_, + [texture](const Texture& ptr) { + return ptr.get() == texture; + }); + if (it != textures_.end()) { + textures_.erase(it); + } + } + + SDL_Renderer* CreateRenderer(SDL_Window *handle, int index, Uint32 flags) + { + auto p = sdl_console::SDL_CreateRenderer(handle, index, flags); + if (!p) return nullptr; + renderers_.emplace_back(make_unique_renderer(p)); + return p; + } + + SDL_Window* CreateWindow(const char *title, + int x, int y, int w, int h, + Uint32 flags) + { + auto p = sdl_console::SDL_CreateWindow(title, x, y, w, h, flags); + if (!p) return nullptr; + windows_.emplace_back(make_unique_window(p)); + return p; + } + + void clear() + { + // Order matters! + textures_.clear(); + renderers_.clear(); + windows_.clear(); + } + + ~SDLThreadSpecificData() + { + // TODO: Investigate whether it is safe to destroy resources + // after SDL_Quit() is called. + clear(); + } + +private: + Texture make_unique_texture(SDL_Texture* texture) + { + return Texture(texture, sdl_console::SDL_DestroyTexture); + } + + Renderer make_unique_renderer(SDL_Renderer* renderer) + { + return Renderer(renderer, sdl_console::SDL_DestroyRenderer); + } + + Window make_unique_window(SDL_Window* window) + { + return Window(window, sdl_console::SDL_DestroyWindow); + } + + std::vector windows_; + std::vector renderers_; + std::vector textures_; +}; + +static thread_local SDLThreadSpecificData sdl_tsd; + +/* + * A lightweight implementation inspired by Qt's signals and slots, designed + * for integrating with SDL_Event. + * + * TODO: Consider adding a custom event type for internal events to eliminate + * the dependency on SDL_UserEvent. + * + * ISlot: An interface for slots, which are event handlers. A slot can: + * - Be invoked with an SDL_Event. + * - Be connected or disconnected from a signal. + * - Check its connection status. + * + * ISignal: An interface for signals, which are event emitters. A signal can: + * - Disconnect specific slots based on the event type. + * - Reconnect slots to specific event types. + * - Check if a slot is connected to an event type. + * + * Slot: A templated implementation of ISlot for specific SDL event + * types (e.g., SDL_KeyboardEvent, SDL_MouseButtonEvent). It wraps a callable + * object (std::function) that is invoked when the event occurs. + */ + +class ISlot { +public: + virtual ~ISlot() = default; + virtual void invoke(SDL_Event& event) = 0; + virtual void disconnect() = 0; + virtual void connect() = 0; + virtual bool is_connected() = 0; +}; + +class ISignal { +public: + virtual ~ISignal() = default; + // todo for connect() + virtual void disconnect(Uint32 event_type, ISlot* slot) = 0; + virtual void reconnect(Uint32 event_type, ISlot* slot) = 0; + virtual bool is_connected(Uint32 event_type, ISlot* slot) = 0; +}; + +template +class Slot : public ISlot { +public: + using Func = std::function; + + Slot(ISignal& emitter, Uint32 event_type, Func& func) + : emitter_(emitter) + , event_type_(event_type) + , func_(func) + { + } + + void invoke(SDL_Event& event) override + { + func_(get_event(event)); + } + + void disconnect() override + { + emitter_.disconnect(event_type_, this); + } + + void connect() override + { + emitter_.reconnect(event_type_, this); + } + + bool is_connected() override + { + return emitter_.is_connected(event_type_, this); + } + + ~Slot() = default; + +private: + ISignal& emitter_; + Uint32 event_type_; + Func func_; + + EventType& get_event(SDL_Event& event) + { + // These branches are evaluated at compile time. + if constexpr (std::is_same_v) { + return event.key; + } else if constexpr (std::is_same_v) { + return event.button; + } else if constexpr (std::is_same_v) { + return event.motion; + } else if constexpr (std::is_same_v) { + return event.user; + } else if constexpr (std::is_same_v) { + return event.text; + } else if constexpr (std::is_same_v) { + return event.wheel; + } else if constexpr (std::is_same_v) { + return event.window; + } else { + static_assert(std::is_same_v, "Unsupported event type"); + } + } +}; + +class SignalEmitter : public ISignal { +public: + template + ISlot* connect(Uint32 event_type, typename Slot::Func func) + { + auto slot = std::make_unique>(*this, event_type, func); + return slots_[event_type].emplace_back(std::move(slot)).get(); + } + + template + ISlot* connect_later(Uint32 event_type, typename Slot::Func func) + { + auto slot = std::make_unique>(*this, event_type, func); + return disconnected_slots_[event_type].emplace_back(std::move(slot)).get(); + } + + void disconnect(Uint32 event_type, ISlot* slot) override + { + auto& slots = slots_[event_type]; + auto it = std::ranges::find_if(slots, [slot](const std::unique_ptr& s) { + return s.get() == slot; + }); + + if (it != slots.end()) { + disconnected_slots_[event_type].emplace_back(std::move(*it)); + slots.erase(it); + } + } + + void reconnect(Uint32 event_type, ISlot* slot) override + { + auto& disconnected_slots = disconnected_slots_[event_type]; + auto it = std::ranges::find_if(disconnected_slots, [slot](const std::unique_ptr& s) { + return s.get() == slot; + }); + + if (it != disconnected_slots.end()) { + slots_[event_type].emplace_back(std::move(*it)); + disconnected_slots.erase(it); + } + } + + bool is_connected(Uint32 event_type, ISlot* slot) override + { + return std::ranges::any_of(slots_[event_type], [slot](const std::unique_ptr& s) { + return s.get() == slot; + }); + } + + void emit(SDL_Event& event) + { + auto it = slots_.find(event.type); + if (it != slots_.end()) { + for (auto& slot : it->second) { + slot->invoke(event); + } + } + } + + void emit(InternalEventType::Type type) + { + auto e = make_sdl_user_event(type, nullptr); + emit(e); + } + + void emit(InternalEventType::Type type, void* data1) + { + auto e = make_sdl_user_event(type, data1); + emit(e); + } + + void clear() + { + slots_.clear(); + disconnected_slots_.clear(); + } + + static SDL_Event make_sdl_user_event(InternalEventType::Type type, void* data1) + { + SDL_Event event; + sdl_console::SDL_zero(event); + event.type = type; + event.user.data1 = data1; + return event; + } + + template + static T copy_data1_from_userevent(SDL_UserEvent& e, T default_value) { + if (e.data1 == nullptr) { + return default_value; + } + + T* value = static_cast(e.data1); + return *value; + } + +private: + using Container = std::vector>; + std::map slots_; + std::map disconnected_slots_; +}; + +/* + * Stores configuration for components. + */ +class Property { + using Value = std::variant; +public: + template + void set(const std::string& key, const T& value) { + std::scoped_lock l(m_); + props_[key] = value; + } + + template + T get(const std::string& key, const T& default_value = T{}) { + std::scoped_lock l(m_); + auto it = props_.find(key); + if (it == props_.end()) { + return default_value; + } + + if (std::holds_alternative(it->second) == false) { + return default_value; // TODO: log error + } + return std::get(it->second); + } + +private: + std::unordered_map props_; + std::recursive_mutex m_; +}; + +namespace property { + // These must be set before SDLConsole::init() + constexpr char WINDOW_MAIN_CREATE_RECT[] = "window.main.create.rect"; + constexpr char WINDOW_MAIN_TITLE[] = "window.main.title"; + + // Set any time. + constexpr char OUTPUT_SCROLLBACK[] = "output.scrollback"; + constexpr char PROMPT_TEXT[] = "prompt.text"; + + // Runtime information. Read only. + constexpr char RT_OUTPUT_ROWS[] = "rt.output.rows"; + constexpr char RT_OUTPUT_COLUMNS[] = "rt.output.columns"; + +} + +class Widget; +SDL_Texture* create_text_texture(Widget&, const std::u32string&, const SDL_Color&); + +class TextEntry { +public: + // A fragment is simply a chunk of text. + struct Fragment { + std::u32string_view text; + size_t entry_offset; // position of fragment whin TextEntry + size_t start_offset; // 0-based start position of this fragment + size_t end_offset; // 0-based send position of this fragment + SDL_Point coord {}; + + Fragment(std::u32string_view text, size_t entry_offset, size_t start_offset, size_t end_offset) + : text(text) + , entry_offset(entry_offset) + , start_offset(start_offset) + , end_offset(end_offset) {}; + + ~Fragment() + { + } + + Fragment(const Fragment&) = delete; + Fragment& operator=(const Fragment&) = delete; + }; + using Fragments = std::deque; + + TextEntryType type; + // Unfragmented text. + std::u32string text; + SDL_Rect rect {}; + size_t size { 0 }; // # of fragments + std::optional color_opt; + + TextEntry() {}; + + ~TextEntry() {}; + + TextEntry(TextEntryType type, const std::u32string& text, std::optional& color) + : type(type) + , text(text) + , color_opt(color) + {}; + + auto& add_fragment(std::u32string_view text, size_t start_offset, size_t end_offset) + { + return fragments_.emplace_back(text, size++, start_offset, end_offset); + } + + void clear() + { + size = 0; + fragments_.clear(); + } + + Fragments& fragments() + { + return fragments_; + } + + void wrap_text( + const int char_width, + const int viewport_width) + { + // clear the fragments we're rebuilding. + clear(); + + struct Range { + int start; + int end; + }; + + int delim_idx = -1; + int range_start_idx = 0; + int text_idx = 0; + std::vector ranges; + + auto close_fragment = [&](int end_idx) { + ranges.emplace_back(range_start_idx, end_idx); + }; + + auto open_fragment = [&](int idx) { + range_start_idx = idx + 1; + }; + + for (const auto& ch : text) { + if (text::is_newline(ch)) { + if (text_idx > range_start_idx) { + close_fragment(text_idx-1); // Up to newline + } + open_fragment(text_idx); // Skip new line + delim_idx = -1; + } else if (text::is_wspace(ch)) { + delim_idx = text_idx; // Last space or tab character + } + + if ((text_idx - range_start_idx + 1) * char_width >= viewport_width) { + if (delim_idx != -1) { + close_fragment(delim_idx); // Wrap at the last whitespace + open_fragment(delim_idx); + delim_idx = -1; + } else { + close_fragment(text_idx); // Wrap at current character + open_fragment(text_idx); + } + } + + text_idx++; + } + + // Handle remaining text + if (range_start_idx < (int)text.size()) { + close_fragment(text.size() - 1); + } + + for (const auto& range : ranges) { + if (range.end >= range.start) { // guard against empty fragments for insurance + std::u32string_view view(text); + add_fragment(view.substr(range.start, range.end - range.start + 1), range.start, range.end); + } + } + } + + std::optional> fragment_from_offset(size_t index) + { + for (auto& frag : fragments_) { + if (index >= frag.start_offset && index <= frag.end_offset) { + return frag; + } + } + return std::nullopt; + } + + TextEntry(const TextEntry&) = delete; + TextEntry& operator=(const TextEntry&) = delete; + +private: + Fragments fragments_; +}; + +struct Glyph { + SDL_Rect rect; +}; + +// XXX, TODO: cleanup. +class Font : public SignalEmitter { + class ScopedColor { + public: + ScopedColor(Font* font) : font_(font) {} + ScopedColor(Font* font, const SDL_Color& color) + : font_(font) + { + set(color); + } + + void set(const SDL_Color& color) + { + SDL_SetTextureColorMod(font_->texture_, color.r, color.g, color.b); + } + + ~ScopedColor() { + SDL_SetTextureColorMod(font_->texture_, 255, 255, 255); + } + + private: + Font* font_; + + ScopedColor(const ScopedColor&) = delete; + ScopedColor& operator=(const ScopedColor&) = delete; + }; + +// FIXME: make members private and add accessors +public: + SDL_Renderer* renderer_; + SDL_Texture* texture_; + std::vector glyphs; + int char_width; + int line_height; + int vertical_spacing; + float scale_factor { 1 }; + int orig_char_width; + int orig_line_height; + int size_change_delta_ { 2 }; + + Font(SDL_Renderer* renderer, SDL_Texture* texture, std::vector& glyphs, int char_width, int line_height) + : renderer_(renderer) + , texture_(texture) + , glyphs(glyphs) + , char_width(char_width) + , line_height(line_height) + { + this->char_width = char_width; + this->line_height = line_height; + this->vertical_spacing = line_height * 0.5; + orig_char_width = this->char_width; + orig_line_height = this->line_height; + } + + ~Font() + { + } + + std::optional set_color(std::optional& color) + { + if (!color.has_value()) return std::nullopt; + return std::make_optional(this, color.value()); + } + + ScopedColor set_color(const SDL_Color& color) + { + return ScopedColor(this, color); + } + + void render(const std::u32string_view& text, int x, int y) + { + for (auto& ch : text) { + char32_t index; + if (ch <= 127) + index = ch; + else { + index = unicode_glyph_index(ch); + } + Glyph& g = glyphs.at(index); + SDL_Rect dst = { x, y + (vertical_spacing / 2), (int)(g.rect.w * scale_factor), (int)((g.rect.h * scale_factor)) }; + x += g.rect.w * scale_factor; + sdl_console::SDL_RenderCopy(renderer_, texture_, &g.rect, &dst); + } + } + + // Get the surface size of a text. + // Mono-spaced faces have the equal widths and heights. + void size_text(const std::u32string& s, int& w, int& h) + { + w = s.length() * char_width; + h = line_height_with_spacing(); + } + + int line_height_with_spacing() + { + return line_height + vertical_spacing; + } + + void incr_size() + { + change_size(size_change_delta_); + emit(InternalEventType::font_size_changed); + } + + void decr_size() + { + change_size(-size_change_delta_); + emit(InternalEventType::font_size_changed); + } + + // Returns '?' if not found. + char32_t unicode_glyph_index(const char32_t ch) + { + auto it = unicode_to_cp437.find(ch); + if (it != unicode_to_cp437.end()) { + return it->second; + } + return '?'; + } + + Font make_copy() + { + return *this; + } + + Font(Font&& other) noexcept + : renderer_(other.renderer_) + , texture_(other.texture_) + , glyphs(other.glyphs) + , char_width(other.char_width) + , line_height(other.line_height) + , vertical_spacing(other.vertical_spacing) + , scale_factor(other.scale_factor) + , orig_char_width(other.orig_char_width) + , orig_line_height(other.orig_line_height) + { + } + + Font& operator=(Font&& other) noexcept + { + if (this != &other) { + renderer_ = other.renderer_; + texture_ = other.texture_; + glyphs = other.glyphs; + char_width = other.char_width; + line_height = other.line_height; + vertical_spacing = other.vertical_spacing; + scale_factor = other.scale_factor; + orig_char_width = other.char_width; + orig_line_height = other.line_height; + } + return *this; + } + + // Font(const Font&) = delete; + Font& operator=(const Font&) = delete; + +private: + void change_size(int delta) { + if ((char_width <= 8 && delta < 0) || (char_width >= 32 && delta > 0)) + return; + + scale_factor = (float)(char_width + delta) / orig_char_width; + + char_width = orig_char_width * scale_factor; + line_height = orig_line_height * scale_factor; + } + + // Make copying explicit via make_copy() + Font(const Font& other) + : renderer_(other.renderer_) + , texture_(other.texture_) + , glyphs(other.glyphs) + , char_width(other.char_width) + , line_height(other.line_height) + , vertical_spacing(other.vertical_spacing) + , scale_factor(other.scale_factor) + , orig_char_width(other.orig_char_width) + , orig_line_height(other.orig_line_height) + { + } +}; + +// This stuff needs reworked, I think. +using FontMap = std::map, Font>; +class FontLoader { +public: + FontLoader(SDL_Renderer* renderer) + : renderer_(renderer) + { + } + + virtual ~FontLoader() = default; + + virtual Font* open(const std::string& path, int size) = 0; + + Font* default_font() + { + return &fmap_.begin()->second; + } + + Font* get_copy(std::string key, Font* font) + { + auto kp = std::make_pair(key, 0); + auto result = fmap_.emplace(kp, font->make_copy()); + return &result.first->second; + } + + FontLoader(const FontLoader&) = delete; + FontLoader& operator=(const FontLoader&) = delete; + + FontLoader(FontLoader&& other) noexcept + : fmap_(std::move(other.fmap_)) + , renderer_(other.renderer_) + , textures_(std::move(other.textures_)) + { + } + + FontLoader& operator=(FontLoader&& other) noexcept + { + if (this != &other) { + fmap_ = std::move(other.fmap_); + renderer_ = other.renderer_; + textures_ = std::move(other.textures_); + } + return *this; + } + +protected: + FontMap fmap_; + SDL_Renderer* renderer_; + std::vector textures_; +}; + +class BMPFontLoader : public FontLoader { +public: + BMPFontLoader(SDL_Renderer* renderer) + : FontLoader(renderer) + { + } + + ~BMPFontLoader() + { + // Long-term resources are cleaned up by SDLConsole::destroy() + /* + for (auto tex : textures_) { + sdl_tsd.DestroyTexture(tex); + }*/ + } + + Font* open(const std::string& path, int size) + { + auto key = std::make_pair(path, size); + auto it = fmap_.find(key); + + if (it != fmap_.end()) { + return &it->second; + } + + SDL_Surface* surface = DFSDL::DFIMG_Load(path.c_str()); + if (surface == nullptr) { + return nullptr; + } + + /* Don't think this is needed + if (surface->format->format != SDL_PIXELFORMAT_ARGB8888) { + SDL_Surface* conv_surface = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_ARGB8888, 0); + if (!conv_surface) { + std::cerr << "Error converting surface format: " << SDL_GetError() << std::endl; + SDL_FreeSurface(surface); + return nullptr; + } + + SDL_FreeSurface(surface); + surface = conv_surface; + }*/ + + // FIXME: hardcoded magenta + // Make this keyed color transparent. + Uint32 bg_color = sdl_console::SDL_MapRGB(surface->format, 255, 0, 255); + sdl_console::SDL_SetColorKey(surface, SDL_TRUE, bg_color); + + // Create a surface in ARGB8888 format, and replace the keyed color + // with fully transparant pixels. This step completely removes the color. + // NOTE: Do not use surface->pitch + + SDL_Surface* conv_surface = sdl_console::SDL_CreateRGBSurfaceWithFormat(0, surface->w, surface->h, 32, + SDL_PixelFormatEnum::SDL_PIXELFORMAT_ARGB8888); + sdl_console::SDL_BlitSurface(surface, nullptr, conv_surface, nullptr); + sdl_console::SDL_FreeSurface(surface); + surface = conv_surface; + + // NOTE: Do not use surface->pitch + int width = surface->w; + int height = surface->h; + + std::vector glyphs; + // FIXME: magic numbers + glyphs = build_glyph_rects(width, height, 16, 16); + + auto texture = sdl_tsd.CreateTextureFromSurface(renderer_, surface); + if (!texture) { + std::cerr << "SDL_CreateTextureFromSurface Error: " << sdl_console::SDL_GetError() << std::endl; + return nullptr; + } + sdl_console::SDL_FreeSurface(surface); + sdl_console::SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + textures_.push_back(texture); + + assert(width > 0); + assert(height > 0); + + // FIXME: magic numbers + auto result = fmap_.emplace(key, Font(renderer_, texture, glyphs, std::max(8, width/16), std::max(8, height/16))); + return &result.first->second; + } + + BMPFontLoader(const BMPFontLoader&) = delete; + BMPFontLoader& operator=(const BMPFontLoader&) = delete; + + + BMPFontLoader(BMPFontLoader&& other) noexcept = default; + BMPFontLoader& operator=(BMPFontLoader&& other) noexcept = default; + +private: + std::vector build_glyph_rects(int sheet_w, int sheet_h, int columns, int rows) + { + int tile_w = sheet_w / columns; + int tile_h = sheet_h / rows; + int total_glyphs = rows * columns; + + std::vector glyphs; + glyphs.reserve(total_glyphs); + + for (int i = 0; i < total_glyphs; ++i) { + int r = i / columns; + int c = i % columns; + Glyph glyph; + glyph.rect = { tile_w * c, tile_h * r, tile_w, tile_h }; // Rectangle in pixel dimensions + glyphs.push_back(glyph); + } + return glyphs; + } +}; + +class MainWindow; + +/* + * Shared context object for a window and its children, includes + * resources and properties required for rendering and event handling. + */ +class WidgetContext { +public: + SignalEmitter* global_emitter; + Property &props; + SDL_Window* window_handle; + SDL_Renderer* renderer; + Uint32 window_id{0}; + BMPFontLoader font_loader; + SDL_Rect rect{}; + SDL_Point mouse_coord{}; + + WidgetContext(SignalEmitter* emitter, Property& props, SDL_Window* h, SDL_Renderer* r) + : global_emitter(emitter) + , props(props) + , window_handle(h) + , renderer(r) + , font_loader(r) + { + window_id = sdl_console::SDL_GetWindowID(window_handle); + if (window_id == 0) { + throw std::runtime_error("Failed to get window ID"); + } + sdl_console::SDL_GetRendererOutputSize(renderer, &rect.w, &rect.h); + } + + ~WidgetContext() + { + } + + WidgetContext(WidgetContext&& other) noexcept + : global_emitter(other.global_emitter) + , props(other.props) + , window_handle(std::move(other.window_handle)) + , renderer(std::move(other.renderer)) + , window_id(other.window_id) + , font_loader(std::move(other.font_loader)) + , rect(other.rect) + , mouse_coord(other.mouse_coord) + { + other.global_emitter = nullptr; + other.window_handle = nullptr; + other.renderer = nullptr; + } + +#if 0 + WidgetContext& operator=(WidgetContext&& other) noexcept { + if (this != &other) { + global_emitter = other.global_emitter; + props = other.props; + window_handle = std::move(other.window_handle); + renderer = std::move(other.renderer); + window_id = other.window_id; + font_loader = std::move(other.font_loader); + rect = other.rect; + mouse_coord = other.mouse_coord; + + //other.global_emitter = nullptr; + other.window_handle = nullptr; + other.renderer = nullptr; + } + return *this; + } +#endif + + WidgetContext(const WidgetContext&) = delete; + WidgetContext& operator=(const WidgetContext&) = delete; + + // This may belong elsewhere. A window is also a widget, and WidgetContext must be + // constructed before the MainWindow widget. + static WidgetContext create_main_window(Property& props, SignalEmitter* emitter) + { + // Inform SDL to pass the mouse click event when switching between windows. + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); + + auto title = props.get(property::WINDOW_MAIN_TITLE, "SDL Console"); + SDL_Rect create_rect = props.get(property::WINDOW_MAIN_CREATE_RECT, + SDL_Rect{SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 640, 480}); + //auto flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN; + auto flags = SDL_WINDOW_RESIZABLE; + + SDL_Window* handle = sdl_tsd.CreateWindow(title.c_str(), create_rect.x, create_rect.y, create_rect.w, create_rect.h, flags); + if (!handle) { + throw std::runtime_error("Failed to create SDL window"); + } + + sdl_console::SDL_SetWindowMinimumSize(handle, 64, 48); + + SDL_Renderer* renderer = create_renderer(props, handle); + if (!renderer) { + throw std::runtime_error("Failed to create SDL renderer"); + } + + // Does nothing on Wayland+opengl + //SDL_SetWindowOpacity(handle, 0.5f); + + return WidgetContext(emitter, props, handle, renderer); + } + + static SDL_Renderer* create_renderer(Property& props, SDL_Window* handle) + { + sdl_console::SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "best"); + sdl_console::SDL_SetHint(SDL_HINT_RENDER_VSYNC, "1"); + // Flags 0 instructs SDL to choose the default backend for the + // host system. TODO: add config to force software rendering + SDL_RendererFlags rflags = (SDL_RendererFlags)0; + //SDL_RendererFlags rflags = (SDL_RendererFlags)SDL_RENDERER_SOFTWARE; + SDL_Renderer* rend = sdl_tsd.CreateRenderer(handle, -1, rflags); + sdl_console::SDL_RenderSetIntegerScale(rend, SDL_TRUE); + return rend; + } +}; + +// TODO: needs work +class Widget : public SignalEmitter { +public: + Widget* parent; + Font* font; + SDL_Rect viewport {}; + WidgetContext& context; + + Widget(Widget* parent) + : parent(parent) + , font(parent->font) + , viewport(parent->viewport) + , context(parent->context) + { + } + + Widget(Widget* parent, SDL_Rect viewport) + : parent(parent) + , font(parent->font) + , viewport(viewport) + , context(parent->context) + { + } + + // Constructor for Window + Widget(WidgetContext& window_context) + : parent(nullptr) + , context(window_context) + + { + auto bmpfont = context.font_loader.open("data/art/curses_640x300.png", 14); + if (!bmpfont) + throw(std::runtime_error("Error loading font")); + font = bmpfont; + viewport = context.rect; + } + + SDL_Renderer* renderer() + { + return context.renderer; + } + + SDL_Point mouse_coord() + { + return context.mouse_coord; + } + + SDL_Point map_point_to_viewport(const SDL_Point& point) + { + return { point.x - viewport.x, point.y - viewport.y }; + } + + Property& props() + { + return context.props; + } + + template + ISlot* connect_global(Args&&... args) + { + return context.global_emitter->connect(std::forward(args)...); + } + + template + void disconnect_global(Args&&... args) + { + context.global_emitter->disconnect(std::forward(args)...); + } + + template + void emit_global(Args&&... args) + { + context.global_emitter->emit(std::forward(args)...); + } + + virtual void render() = 0; + virtual void resize(const SDL_Rect& new_viewport) = 0; + + virtual ~Widget() { } + + Widget(const Widget&) = delete; + Widget& operator=(const Widget&) = delete; +}; + +class Prompt : public Widget { +public: + // Holds wrapped lines from input + TextEntry entry; + // The text of the prompt itself. + std::u32string prompt_text; + // The input portion of the prompt. + std::u32string* input; + std::u32string interrupted_input; + size_t cursor { 0 }; // position of cursor within an entry + // 1x1 texture stretched to font's single character dimensions + SDL_Texture* cursor_texture; + /* + * For input history. + * use deque to hold a stable reference. + */ + std::deque history; + int history_index; + + Prompt(Widget* parent) + : Widget(parent) + { + input = &history.emplace_back(U""); + + set_prompt_text(props().get(property::PROMPT_TEXT, U"> ")); + + create_cursor_texture(); + + connect_global(SDL_KEYDOWN, [this](SDL_KeyboardEvent& e) { + on_SDL_KEYDOWN(e); + }); + + connect_global(SDL_TEXTINPUT, [this](SDL_TextInputEvent& e) { + put_input_at_cursor(text::from_utf8(e.text)); + }); + } + + ~Prompt() + { + //sdl_tsd.DestroyTexture(cursor_texture); + } + + // NOTE: Only called by constructor. + void create_cursor_texture() + { + cursor_texture = sdl_tsd.CreateTexture(renderer(), SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_STATIC, 1, 1); + if (cursor_texture == nullptr) + throw(std::runtime_error(sdl_console::SDL_GetError())); + + // FFFFFF = rgb white, 7F = 50% transparant + Uint32 pixel = 0xFFFFFF7F; + if (sdl_console::SDL_UpdateTexture(cursor_texture, NULL, &pixel, sizeof(Uint32)) != 0) { + throw(std::runtime_error(sdl_console::SDL_GetError())); + } + // For transparancy + sdl_console::SDL_SetTextureBlendMode(cursor_texture, SDL_BLENDMODE_BLEND); + } + + /* OutputPane does this */ + void render() override + { + } + + void put_input_from_clipboard() + { + auto* str = sdl_console::SDL_GetClipboardText(); + if (*str != '\0') + put_input_at_cursor(text::from_utf8(str)); + // Always free, even when empty. + sdl_console::SDL_free(str); + } + + void on_SDL_KEYDOWN(const SDL_KeyboardEvent& e) + { + // TODO: check if keysym.sym mapping is universally friendly + auto sym = e.keysym.sym; + switch (sym) { + case SDLK_BACKSPACE: + erase_input(); + break; + + case SDLK_UP: + set_input_from_history(ScrollDirection::up); + break; + + case SDLK_DOWN: + set_input_from_history(ScrollDirection::down); + break; + + case SDLK_LEFT: + move_cursor_left(); + break; + + case SDLK_RIGHT: + move_cursor_right(); + break; + + case SDLK_RETURN: + new_command_input(); + + case SDLK_HOME: + cursor = 0; + break; + + case SDLK_END: + cursor = input->length(); + break; + + case SDLK_b: + if (sdl_console::SDL_GetModState() & KMOD_CTRL) { + cursor = text::find_prev_word(*input, cursor); + } + break; + + case SDLK_f: + if (sdl_console::SDL_GetModState() & KMOD_CTRL) { + cursor = text::find_next_word(*input, cursor); + } + break; + case SDLK_c: + // FIXME: OutputPane also listens for ctrl-c for copying text. + // TODO: Add state for when selecting text + if (sdl_console::SDL_GetModState() & KMOD_CTRL) { + *input += U"^C"; + interrupt(); + } + break; + } + } + + void set_command_history(std::deque saved_history) + { + std::swap(history, saved_history); + input = &history.emplace_back(U""); + history_index = history.size() - 1; + reset_cursor(); + } + + void new_command_input() + { + emit(InternalEventType::new_command_input, input); + + // If empty, log an empty line? But don't add it to history. + if (input->empty()) return; + + input = &history.emplace_back(U""); + history_index = history.size() - 1; + reset_cursor(); + wrap_text(); + } + + void interrupt() + { + interrupted_input = *input; + emit(InternalEventType::new_input, input); + input->clear(); + reset_cursor(); + wrap_text(); + } + + void restore_from_interrupt() + { + *input = interrupted_input; + cursor = input->length(); + wrap_text(); + } + + void reset_cursor() + { + cursor = 0; + } + + void set_prompt_text(const std::u32string& value) + { + prompt_text = value; + wrap_text(); + } + + /* + * Set the current line. We can go UP (next) or DOWN (previous) through the + * lines. This function essentially acts as a history viewer. This function + * will skip lines with zero length. The cursor is always set to the length of + * the line's input. + */ + void set_input_from_history(const ScrollDirection dir) + { + if (history.empty()) return; + + if (dir == ScrollDirection::up && history_index > 0) { + history_index--; + } else if (dir == ScrollDirection::down && history_index < (int)history.size() - 1) { + history_index++; + } else { + return; + } + + input = &history.at(history_index); + cursor = input->length(); + wrap_text(); + } + + void put_input_at_cursor(const std::u32string& str) + { + /* if cursor is at end of line, it's a simple concatenation */ + if (cursor == input->length()) { + *input += str; + } else { + /* else insert text into line at cursor's index */ + input->insert(cursor, str); + } + cursor += str.length(); + wrap_text(); + } + + void set_input(const std::u32string& str) + { + *input = str; + cursor = str.length(); + wrap_text(); + } + + void erase_input() + { + if (cursor == 0 || input->empty()) + return; + + if (input->length() == cursor) { + input->pop_back(); + } else { + /* else shift the text from cursor left by one character */ + input->erase(cursor-1, 1); + } + cursor -= 1; + wrap_text(); + } + + void move_cursor_left() + { + if (cursor > 0) { + cursor--; + } + } + + void move_cursor_right() + { + if (cursor < input->length()) { + cursor++; + } + } + + void resize(const SDL_Rect& new_viewport) override + { + viewport = new_viewport; + wrap_text(); + } + + void wrap_text() + { + entry.text = prompt_text + *input; + entry.wrap_text(font->char_width, viewport.w); + } + + // TODO: The cursor x,y position should be updated + // elsewhere, such as when the cursor position changes. + // instead of within its rendering function. + void render_cursor(int scroll_offset) + { + if (entry.fragments().empty()) + return; + + // cursor's starting position + auto cursor_pos = cursor + prompt_text.length(); + TextEntry::Fragment *line; + + // cursor is at the end + if (cursor_pos == entry.text.length()) { + // cursor is not visible. + if (scroll_offset > 0) + return; + line = &entry.fragments().back(); + // else find the line containing the cursor + } else { + auto line_opt = entry.fragment_from_offset(cursor_pos); + if (!line_opt.has_value()) + return; // should not happen + + line = &line_opt.value().get(); + // the very bottom of the prompt is the last entry + // entry_offset = entry.size-1 at bottom + int r = (entry.size - 1) - line->entry_offset; + // scroll_offset starts at 0. + if (scroll_offset > r) { + return; + } + } + + auto lh = font->line_height_with_spacing(); + auto cw = font->char_width; + auto cx = (cursor_pos - line->start_offset) * cw; + auto cy = line->coord.y; + + SDL_Rect rect{ (int)cx, cy, cw, lh }; + render_texture(renderer(), cursor_texture, rect); + } + + Prompt(const Prompt&) = delete; + Prompt& operator=(const Prompt&) = delete; +}; + +class Scrollbar : public Widget { +private: + struct Thumb { + SDL_Rect rect{}; + }; + + int page_size_; // Height of visible area. + int content_size_ { 0 }; // Total height of content. + int scroll_offset_ { 0 }; + bool depressed_ { false }; + ISlot* mouse_motion_slot_ { nullptr }; + Thumb thumb_; + +public: + Scrollbar(Widget* parent, int page_size) + : Widget(parent) + , page_size_(page_size) + { + connect_global(SDL_MOUSEBUTTONDOWN, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONDOWN(e); + }); + + connect_global(SDL_MOUSEBUTTONUP, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONUP(e); + }); + + mouse_motion_slot_ = context.global_emitter->connect_later(SDL_MOUSEMOTION, [this](SDL_MouseMotionEvent& e) { + if (!depressed_) + return; + + scroll_offset_ = scroll_offset_from_track_position(e.y); + move_thumb_to(track_position_from_scroll_offset()); + emit(InternalEventType::value_changed, &scroll_offset_); + }); + + thumb_.rect = viewport; + set_thumb_height(); + } + + void resize(const SDL_Rect& new_viewport) override + { + viewport = new_viewport; + thumb_.rect = viewport; + set_thumb_height(); + move_thumb_to(track_position_from_scroll_offset()); + } + + void set_page_size(size_t size) + { + page_size_ = size; + set_thumb_height(); + move_thumb_to(track_position_from_scroll_offset()); + } + + void set_content_size(size_t size) + { + content_size_ = size; + set_thumb_height(); + move_thumb_to(track_position_from_scroll_offset()); + } + + void scroll_to(size_t position) + { + scroll_offset_ = position; + move_thumb_to(track_position_from_scroll_offset()); + } + + void render() override + { + set_draw_color(renderer(), colors::gold); + + SDL_RenderDrawRect(renderer(), &viewport); + + set_draw_color(renderer(), colors::mauve); + + // FIXME: hardcoded magic + SDL_Rect tr{thumb_.rect.x + 4, thumb_.rect.y + 4, thumb_.rect.w - 8, thumb_.rect.h - 8}; + SDL_RenderFillRect(renderer(), &tr); + + set_draw_color(renderer(), colors::darkgray); + } + + ~Scrollbar() + { + } + + Scrollbar(const Scrollbar&) = delete; + Scrollbar& operator=(const Scrollbar&) = delete; + +private: + void on_SDL_MOUSEBUTTONDOWN(SDL_MouseButtonEvent& e) + { + if (!geometry::in_rect(e.x, e.y, viewport)) { + return; + } + + if (!mouse_motion_slot_->is_connected()) { + mouse_motion_slot_->connect(); + } + + depressed_ = true; + scroll_offset_ = scroll_offset_from_track_position(e.y); + move_thumb_to(track_position_from_scroll_offset()); + emit(InternalEventType::value_changed, &scroll_offset_); + } + + void on_SDL_MOUSEBUTTONUP(SDL_MouseButtonEvent& e) + { + if (depressed_) { + depressed_ = false; + mouse_motion_slot_->disconnect(); + } + } + + int calculate_thumb_position(int target_y) + { + int track_top = viewport.y; + int track_bot = viewport.y + viewport.h; + + // Position with offset and constrain within track limits + return std::clamp(target_y - thumb_.rect.h, track_top, track_bot - thumb_.rect.h); + } + + void move_thumb_to(int y) + { + thumb_.rect.y = calculate_thumb_position(y); + } + + void set_thumb_height() + { + if (content_size_ > 0) { + float scroll_ratio = (float)page_size_ / content_size_; + int h = (int)std::round(scroll_ratio * viewport.h); + // 30 is minimum height. + thumb_.rect.h = std::clamp(h, 30, viewport.h); + } else { + thumb_.rect.h = viewport.h; + } + } + + int scroll_offset_from_track_position(int y) + { + int track_h = viewport.h; + + // Track position is aligns with the middle of the thumb. + int thumb_mid_y = y - (thumb_.rect.h / 2); + thumb_mid_y = std::clamp(thumb_mid_y, viewport.y, viewport.y + track_h - thumb_.rect.h); + + float pos_ratio = (float)(thumb_mid_y - viewport.y) / (track_h - thumb_.rect.h); + int offset = (int)((1.0f - pos_ratio) * (content_size_ - page_size_)); + + // Ensure the scroll offset does not go beyond the valid range + return std::clamp(offset, 0, content_size_ - page_size_); + } + + int track_position_from_scroll_offset() + { + int track_h = viewport.h; + + if (content_size_ <= page_size_ || content_size_ == 0) { + return viewport.y; + } + + float offset_ratio = (float)scroll_offset_ / (content_size_); + int pos = (int)((1.0f - offset_ratio) * track_h); + + return pos + viewport.y; + } + +}; + +class Button : public Widget { +public: + Button(Widget* parent, std::u32string& label, SDL_Color color) + : Widget(parent) + , label(label) + { + compute_button_size(); + connect_global(SDL_MOUSEBUTTONDOWN, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONDOWN(e); + }); + + connect_global(SDL_MOUSEBUTTONUP, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONUP(e); + }); + + font->connect(InternalEventType::font_size_changed, [this](SDL_UserEvent& e) { + compute_button_size(); + }); + } + + void resize(const SDL_Rect& new_viewport) override + { + label_rect.x = viewport.x + (viewport.w / 2) - (label_rect.w / 2); + label_rect.y = (viewport.h / 2) - (label_rect.h / 2); + } + + void compute_button_size() + { + font->size_text(this->label, label_rect.w, label_rect.h); + viewport.w = label_rect.w + (font->char_width * 2); + } + + ~Button() + { + } + + void on_SDL_MOUSEBUTTONDOWN(SDL_MouseButtonEvent& e) + { + if (!geometry::in_rect(e.x, e.y, viewport)) { + return; + } + depressed = true; + } + + void on_SDL_MOUSEBUTTONUP(SDL_MouseButtonEvent& e) + { + if (!geometry::in_rect(e.x, e.y, viewport)) { + if (depressed) + depressed = false; + return; + } + + if (depressed) { + emit(InternalEventType::clicked); + depressed = false; + } + } + + void render() override + { + if (enabled) { + SDL_Point coord = mouse_coord(); + if (depressed) { + set_draw_color(renderer(), colors::teal); + sdl_console::SDL_RenderFillRect(renderer(), &viewport); + // SDL_RenderDrawRect(ui.renderer, &w.rect); + set_draw_color(renderer(), colors::darkgray); + } else if (geometry::in_rect(coord, viewport)) { + set_draw_color(renderer(), colors::teal); + sdl_console::SDL_RenderDrawRect(renderer(), &viewport); + set_draw_color(renderer(), colors::darkgray); + } + font->render(label, label_rect.x, label_rect.y); + } else { + auto scoped_color = font->set_color(colors::mediumgray); + font->render(label, label_rect.x, label_rect.y); + } + } + + Button(const Button&) = delete; + Button& operator=(const Button&) = delete; + + std::u32string label; + SDL_Rect label_rect {}; + bool depressed { false }; + bool enabled { true }; +}; + +class Toolbar : public Widget { +public: + Toolbar(Widget* parent, SDL_Rect viewport); + ~Toolbar() {}; + virtual void render() override; + virtual void resize(const SDL_Rect& new_viewport) override; + void layout_buttons(); + Button* add_button(std::u32string text); + int compute_widgets_startx(); + Toolbar(const Toolbar&) = delete; + Toolbar& operator=(const Toolbar&) = delete; + // Should be changed to children and probably moved to base class + std::deque> widgets; +}; + +class CommandPipe { +public: + CommandPipe() = default; + + void make_connection(SignalEmitter& emitter) + { + emitter.connect(InternalEventType::new_command_input, [this](SDL_UserEvent& e) { + auto str = SignalEmitter::copy_data1_from_userevent(e, U""); + push(str); + }); + } + + void push(std::u32string s) + { + { + std::scoped_lock lock(mutex_); + queue_.push(s); + } + cv_.notify_one(); + } + + void shutdown() + { + { + std::scoped_lock lock(mutex_); + shutdown_ = true; + } + cv_.notify_all(); + } + + /* This function may be called recursively */ + int wait_get(std::string& buf) + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return !queue_.empty() || shutdown_; }); + + if (shutdown_) { + return -1; + } + + buf = text::to_utf8(queue_.front()); + queue_.pop(); + return buf.length(); + } + + ~CommandPipe() + { + shutdown(); + } + +private: + std::condition_variable_any cv_; + std::recursive_mutex mutex_; + std::queue queue_; + bool shutdown_{false}; +}; + +class TextSelection { +public: + SDL_Point begin{-1, -1}; + SDL_Point end{-1, -1}; + std::vector rects; + + void reset() + { + begin = {-1, -1}; + end = {-1, -1}; + rects.clear(); + } +}; + +class OutputPane : public Widget { +public: + // Use deque to hold a stable reference. + std::deque entries; + Prompt prompt; + Scrollbar scrollbar; + // Scrollbar could be made optional. + int scroll_offset { 0 }; + SDL_Rect frame; + int scrollback_; + int num_rows { 0 }; + bool depressed { false }; + TextSelection text_selection; + ISlot* mouse_motion_slot { nullptr }; + + OutputPane(Widget* parent, SDL_Rect& viewport) + : Widget(parent, viewport) + , prompt(this) + , scrollbar(this, rows()) + { + resize(viewport); + + set_scrollback(props().get(property::OUTPUT_SCROLLBACK, 1000)); + + scrollbar.set_page_size(rows()); + scrollbar.set_content_size(1); // account for prompt + + prompt.connect(InternalEventType::new_command_input, [this](SDL_UserEvent& e) + { + auto str = SignalEmitter::copy_data1_from_userevent(e, U""); + new_input(str); + }); + + prompt.connect(InternalEventType::new_input, [this](SDL_UserEvent& e) + { + auto str = SignalEmitter::copy_data1_from_userevent(e, U""); + new_input(str); + }); + + font->connect(InternalEventType::font_size_changed, [this](SDL_UserEvent& e) + { + resize(frame); + }); + + connect_global(SDL_MOUSEBUTTONDOWN, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONDOWN(e); + }); + + connect_global(SDL_MOUSEBUTTONUP, [this](SDL_MouseButtonEvent& e) { + on_SDL_MOUSEBUTTONUP(e); + }); + + connect_global(SDL_MOUSEWHEEL, [this](SDL_MouseWheelEvent& e) { + scroll(e.y); + }); + + mouse_motion_slot = context.global_emitter->connect_later(SDL_MOUSEMOTION, [this](SDL_MouseMotionEvent& e) { + //if (!geometry::in_rect(e.x, e.y, this->viewport)) + // return; + if (depressed) { + end_text_selection({ e.x, e.y }); + + if (e.y > this->viewport.h) { + scroll(-1); + } else if (e.y < 0) { + scroll(1); + } + } + }); + + connect_global(SDL_KEYDOWN, [this](SDL_KeyboardEvent& e) { + on_SDL_KEYDOWN(e); + }); + + connect_global(SDL_TEXTINPUT, [this](SDL_TextInputEvent& e) { + // When inputting into the prompt, we should keep anchored to the + // bottom so the prompt is visible. + // We also need to adjust the scrollbar range as the prompt may span + // multiple lines. + // TODO: maybe it should connect to prompt for this. + set_scroll_offset(0); + scrollbar.set_content_size(num_rows + prompt.entry.size); + }); + + scrollbar.connect(InternalEventType::value_changed, [this](SDL_UserEvent& e) { + scroll_offset = SignalEmitter::copy_data1_from_userevent(e, 0); + }); + } + + void set_scrollback(size_t scrollback) + { + scrollback_ = scrollback; + } + + int on_SDL_KEYDOWN(const SDL_KeyboardEvent& e) + { + auto sym = e.keysym.sym; + switch (sym) { + case SDLK_TAB: + { + std::thread cmdhelper([input = text::to_utf8(*prompt.input)]() { + auto& core = DFHack::Core::getInstance(); + auto& con = SDLConsole::get_console(); + std::vector possibles; + + core.getAutoCompletePossibles(input, possibles); + if (possibles.empty()) return; + else if (possibles.size() == 1) + SDLConsole::get_console().set_prompt_input(possibles[0]); + else { + std::string result = std::accumulate( + std::next(possibles.begin()), possibles.end(), possibles[0], + [](const std::string& a, const std::string& b) { + return a + " " + b; + }); + con.interrupt_prompt(); + con.write_line(result); + con.restore_prompt(); + } + }); + cmdhelper.detach(); + } + break; + /* copy */ + case SDLK_c: + if (sdl_console::SDL_GetModState() & KMOD_CTRL) { + copy_selected_text_to_clipboard(); + } + break; + + /* paste */ + case SDLK_v: + if (sdl_console::SDL_GetModState() & KMOD_CTRL) { + prompt.put_input_from_clipboard(); + } + break; + + case SDLK_PAGEUP: + scroll(ScrollDirection::page_up); + break; + + case SDLK_PAGEDOWN: + scroll(ScrollDirection::page_down); + break; + + case SDLK_RETURN: + case SDLK_BACKSPACE: + case SDLK_UP: + case SDLK_DOWN: + case SDLK_LEFT: + case SDLK_RIGHT: + set_scroll_offset(0); + break; + } + return 0; + } + + void on_SDL_MOUSEBUTTONDOWN(SDL_MouseButtonEvent& e) + { + if (!geometry::in_rect(e.x, e.y, viewport)) + return; + + if (e.button != SDL_BUTTON_LEFT) { + return; + } + + // TODO: cleanup text selection bidness, this is ugly. + if (e.clicks == 1) { + begin_text_selection({ e.x, e.y }); + } else if (e.clicks == 2) { + SDL_Point vp = map_point_to_viewport({e.x, e.y}); + auto frag = find_fragment_at_y(vp.y); + if (frag.has_value()) { + const std::u32string& text = frag.value().get().text.data(); + auto wordpos = text::find_text_range(text, (size_t)get_column(vp.x)); + + auto get_x = [this](std::u32string::size_type pos, int fallback) -> int { + return (pos != std::u32string::npos) ? pos * font->char_width : fallback; + }; + + begin_text_selection({get_x(wordpos.first, viewport.x), vp.y}, false); + // wordpos is 0-based + end_text_selection({get_x(wordpos.second+1, viewport.w), vp.y}, false); + } + } else if (e.clicks == 3) { + // NOTE: using 0 for x doesn't work. + begin_text_selection({viewport.x, e.y}); + end_text_selection({viewport.w, e.y}); + } + + depressed = true; + mouse_motion_slot->connect(); + } + + void on_SDL_MOUSEBUTTONUP(SDL_MouseButtonEvent& e) + { + if (depressed) { + sdl_console::SDL_CaptureMouse(SDL_FALSE); + depressed = false; + mouse_motion_slot->disconnect(); + } + } + + void clear() + { + entries.clear(); + num_rows = 0; + set_scroll_offset(0); + scrollbar.set_content_size(1); + text_selection.reset(); + emit_text_selection_changed(); + } + + void set_scroll_offset(int v) + { + scroll_offset = v; + scrollbar.scroll_to(v); + } + + void begin_text_selection(const SDL_Point& point, bool need_map = true) + { + text_selection.reset(); + text_selection.begin = need_map ? map_point_to_viewport(point) : point; + emit_text_selection_changed(); + } + + void end_text_selection(const SDL_Point& point, bool need_map = true) + { + text_selection.end = need_map ? map_point_to_viewport(point) : point; + text_selection.rects = get_selected_rects(); + emit_text_selection_changed(); + } + + void emit_text_selection_changed() + { + static bool previous_state = false; + bool has_selection = !text_selection.rects.empty(); + + if (has_selection != previous_state) { + previous_state = has_selection; + emit(InternalEventType::text_selection_changed, &has_selection); + } + } + + void scroll(int y) + { + if (y > 0) { + scroll(ScrollDirection::up); + } else if (y < 0) { + scroll(ScrollDirection::down); + } + } + + void scroll(ScrollDirection dir) + { + switch (dir) { + case ScrollDirection::up: + scroll_offset += 1; + break; + case ScrollDirection::down: + scroll_offset -= 1; + break; + case ScrollDirection::page_up: + scroll_offset += rows() / 2; + break; + case ScrollDirection::page_down: + scroll_offset -= rows() / 2; + break; + } + + set_scroll_offset(std::min(std::max(0, scroll_offset), num_rows - 1)); + } + + void resize(const SDL_Rect& new_viewport) override + { + frame = new_viewport; + viewport = new_viewport; + // FIXME: magic numbers + scrollbar.resize({ viewport.w - (8 * 2), viewport.y, (8 * 2), viewport.h }); + apply_margin_and_align_viewport(); + + num_rows = 0; + for (auto& e : entries) { + wrap_text(e); + } + + context.props.set(property::RT_OUTPUT_COLUMNS, columns()); + context.props.set(property::RT_OUTPUT_ROWS, rows()); + } + + /* + * Adjust viewport dimensions to align with margin and font properties. + * For character alignment consistency, the viewport must be divisible + * into rows and columns that match the font's fixed character dimensions. + */ + void apply_margin_and_align_viewport() + { + // (8px each side + 4px buffer tweak) + const int scrollbar_space = (8 * 2) + 4; + // Make room for scrollbar. TODO: needs layout framework + // Deduct space on the right. + viewport.w -= scrollbar_space; + + const int margin = 4; // // Margin around the viewport in px. + + // max width respect to font and margin + const int max_width = viewport.w - (margin * 2); + const int wfit = (max_width / font->char_width) * font->char_width; + + // max height with respect to font and margin + const int max_height = viewport.h - (margin * 2); + const int hfit = (max_height / font->line_height_with_spacing()) * font->line_height_with_spacing(); + + viewport.x = frame.x + margin; + viewport.y = frame.y + margin; + viewport.w = wfit; + viewport.h = hfit; + // Prompt viewport is shared with this + prompt.resize(viewport); + } + + void new_output(const std::u32string& text, std::optional color) + { + TextEntry& entry = create_entry(TextEntryType::output, text, color); + wrap_text(entry); + } + + void new_input(const std::u32string& text) + { + auto both = prompt.prompt_text + text; + auto& entry = create_entry(TextEntryType::input, both, std::nullopt); + wrap_text(entry); + } + + void wrap_text( + TextEntry& entry) + { + entry.wrap_text(font->char_width, viewport.w); + num_rows += entry.size; + scrollbar.set_content_size(num_rows + prompt.entry.size); + } + + /* + * Create a new entry which may span multiple rows and set it to be the head. + * This function will automatically cycle-out entries if the number of rows + * has reached the max. + */ + TextEntry& create_entry(const TextEntryType entry_type, + const std::u32string& text, std::optional color) + { + TextEntry& entry = entries.emplace_front(entry_type, text, color); + + /* When the list is too long, start chopping */ + if (num_rows > scrollback_) { + num_rows -= entries.back().size; + entries.pop_back(); + } + + return entry; + } + + std::optional> find_fragment_at_y(int y) + { + for (auto& entry : entries) { + for (auto& frag : entry.fragments()) { + if (geometry::is_y_within_bounds(y, frag.coord.y, font->line_height)) { + return frag; + } + } + } + for (auto& frag : prompt.entry.fragments()) { + if (geometry::is_y_within_bounds(y, frag.coord.y, font->line_height)) { + return frag; + } + } + + return std::nullopt; + } + + void copy_selected_text_to_clipboard() + { + auto sep = U'\n'; + std::u32string clipboard_text; + + for (const auto& rect : text_selection.rects) { + auto frag_opt = find_fragment_at_y(rect.y); + if (!frag_opt) { + continue; + } + + const auto& frag = frag_opt.value().get(); + auto col = get_column(rect.x); + + if (col < frag.text.size()) { + if (!clipboard_text.empty()) + clipboard_text += sep; + auto extent = column_extent(rect.w) + col; + auto end_idx = std::min(extent, frag.text.size() - 1); + clipboard_text += frag.text.substr(col, (end_idx - col) + 1); + } + } + sdl_console::SDL_SetClipboardText(text::to_utf8(clipboard_text).c_str()); + } + + size_t get_column(int x) + { + size_t column = x / font->char_width; + return std::clamp(column, size_t(0), columns()); + } + + size_t column_extent(int width) + { + size_t extent = width / font->char_width; + return std::clamp(extent, size_t(0), columns()); + } + + size_t columns() + { + return (float)viewport.w / font->char_width; + } + + size_t rows() + { + return (float)viewport.h / font->line_height_with_spacing(); + } + +#if 0 + void do_cmd_completion() + { + assert(cmd_completion_future); + if (cmd_completion_future->valid() + && cmd_completion_future->wait_for(std::chrono::milliseconds(0)) // 0 for don't block + == std::future_status::ready) { + try { + std::string result = cmd_completion_future->get(); + if (!result.empty()) + prompt.set_input(text::from_utf8(result)); + } catch (const std::exception &e) { + std::cerr << "SDLConsole: cmd_completion_future exception: " << e.what() << std::endl; + } + cmd_completion_future.reset(); + } + } +#endif + + void render() override + { + // SDL_RenderSetScale(renderer(), 1.2, 1.2); + sdl_console::SDL_RenderSetViewport(renderer(), &viewport); + // TODO: make sure renderer supports blending else highlighting + // will make the text invisible. + if (!text_selection.rects.empty()) + render_highlight_selected_text(); + // SDL_SetTextureColorMod(font->texture, 0, 128, 0); + + render_prompt_and_output(); + // SDL_SetTextureColorMod(font->texture, 255, 255, 255); + prompt.render_cursor(scroll_offset); + sdl_console::SDL_RenderSetViewport(renderer(), &parent->viewport); + scrollbar.render(); + // SDL_RenderSetScale(renderer(), 1.0, 1.0); + } + + void render_prompt_and_output() + { + const int max_screen_row = rows() + scroll_offset; + int ypos = viewport.h; // Start from the bottom + int row_counter = 0; + + render_entry(prompt.entry, ypos, row_counter, max_screen_row); + + if (entries.empty()) + return; + + for (auto& entry : entries) { + if (row_counter > max_screen_row) { + break; + } + render_entry(entry, ypos, row_counter, max_screen_row); + } + } + + // FIXME: Position and rows to render calculations should be done elsewhere. + void render_entry(TextEntry& entry, int& ypos, int& row_counter, int max_screen_row) + { + auto scoped_color = font->set_color(entry.color_opt); + for (auto& row : entry.fragments() | std::views::reverse) { + row_counter++; + if (row_counter <= scroll_offset) { + continue; + } else if (row_counter > max_screen_row) { + break; + } + + ypos -= font->line_height_with_spacing(); + row.coord.y = ypos; + font->render(row.text, row.coord.x, row.coord.y); + } + } + + void render_highlight_selected_text() + { + set_draw_color(renderer(), colors::mediumgray); + sdl_console::SDL_RenderFillRects(renderer(), text_selection.rects.data(), text_selection.rects.size()); + set_draw_color(renderer(), colors::darkgray); + + /* + for (auto& rect : text_selection.rects) { + + sdl_console::SDL_RenderFillRect(renderer(), &rect); + } + set_draw_color(renderer(), colors::darkgray); + */ + } + + /* + * FIXME: This function handles regions of text shown on screen. + * TODO: Support highlighting while scrolling (keep highlighted state when off screen). + */ + std::vector get_selected_rects() + { + const int char_width = font->char_width; + const int line_height = font->line_height_with_spacing(); + + // Calculate the start and end positions, snapping to line and character boundaries + auto [top_point, bottom_point] = std::minmax({text_selection.begin, text_selection.end}, + [](const SDL_Point& a, const SDL_Point& b) { + return a.y < b.y; + }); + + auto top = grid::floor_boundary(top_point.y, line_height); + auto bottom = grid::ceil_boundary(bottom_point.y, line_height); + bool is_single_row = (bottom_point.y - top_point.y) <= line_height; + + int left; + int right; + if (is_single_row) { + left = grid::floor_boundary(std::min(text_selection.begin.x, text_selection.end.x), char_width); + right = grid::ceil_boundary(std::max(text_selection.begin.x, text_selection.end.x), char_width); + } else { + left = grid::floor_boundary(top_point.x, char_width); + right = grid::ceil_boundary(bottom_point.x, char_width); + } + + SDL_Rect current_rect = { left, top, (right - left), line_height }; + if (is_single_row) + return { current_rect }; + + int rows = std::ceil((float)(bottom - top) / line_height); + std::vector selected_rects; + current_rect.w = viewport.w; + selected_rects.push_back(current_rect); + // Handle intermediate rows + for (int i = 1; i < rows; ++i) { + current_rect.x = 0; + current_rect.y = top + i * line_height; + current_rect.w = viewport.w; + selected_rects.push_back(current_rect); + } + // Fill last row to end of selected text + selected_rects.back().w = right; + + return selected_rects; + } + + + OutputPane(const OutputPane&) = delete; + OutputPane& operator=(const OutputPane&) = delete; +}; + + +class MainWindow : public Widget { +public: + std::unique_ptr toolbar; // optional toolbar. XXX: implementation requires it + std::unique_ptr outpane; + bool has_focus{false}; + bool is_shown{true}; + bool is_minimized{false}; + + MainWindow(WidgetContext& wctx) + : Widget(wctx) + { + connect_global(SDL_WINDOWEVENT, [this](SDL_WindowEvent& e) { + switch(e.event) { + case SDL_WINDOWEVENT_RESIZED: + resize({}); + break; + case SDL_WINDOWEVENT_FOCUS_LOST: + has_focus = false; + break; + case SDL_WINDOWEVENT_FOCUS_GAINED: + // TODO: Check if cursor is disabled first. + SDL_ShowCursor(SDL_ENABLE); + has_focus = true; + break; + case SDL_WINDOWEVENT_MINIMIZED: + is_minimized = true; + case SDL_WINDOWEVENT_HIDDEN: + is_shown = false; + break; + case SDL_WINDOWEVENT_SHOWN: + case SDL_WINDOWEVENT_EXPOSED: + case SDL_WINDOWEVENT_MAXIMIZED: + is_shown = true; + is_minimized = false; + break; + } + }); + + connect_global(SDL_MOUSEMOTION, [this](SDL_MouseMotionEvent& e) { + context.mouse_coord.x = e.x; + context.mouse_coord.y = e.y; + }); + + // TODO: make toolbar optional + SDL_Rect tv = { 0, 0, viewport.w, font->line_height * 2 }; + toolbar = std::make_unique(this, tv); + + SDL_Rect lv = { 0, toolbar->viewport.h, viewport.w, viewport.h - toolbar->viewport.h }; + outpane = std::make_unique(this, lv); + + Button& copy = *toolbar->add_button(U"Copy"); + copy.enabled = false; + copy.connect(InternalEventType::clicked, [this](SDL_UserEvent& e) { + outpane->copy_selected_text_to_clipboard(); + }); + + outpane->connect(InternalEventType::text_selection_changed, [©](SDL_UserEvent& e) { + copy.enabled = SignalEmitter::copy_data1_from_userevent(e, false); + }); + + Button& paste = *toolbar->add_button(U"Paste"); + paste.connect(InternalEventType::clicked, [this](SDL_UserEvent& e) { + outpane->prompt.put_input_from_clipboard(); + }); + + Button& font_inc = *toolbar->add_button(U"A+"); + font_inc.connect(InternalEventType::clicked, [this](SDL_UserEvent& e) { + outpane->font->incr_size(); + }); + + Button& font_dec = *toolbar->add_button(U"A-"); + font_dec.connect(InternalEventType::clicked, [this](SDL_UserEvent& e) { + outpane->font->decr_size(); + }); + } + + ~MainWindow() + { + } + + void render() override { + // Should not fail unless OOM. + sdl_console::SDL_RenderClear(renderer()); + // set background color + // should not fail unless renderer is invalid + set_draw_color(renderer(), colors::darkgray); + toolbar->render(); + outpane->render(); + + sdl_console::SDL_RenderPresent(renderer()); + } + + void resize(const SDL_Rect& new_viewport) override + { + sdl_console::SDL_GetRendererOutputSize(renderer(), &viewport.w, &viewport.h); + toolbar->resize({ 0, 0, viewport.w, font->line_height_with_spacing() * 2 }); + outpane->resize({ 0, toolbar->viewport.h, viewport.w, viewport.h - toolbar->viewport.h }); + } + + MainWindow(const MainWindow&) = delete; + MainWindow& operator=(const MainWindow&) = delete; +}; + +Toolbar::Toolbar(Widget* parent, SDL_Rect viewport) + : Widget(parent, viewport) +{ + // Copy so that font size changes don't propogate to the toolbar. + font = context.font_loader.get_copy("toolbar", font); +}; + +void Toolbar::render() +{ + set_draw_color(renderer(), colors::gold); + // Render bg + // SDL_RenderFillRect(renderer(), &viewport); + // Draw a border + sdl_console::SDL_RenderDrawRect(renderer(), &viewport); + // Lay out horizontally + for (auto& w : widgets) { + w->render(); + } + + set_draw_color(renderer(), colors::darkgray); +} + +void Toolbar::resize(const SDL_Rect& new_viewport) +{ + viewport.w = new_viewport.w; + layout_buttons(); +} + +Button* Toolbar::add_button(std::u32string text) +{ + auto button = std::make_unique