diff --git a/Import/GacUI.cpp b/Import/GacUI.cpp index 74dfe7c2..82e87ba5 100644 --- a/Import/GacUI.cpp +++ b/Import/GacUI.cpp @@ -36150,7 +36150,19 @@ GuiRemoteGraphicsRenderTarget fontHeights.Add(key, fontHeight.height); } } + } + if (measuring.createdImages) + { + for (auto&& imageMetadata : *measuring.createdImages.Obj()) + { + auto image = remote->imageService.GetImage(imageMetadata.id); + image->UpdateFromImageMetadata(imageMetadata); + } + } + + if (measuring.fontHeights || measuring.createdImages) + { // TODO: (enumerable) foreach:indexed(alterable(reversed)) for (vint i = renderersAskingForCache.Count() - 1; i >= 0; i--) { @@ -36168,15 +36180,6 @@ GuiRemoteGraphicsRenderTarget } } - if (measuring.createdImages) - { - for (auto&& imageMetadata : *measuring.createdImages.Obj()) - { - auto image = remote->imageService.GetImage(imageMetadata.id); - image->UpdateFromImageMetadata(imageMetadata); - } - } - if (measuring.minSizes) { for (auto&& minSize : *measuring.minSizes.Obj()) @@ -36794,6 +36797,31 @@ Gui3DSplitterElementRenderer { } + void Gui3DSplitterElementRenderer::Render(Rect bounds) + { + switch (element->GetDirection()) + { + case Gui3DSplitterElement::Horizontal: + if (bounds.Height() < 2) + { + auto y = bounds.y1 + bounds.Height() / 2 - 1; + bounds.y1 = y; + bounds.y2 = y + 2; + } + break; + case Gui3DSplitterElement::Vertical: + if (bounds.Width() < 2) + { + auto x = bounds.x1 + bounds.Width() / 2 - 1; + bounds.x1 = x; + bounds.x2 = x + 2; + } + break; + } + + GuiRemoteProtocolElementRenderer::Render(bounds); + } + void Gui3DSplitterElementRenderer::SendUpdateElementMessages(bool fullContent, collections::List& updatedElements) { ElementDesc_SinkSplitter arguments; @@ -44112,6 +44140,11 @@ namespace vl::presentation::remote_renderer GetCurrentController()->CallbackService()->UninstallListener(this); } + bool GuiRemoteRendererSingle::IsDisconnectedFromCore() + { + return disconnectingFromCore; + } + void GuiRemoteRendererSingle::ForceExitByFatelError() { if (window) diff --git a/Import/GacUI.h b/Import/GacUI.h index ed790188..e17d8baf 100644 --- a/Import/GacUI.h +++ b/Import/GacUI.h @@ -22497,6 +22497,7 @@ namespace vl::presentation::elements_remoteprotocol public: Gui3DSplitterElementRenderer(); + void Render(Rect bounds) override; void SendUpdateElementMessages(bool fullContent, collections::List& updatedElements) override; }; @@ -23628,7 +23629,7 @@ namespace vl::presentation::remote_renderer INativeWindow* window = nullptr; INativeScreen* screen = nullptr; IGuiRemoteProtocolEvents* events = nullptr; - bool disconnectingFromCore = false; + atomic_vint disconnectingFromCore = false; bool stoppedByFatalError = false; Nullable fatalError; WString titleBeforeFatalError; @@ -23769,6 +23770,7 @@ namespace vl::presentation::remote_renderer void RegisterMainWindow(INativeWindow* _window); void UnregisterMainWindow(); + bool IsDisconnectedFromCore(); void ForceExitByFatelError(); void RequestCoreForceExitByFatalError(); void RetainByFatalError(const WString& errorMessage); diff --git a/Import/Vlpp.Linux.cpp b/Import/Vlpp.Linux.cpp index 3c1ba508..9b97304a 100644 --- a/Import/Vlpp.Linux.cpp +++ b/Import/Vlpp.Linux.cpp @@ -30,12 +30,14 @@ Console void Console::Write(const wchar_t* string, vint length) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::Write(const wchar_t*, vint)#Console operations are disabled."); std::wstring s(string, string + length); std::wcout << s << std::flush; } Nullable Console::TryRead() { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::TryRead()#Console operations are disabled."); std::wstring s; if (!std::getline(std::wcin, s, L'\n')) { @@ -59,6 +61,7 @@ Console void Console::SetColor(bool red, bool green, bool blue, bool light) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::SetColor(bool, bool, bool, bool)#Console operations are disabled."); int color = (blue ? 1 : 0) * 4 + (green ? 1 : 0) * 2 + (red ? 1 : 0); if (light) wprintf(L"\x1B[00;3%dm", color); @@ -68,6 +71,7 @@ Console void Console::SetTitle(const WString& string) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::SetTitle(const WString&)#Console operations are disabled."); } } } @@ -158,24 +162,27 @@ DateTime vint milliseconds; OSInternalToTime(osInternal, timer, milliseconds); - tm* timeinfo = localtime(&timer); - return ConvertTMToDateTime(timeinfo, milliseconds); + tm timeinfo; + localtime_r(&timer, &timeinfo); + return ConvertTMToDateTime(&timeinfo, milliseconds); } vuint64_t LocalTime() override { struct timeval tv; gettimeofday(&tv, nullptr); - tm* timeinfo = localtime(&tv.tv_sec); - return ConvertTMTToOSInternal(timeinfo, tv.tv_usec / 1000); + tm timeinfo; + localtime_r(&tv.tv_sec, &timeinfo); + return ConvertTMTToOSInternal(&timeinfo, tv.tv_usec / 1000); } vuint64_t UtcTime() override { struct timeval tv; gettimeofday(&tv, nullptr); - tm* timeinfo = gmtime(&tv.tv_sec); - return ConvertTMTToOSInternal(timeinfo, tv.tv_usec / 1000); + tm timeinfo; + gmtime_r(&tv.tv_sec, &timeinfo); + return ConvertTMTToOSInternal(&timeinfo, tv.tv_usec / 1000); } vuint64_t LocalToUtcTime(vuint64_t osInternal) override @@ -184,8 +191,9 @@ DateTime vint milliseconds; OSInternalToTime(osInternal, timer, milliseconds); - tm* timeinfo = gmtime(&timer); - return ConvertTMTToOSInternal(timeinfo, milliseconds); + tm timeinfo; + gmtime_r(&timer, &timeinfo); + return ConvertTMTToOSInternal(&timeinfo, milliseconds); } vuint64_t UtcToLocalTime(vuint64_t osInternal) override @@ -194,8 +202,11 @@ DateTime vint milliseconds; OSInternalToTime(osInternal, timer, milliseconds); - time_t localTimer = mktime(localtime(&timer)); - time_t utcTimer = mktime(gmtime(&timer)); + tm localTimeInfo, utcTimeInfo; + localtime_r(&timer, &localTimeInfo); + gmtime_r(&timer, &utcTimeInfo); + time_t localTimer = mktime(&localTimeInfo); + time_t utcTimer = mktime(&utcTimeInfo); timer += localTimer - utcTimer; TimeToOSInternal(timer, milliseconds, osInternal); diff --git a/Import/Vlpp.Windows.cpp b/Import/Vlpp.Windows.cpp index 44c69d69..c6a77e99 100644 --- a/Import/Vlpp.Windows.cpp +++ b/Import/Vlpp.Windows.cpp @@ -30,6 +30,7 @@ Console void Console::Write(const wchar_t* string, vint length) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::Write(const wchar_t*, vint)#Console operations are disabled."); HANDLE outHandle = GetStdHandle(STD_OUTPUT_HANDLE); DWORD fileMode = 0; DWORD written = 0; @@ -50,6 +51,7 @@ Console Nullable Console::TryRead() { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::TryRead()#Console operations are disabled."); auto inHandle = GetStdHandle(STD_INPUT_HANDLE); if (inHandle == INVALID_HANDLE_VALUE || inHandle == NULL) { @@ -139,6 +141,7 @@ Console void Console::SetColor(bool red, bool green, bool blue, bool light) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::SetColor(bool, bool, bool, bool)#Console operations are disabled."); WORD attribute = 0; if (red)attribute |= FOREGROUND_RED; if (green)attribute |= FOREGROUND_GREEN; @@ -150,6 +153,7 @@ Console void Console::SetTitle(const WString& string) { + CHECK_ERROR(IsEnabled(), L"vl::console::Console::SetTitle(const WString&)#Console operations are disabled."); SetConsoleTitle(string.Buffer()); } } diff --git a/Import/Vlpp.cpp b/Import/Vlpp.cpp index 73140d91..1df33520 100644 --- a/Import/Vlpp.cpp +++ b/Import/Vlpp.cpp @@ -45,11 +45,31 @@ namespace vl { namespace console { + bool& GetConsoleEnabled() + { + static bool enabled = true; + return enabled; + } /*********************************************************************** Console ***********************************************************************/ + void Console::Enable() + { + GetConsoleEnabled() = true; + } + + void Console::Disable() + { + GetConsoleEnabled() = false; + } + + bool Console::IsEnabled() + { + return GetConsoleEnabled(); + } + void Console::Write(const wchar_t* string) { Write(string, wcslen(string)); diff --git a/Import/Vlpp.h b/Import/Vlpp.h index 8eef7eb8..c73c62ea 100644 --- a/Import/Vlpp.h +++ b/Import/Vlpp.h @@ -8716,6 +8716,15 @@ namespace vl class Console abstract { public: + /// Enable Console operations. This operation is idempotent. + static void Enable(); + + /// Disable Console operations. This operation is idempotent. + static void Disable(); + + /// Test whether Console operations are enabled. + static bool IsEnabled(); + /// Write a string to the command-line window. /// Content to write. /// Size of the content in wchar_t, not including the zero terminator. diff --git a/Import/VlppOS.Linux.cpp b/Import/VlppOS.Linux.cpp index 6afeaebb..d0decd6e 100644 --- a/Import/VlppOS.Linux.cpp +++ b/Import/VlppOS.Linux.cpp @@ -5699,3 +5699,584 @@ namespace vl::inter_process::async_tcp_socket } #endif + +/*********************************************************************** +.\TUI\TUI.LINUX.CPP +***********************************************************************/ +/*********************************************************************** +Author: Zihan Chen (vczh) +Licensed under https://github.com/vczh-libraries/License +***********************************************************************/ + + +#ifdef VCZH_GCC + +#include +#include +#include +#include +#include +#include + +using namespace vl; +using namespace vl::collections; + +namespace vl +{ + namespace console + { + vint TUI::MeasureChar(char32_t code) + { + if (!tui_internal::IsScalar(code)) return 0; + auto width = wcwidth((wchar_t)code); + return width < 0 ? 0 : width; + } + + namespace tui_internal + { + int resizePipe[2] = { -1, -1 }; + + void ResizeSignalHandler(int) + { + auto savedError = errno; + char byte = 1; + while (write(resizePipe[1], &byte, 1) == -1 && errno == EINTR) + { + } + errno = savedError; + } + + void EnsureResizePipe() + { + if (resizePipe[0] != -1) return; + CHECK_ERROR(pipe(resizePipe) == 0, L"vl::console::TUI POSIX backend failed to create its resize pipe."); + for (vint i = 0; i < 2; i++) + { + auto descriptorFlags = fcntl(resizePipe[i], F_GETFD); + auto statusFlags = fcntl(resizePipe[i], F_GETFL); + CHECK_ERROR(descriptorFlags != -1 && statusFlags != -1, L"vl::console::TUI POSIX backend failed to query resize-pipe flags."); + CHECK_ERROR(fcntl(resizePipe[i], F_SETFD, descriptorFlags | FD_CLOEXEC) != -1, L"vl::console::TUI POSIX backend failed to make the resize pipe close-on-exec."); + CHECK_ERROR(fcntl(resizePipe[i], F_SETFL, statusFlags | O_NONBLOCK) != -1, L"vl::console::TUI POSIX backend failed to make the resize pipe nonblocking."); + } + } + + void WriteAll(int descriptor, const char* text, vint length) + { + vint written = 0; + while (written < length) + { + auto count = write(descriptor, text + written, (size_t)(length - written)); + if (count == -1 && errno == EINTR) continue; + CHECK_ERROR(count > 0, L"vl::console::TUI POSIX backend failed while writing terminal output."); + written += count; + } + } + + void AppendUtf8(std::string& output, char32_t code) + { + if (code <= 0x7F) + { + output.push_back((char)code); + } + else if (code <= 0x7FF) + { + output.push_back((char)(0xC0 | (code >> 6))); + output.push_back((char)(0x80 | (code & 0x3F))); + } + else if (code <= 0xFFFF) + { + output.push_back((char)(0xE0 | (code >> 12))); + output.push_back((char)(0x80 | ((code >> 6) & 0x3F))); + output.push_back((char)(0x80 | (code & 0x3F))); + } + else + { + output.push_back((char)(0xF0 | (code >> 18))); + output.push_back((char)(0x80 | ((code >> 12) & 0x3F))); + output.push_back((char)(0x80 | ((code >> 6) & 0x3F))); + output.push_back((char)(0x80 | (code & 0x3F))); + } + } + + void AppendNumber(std::string& output, vint value) + { + output += std::to_string((long long)value); + } + + class PosixTuiBackend : public unittest::ITuiBackend + { + private: + termios savedTermios = {}; + struct sigaction savedAction = {}; + sigset_t savedMask = {}; + List inputBytes; + List pendingEvents; + vuint64_t escapeDeadline = 0; + vuint64_t lastClickTime = 0; + vint lastClickX = -1; + vint lastClickY = -1; + TuiMouseButton lastClickButton = TuiMouseButton::Left; + bool left = false; + bool middle = false; + bool right = false; + bool started = false; + bool termiosChanged = false; + bool signalInstalled = false; + + void QueueChar(wchar_t code, bool alt = false) + { + unittest::TuiBackendEvent event; + event.type = unittest::TuiBackendEventType::Char; + event.charInfo.code = code; + event.charInfo.alt = alt; + pendingEvents.Add(event); + } + + void DrainResizePipe() + { + char buffer[64]; + for (;;) + { + auto count = read(resizePipe[0], buffer, sizeof(buffer)); + if (count > 0) continue; + if (count == -1 && errno == EINTR) continue; + break; + } + } + + void QueueResize() + { + vint width = 0; + vint height = 0; + if (TryGetConsoleSize(width, height)) + { + unittest::TuiBackendEvent event; + event.type = unittest::TuiBackendEventType::Resize; + event.width = width; + event.height = height; + pendingEvents.Add(event); + } + } + + bool TryParseMouse() + { + if (inputBytes.Count() < 4 || inputBytes[0] != 0x1B || inputBytes[1] != '[' || inputBytes[2] != '<') return false; + vint end = -1; + for (vint i = 3; i < inputBytes.Count(); i++) + { + if (inputBytes[i] == 'M' || inputBytes[i] == 'm') + { + end = i; + break; + } + if (!(inputBytes[i] == ';' || (inputBytes[i] >= '0' && inputBytes[i] <= '9'))) + { + inputBytes.RemoveRange(0, i + 1); + QueueChar(L'\uFFFD'); + return true; + } + } + if (end == -1) return false; + + vint values[3] = {}; + vint valueIndex = 0; + for (vint i = 3; i < end; i++) + { + if (inputBytes[i] == ';') + { + valueIndex++; + if (valueIndex >= 3) break; + } + else + { + values[valueIndex] = values[valueIndex] * 10 + inputBytes[i] - '0'; + } + } + auto final = inputBytes[end]; + inputBytes.RemoveRange(0, end + 1); + escapeDeadline = 0; + if (valueIndex != 2) return true; + + auto cb = values[0]; + TuiMouseInfo info; + info.x = values[1] - 1; + info.y = values[2] - 1; + info.shift = (cb & 4) != 0; + info.alt = (cb & 8) != 0; + info.ctrl = (cb & 16) != 0; + auto motion = (cb & 32) != 0; + auto base = cb & ~(4 | 8 | 16 | 32); + info.left = left; + info.middle = middle; + info.right = right; + + unittest::TuiBackendEvent event; + event.mouseInfo = info; + if (base >= 64 && base <= 67) + { + if (base == 64 || base == 65) + { + event.type = unittest::TuiBackendEventType::MouseVerticalWheel; + event.mouseInfo.wheel = base == 64 ? 120 : -120; + } + else + { + event.type = unittest::TuiBackendEventType::MouseHorizontalWheel; + event.mouseInfo.wheel = base == 66 ? 120 : -120; + } + pendingEvents.Add(event); + return true; + } + if (motion) + { + event.type = unittest::TuiBackendEventType::MouseMove; + pendingEvents.Add(event); + return true; + } + + auto released = final == 'm' || base == 3; + auto button = base == 0 ? TuiMouseButton::Left : base == 1 ? TuiMouseButton::Middle : TuiMouseButton::Right; + event.mouseButton = button; + if (released) + { + event.type = unittest::TuiBackendEventType::MouseUp; + if (button == TuiMouseButton::Left) left = false; + if (button == TuiMouseButton::Middle) middle = false; + if (button == TuiMouseButton::Right) right = false; + } + else + { + auto now = GetMonotonicTime(); + auto doubleClick = lastClickTime != 0 && now - lastClickTime <= 500 && lastClickX == info.x && lastClickY == info.y && lastClickButton == button; + event.type = doubleClick ? unittest::TuiBackendEventType::MouseDoubleClick : unittest::TuiBackendEventType::MouseDown; + if (button == TuiMouseButton::Left) left = true; + if (button == TuiMouseButton::Middle) middle = true; + if (button == TuiMouseButton::Right) right = true; + if (doubleClick) + { + lastClickTime = 0; + } + else + { + lastClickTime = now; + lastClickX = info.x; + lastClickY = info.y; + lastClickButton = button; + } + } + event.mouseInfo.left = left; + event.mouseInfo.middle = middle; + event.mouseInfo.right = right; + pendingEvents.Add(event); + return true; + } + + bool TryConsumeSpecialSequence() + { + if (inputBytes.Count() < 2 || inputBytes[0] != 0x1B || inputBytes[1] != '[') return false; + for (vint i = 2; i < inputBytes.Count(); i++) + { + auto byte = inputBytes[i]; + if (byte >= 0x40 && byte <= 0x7E) + { + inputBytes.RemoveRange(0, i + 1); + escapeDeadline = 0; + return true; + } + } + return false; + } + + bool TryDecodeUtf8(bool alt = false) + { + auto offset = alt ? 1 : 0; + if (inputBytes.Count() <= offset) return false; + auto first = inputBytes[offset]; + vint length = 0; + char32_t code = 0; + if (first < 0x80) + { + length = 1; + code = first; + } + else if (first >= 0xC2 && first <= 0xDF) + { + length = 2; + code = first & 0x1F; + } + else if (first >= 0xE0 && first <= 0xEF) + { + length = 3; + code = first & 0x0F; + } + else if (first >= 0xF0 && first <= 0xF4) + { + length = 4; + code = first & 0x07; + } + else + { + inputBytes.RemoveRange(0, offset + 1); + QueueChar(L'\uFFFD', alt); + return true; + } + if (inputBytes.Count() < offset + length) return false; + for (vint i = 1; i < length; i++) + { + auto next = inputBytes[offset + i]; + if ((next & 0xC0) != 0x80) + { + inputBytes.RemoveRange(0, offset + 1); + QueueChar(L'\uFFFD', alt); + return true; + } + code = (code << 6) | (next & 0x3F); + } + auto minimum = length == 1 ? 0 : length == 2 ? 0x80 : length == 3 ? 0x800 : 0x10000; + if (code < minimum || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) + { + inputBytes.RemoveRange(0, offset + length); + QueueChar(L'\uFFFD', alt); + return true; + } + inputBytes.RemoveRange(0, offset + length); + escapeDeadline = 0; + QueueChar((wchar_t)code, alt); + return true; + } + + bool ParseInput() + { + if (inputBytes.Count() == 0) return false; + if (inputBytes[0] == 0x1B) + { + if (TryParseMouse()) return true; + if (TryConsumeSpecialSequence()) return true; + if (inputBytes.Count() > 1 && inputBytes[1] != '[') + { + return TryDecodeUtf8(true); + } + if (escapeDeadline == 0) escapeDeadline = GetMonotonicTime() + 30; + if (GetMonotonicTime() >= escapeDeadline) + { + inputBytes.RemoveAt(0); + escapeDeadline = 0; + QueueChar((wchar_t)0x1B); + return true; + } + return false; + } + return TryDecodeUtf8(); + } + + void AppendColor(std::string& output, TuiColor foreground, TuiColor background, TuiColorMode colorMode) + { + output += "\x1B["; + if (colorMode == TuiColorMode::TrueColor) + { + output += "38;2;"; + AppendNumber(output, foreground.r); + output += ";"; + AppendNumber(output, foreground.g); + output += ";"; + AppendNumber(output, foreground.b); + output += ";48;2;"; + AppendNumber(output, background.r); + output += ";"; + AppendNumber(output, background.g); + output += ";"; + AppendNumber(output, background.b); + } + else if (colorMode == TuiColorMode::Color256) + { + output += "38;5;"; + AppendNumber(output, QuantizeColor(foreground, colorMode)); + output += ";48;5;"; + AppendNumber(output, QuantizeColor(background, colorMode)); + } + else + { + auto foregroundIndex = QuantizeColor(foreground, colorMode); + auto backgroundIndex = QuantizeColor(background, colorMode); + AppendNumber(output, foregroundIndex < 8 ? 30 + foregroundIndex : 90 + foregroundIndex - 8); + output += ";"; + AppendNumber(output, backgroundIndex < 8 ? 40 + backgroundIndex : 100 + backgroundIndex - 8); + } + output += "m"; + } + + public: + TuiColorMode Start(const TuiStartOptions& options) override + { + CHECK_ERROR(!started, L"vl::console::TUI POSIX backend is already active."); + CHECK_ERROR(isatty(STDIN_FILENO) && isatty(STDOUT_FILENO), L"vl::console::TUI requires interactive terminal descriptors."); + EnsureResizePipe(); + DrainResizePipe(); + CHECK_ERROR(tcgetattr(STDIN_FILENO, &savedTermios) == 0, L"vl::console::TUI failed to query terminal attributes."); + started = true; + + try + { + auto raw = savedTermios; + cfmakeraw(&raw); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + CHECK_ERROR(tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0, L"vl::console::TUI failed to activate raw terminal input."); + termiosChanged = true; + + sigset_t blocked; + sigemptyset(&blocked); + sigaddset(&blocked, SIGWINCH); + CHECK_ERROR(pthread_sigmask(SIG_BLOCK, &blocked, &savedMask) == 0, L"vl::console::TUI failed to block SIGWINCH during handler installation."); + struct sigaction action = {}; + action.sa_handler = ResizeSignalHandler; + sigemptyset(&action.sa_mask); + auto signalResult = sigaction(SIGWINCH, &action, &savedAction); + signalInstalled = signalResult == 0; + auto maskResult = pthread_sigmask(SIG_SETMASK, &savedMask, nullptr); + CHECK_ERROR(signalResult == 0, L"vl::console::TUI failed to install its SIGWINCH handler."); + CHECK_ERROR(maskResult == 0, L"vl::console::TUI failed to restore the owner thread signal mask."); + + const char sequence[] = "\x1B[?1049h\x1B[?25l\x1B[?1003h\x1B[?1006h"; + WriteAll(STDOUT_FILENO, sequence, sizeof(sequence) - 1); + if (options.colorMode != TuiColorMode::Auto) return options.colorMode; + auto colorTerm = getenv("COLORTERM"); + if (colorTerm && (strstr(colorTerm, "truecolor") || strstr(colorTerm, "24bit"))) return TuiColorMode::TrueColor; + auto term = getenv("TERM"); + if (term && strstr(term, "256color")) return TuiColorMode::Color256; + return TuiColorMode::Color16; + } + catch (...) + { + Stop(); + throw; + } + } + + void Stop() override + { + if (!started) return; + const char sequence[] = "\x1B[?1006l\x1B[?1003l\x1B[0m\x1B[?25h\x1B[?1049l"; + auto ignored = write(STDOUT_FILENO, sequence, sizeof(sequence) - 1); + (void)ignored; + if (termiosChanged) tcsetattr(STDIN_FILENO, TCSANOW, &savedTermios); + if (signalInstalled) + { + sigset_t blocked; + sigemptyset(&blocked); + sigaddset(&blocked, SIGWINCH); + pthread_sigmask(SIG_BLOCK, &blocked, nullptr); + sigaction(SIGWINCH, &savedAction, nullptr); + pthread_sigmask(SIG_SETMASK, &savedMask, nullptr); + } + inputBytes.Clear(); + pendingEvents.Clear(); + escapeDeadline = 0; + left = middle = right = false; + termiosChanged = false; + signalInstalled = false; + started = false; + } + + bool TryGetConsoleSize(vint& width, vint& height) override + { + winsize size = {}; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) != 0) return false; + width = size.ws_col; + height = size.ws_row; + return width > 0 && height > 0; + } + + vuint64_t GetMonotonicTime() override + { + timespec time = {}; + clock_gettime(CLOCK_MONOTONIC, &time); + return (vuint64_t)time.tv_sec * 1000 + time.tv_nsec / 1000000; + } + + bool ReadEvent(vint milliseconds, unittest::TuiBackendEvent& event) override + { + if (pendingEvents.Count() == 0) ParseInput(); + if (pendingEvents.Count() == 0) + { + auto wait = milliseconds; + if (escapeDeadline != 0) + { + auto now = GetMonotonicTime(); + auto remaining = escapeDeadline <= now ? 0 : (vint)(escapeDeadline - now); + if (wait < 0 || remaining < wait) wait = remaining; + } + pollfd descriptors[] = + { + { STDIN_FILENO, POLLIN, 0 }, + { resizePipe[0], POLLIN, 0 }, + }; + vint result = 0; + do + { + result = poll(descriptors, sizeof(descriptors) / sizeof(*descriptors), wait); + } while (result == -1 && errno == EINTR); + CHECK_ERROR(result >= 0, L"vl::console::TUI POSIX backend failed while waiting for terminal input."); + if (descriptors[1].revents & POLLIN) + { + DrainResizePipe(); + QueueResize(); + } + if (descriptors[0].revents & POLLIN) + { + vuint8_t bytes[256]; + auto count = read(STDIN_FILENO, bytes, sizeof(bytes)); + if (count > 0) + { + for (vint i = 0; i < count; i++) inputBytes.Add(bytes[i]); + } + } + if (pendingEvents.Count() == 0) ParseInput(); + } + if (pendingEvents.Count() == 0) return false; + event = pendingEvents[0]; + pendingEvents.RemoveAt(0); + return true; + } + + void Render(const TuiPixel* buffer, vint width, vint height, TuiColorMode colorMode) override + { + std::string output; + TuiColor lastForeground; + TuiColor lastBackground; + bool hasLastColor = false; + for (vint y = 0; y < height; y++) + { + output += "\x1B["; + AppendNumber(output, y + 1); + output += ";1H"; + for (vint x = 0; x < width; x++) + { + auto& pixel = buffer[y * width + x]; + if (pixel.glyph == TuiPixelGlyph::WideCharContinuation) continue; + if (!hasLastColor || pixel.foregroundColor != lastForeground || pixel.backgroundColor != lastBackground) + { + AppendColor(output, pixel.foregroundColor, pixel.backgroundColor, colorMode); + lastForeground = pixel.foregroundColor; + lastBackground = pixel.backgroundColor; + hasLastColor = true; + } + auto code = pixel.GetChar32(); + AppendUtf8(output, code == 0 ? U' ' : code); + } + } + output += "\x1B[0m"; + WriteAll(STDOUT_FILENO, output.data(), (vint)output.size()); + } + }; + + Ptr CreateTuiBackend() + { + return Ptr(new PosixTuiBackend); + } + } + } +} + +#endif + diff --git a/Import/VlppOS.Windows.cpp b/Import/VlppOS.Windows.cpp index dc459f62..ac765bb1 100644 --- a/Import/VlppOS.Windows.cpp +++ b/Import/VlppOS.Windows.cpp @@ -6420,3 +6420,581 @@ void NamedPipeClient::Stop() #pragma comment(lib, "httpapi.lib") #pragma comment(lib, "rpcrt4.lib") + +/*********************************************************************** +.\TUI\TUI.WINDOWS.CPP +***********************************************************************/ +/*********************************************************************** +Author: Zihan Chen (vczh) +Licensed under https://github.com/vczh-libraries/License +***********************************************************************/ + +#define _WINSOCKAPI_ +#include + +#ifndef VCZH_MSVC +static_assert(false, "Do not build this file for non-Windows applications."); +#endif + +using namespace vl; +using namespace vl::collections; + +namespace vl +{ + namespace console + { + vint TUI::MeasureChar(char32_t code) + { +#define ERROR_MESSAGE_PREFIX L"vl::console::TUI::MeasureChar(char32_t)#" + if (!tui_internal::IsScalar(code)) return 0; + + wchar_t text[2]; + auto length = 1; + if (code <= 0xFFFF) + { + text[0] = (wchar_t)code; + } + else + { + code -= 0x10000; + text[0] = (wchar_t)(0xD800 + (code >> 10)); + text[1] = (wchar_t)(0xDC00 + (code & 0x3FF)); + length = 2; + } + + WORD ctype1[2] = {}; + WORD ctype3[2] = {}; + CHECK_ERROR( + GetStringTypeW(CT_CTYPE1, text, length, ctype1) && + GetStringTypeW(CT_CTYPE3, text, length, ctype3), + ERROR_MESSAGE_PREFIX L"Failed to query character types." + ); + if (ctype1[0] & C1_CNTRL) return 0; + if (ctype3[0] & (C3_NONSPACING | C3_DIACRITIC | C3_VOWELMARK)) return 0; + if (ctype3[0] & C3_HALFWIDTH) return 1; + if (length == 2 || (ctype3[0] & (C3_FULLWIDTH | C3_IDEOGRAPH | C3_HIRAGANA | C3_KATAKANA))) return 2; +#undef ERROR_MESSAGE_PREFIX + return 1; + } + + namespace tui_internal + { + void WriteConsoleAll(HANDLE handle, const wchar_t* text, vint length) + { + vint written = 0; + while (written < length) + { + DWORD count = 0; + CHECK_ERROR(WriteConsoleW(handle, text + written, (DWORD)(length - written), &count, nullptr), L"vl::console::TUI Windows backend failed to write terminal output."); + CHECK_ERROR(count > 0, L"vl::console::TUI Windows backend made no progress while writing terminal output."); + written += count; + } + } + + vint AnsiToWindowsColor(vint color) + { + return (color & 8) | ((color & 1) << 2) | (color & 2) | ((color & 4) >> 2); + } + + class WindowsTuiBackend : public unittest::ITuiBackend + { + private: + HANDLE inputHandle = INVALID_HANDLE_VALUE; + HANDLE originalOutputHandle = INVALID_HANDLE_VALUE; + HANDLE outputHandle = INVALID_HANDLE_VALUE; + HANDLE classicOutputHandle = INVALID_HANDLE_VALUE; + DWORD inputMode = 0; + DWORD outputMode = 0; + CONSOLE_CURSOR_INFO cursorInfo = {}; + CONSOLE_SCREEN_BUFFER_INFOEX screenInfo = {}; + CONSOLE_SCREEN_BUFFER_INFO originalGeometry = {}; + List pendingEvents; + DWORD mouseButtons = 0; + vint viewportWidth = 0; + vint viewportHeight = 0; + bool started = false; + bool inputModeChanged = false; + bool outputModeChanged = false; + bool cursorInfoSaved = false; + bool geometrySaved = false; + bool usingVt = false; + + void SetConsoleGeometry(HANDLE handle, COORD bufferSize, SMALL_RECT window) + { + CONSOLE_SCREEN_BUFFER_INFO current = {}; + CHECK_ERROR(GetConsoleScreenBufferInfo(handle, ¤t), L"vl::console::TUI Windows backend failed to query console geometry."); + auto currentWidth = (SHORT)(current.srWindow.Right - current.srWindow.Left + 1); + auto currentHeight = (SHORT)(current.srWindow.Bottom - current.srWindow.Top + 1); + auto temporaryWidth = currentWidth < bufferSize.X ? currentWidth : bufferSize.X; + auto temporaryHeight = currentHeight < bufferSize.Y ? currentHeight : bufferSize.Y; + SMALL_RECT temporary = { 0, 0, (SHORT)(temporaryWidth - 1), (SHORT)(temporaryHeight - 1) }; + if (current.srWindow.Left != temporary.Left || + current.srWindow.Top != temporary.Top || + current.srWindow.Right != temporary.Right || + current.srWindow.Bottom != temporary.Bottom) + { + CHECK_ERROR(SetConsoleWindowInfo(handle, TRUE, &temporary), L"vl::console::TUI Windows backend failed to prepare the console window for resizing."); + } + if (current.dwSize.X != bufferSize.X || current.dwSize.Y != bufferSize.Y) + { + CHECK_ERROR(SetConsoleScreenBufferSize(handle, bufferSize), L"vl::console::TUI Windows backend failed to resize the console screen buffer."); + } + if (temporary.Left != window.Left || + temporary.Top != window.Top || + temporary.Right != window.Right || + temporary.Bottom != window.Bottom) + { + CHECK_ERROR(SetConsoleWindowInfo(handle, TRUE, &window), L"vl::console::TUI Windows backend failed to resize the console window."); + } + } + + void QueueResize(vint width, vint height) + { + unittest::TuiBackendEvent event; + event.type = unittest::TuiBackendEventType::Resize; + event.width = width; + event.height = height; + pendingEvents.Add(event); + } + + void SynchronizeViewport(bool queueEvent) + { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + CHECK_ERROR(GetConsoleScreenBufferInfo(outputHandle, &info), L"vl::console::TUI Windows backend failed to query the console viewport."); + auto width = (vint)(info.srWindow.Right - info.srWindow.Left + 1); + auto height = (vint)(info.srWindow.Bottom - info.srWindow.Top + 1); + CHECK_ERROR(width > 0 && height > 0, L"vl::console::TUI Windows backend received an invalid console viewport."); + auto changed = width != viewportWidth || height != viewportHeight; + COORD bufferSize = { (SHORT)width, (SHORT)height }; + SMALL_RECT window = { 0, 0, (SHORT)(width - 1), (SHORT)(height - 1) }; + if (info.dwSize.X != bufferSize.X || + info.dwSize.Y != bufferSize.Y || + info.srWindow.Left != 0 || + info.srWindow.Top != 0) + { + SetConsoleGeometry(outputHandle, bufferSize, window); + } + viewportWidth = width; + viewportHeight = height; + if (queueEvent && changed) + { + QueueResize(width, height); + } + } + + TuiMouseInfo GetMouseInfo(const MOUSE_EVENT_RECORD& record) + { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + CHECK_ERROR(GetConsoleScreenBufferInfo(outputHandle, &info), L"vl::console::TUI Windows backend failed to query the console viewport."); + TuiMouseInfo result; + result.x = record.dwMousePosition.X - info.srWindow.Left; + result.y = record.dwMousePosition.Y - info.srWindow.Top; + result.ctrl = (record.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0; + result.shift = (record.dwControlKeyState & SHIFT_PRESSED) != 0; + result.alt = (record.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED)) != 0; + result.left = (record.dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED) != 0; + result.middle = (record.dwButtonState & FROM_LEFT_2ND_BUTTON_PRESSED) != 0; + result.right = (record.dwButtonState & RIGHTMOST_BUTTON_PRESSED) != 0; + return result; + } + + TuiKeyInfo GetKeyInfo(const KEY_EVENT_RECORD& record) + { + TuiKeyInfo result; + result.ctrl = (record.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0; + result.shift = (record.dwControlKeyState & SHIFT_PRESSED) != 0; + result.alt = (record.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED)) != 0; + result.capslock = (record.dwControlKeyState & CAPSLOCK_ON) != 0; + result.autoRepeatKeyDown = record.bKeyDown && record.wRepeatCount > 1; + return result; + } + + void QueueChar(wchar_t code, const KEY_EVENT_RECORD& record) + { + unittest::TuiBackendEvent event; + event.type = unittest::TuiBackendEventType::Char; + event.charInfo.code = code; + event.charInfo.ctrl = (record.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0; + event.charInfo.shift = (record.dwControlKeyState & SHIFT_PRESSED) != 0; + event.charInfo.alt = (record.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED)) != 0; + event.charInfo.capslock = (record.dwControlKeyState & CAPSLOCK_ON) != 0; + pendingEvents.Add(event); + } + + void DecodeKey(const KEY_EVENT_RECORD& record) + { + unittest::TuiBackendEvent keyEvent; + keyEvent.type = record.bKeyDown ? unittest::TuiBackendEventType::KeyDown : unittest::TuiBackendEventType::KeyUp; + keyEvent.keyInfo = GetKeyInfo(record); + pendingEvents.Add(keyEvent); + + if (!record.bKeyDown || record.uChar.UnicodeChar == 0) return; + auto codeUnit = record.uChar.UnicodeChar; + auto repeat = record.wRepeatCount == 0 ? 1 : record.wRepeatCount; + for (vint i = 0; i < repeat; i++) + { + QueueChar(codeUnit, record); + } + } + + void QueueMouseButton(unittest::TuiBackendEventType type, TuiMouseButton button, const TuiMouseInfo& info) + { + unittest::TuiBackendEvent event; + event.type = type; + event.mouseButton = button; + event.mouseInfo = info; + pendingEvents.Add(event); + } + + void DecodeMouse(const MOUSE_EVENT_RECORD& record) + { + auto info = GetMouseInfo(record); + if (record.dwEventFlags == MOUSE_MOVED) + { + unittest::TuiBackendEvent event; + event.type = unittest::TuiBackendEventType::MouseMove; + event.mouseInfo = info; + pendingEvents.Add(event); + } + else if (record.dwEventFlags == MOUSE_WHEELED || record.dwEventFlags == MOUSE_HWHEELED) + { + unittest::TuiBackendEvent event; + event.type = record.dwEventFlags == MOUSE_WHEELED ? unittest::TuiBackendEventType::MouseVerticalWheel : unittest::TuiBackendEventType::MouseHorizontalWheel; + event.mouseInfo = info; + event.mouseInfo.wheel = (SHORT)HIWORD(record.dwButtonState); + pendingEvents.Add(event); + } + else if (record.dwEventFlags == DOUBLE_CLICK) + { + auto button = (record.dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED) ? TuiMouseButton::Left + : (record.dwButtonState & FROM_LEFT_2ND_BUTTON_PRESSED) ? TuiMouseButton::Middle + : TuiMouseButton::Right; + QueueMouseButton(unittest::TuiBackendEventType::MouseDoubleClick, button, info); + } + else if (record.dwEventFlags == 0) + { + struct ButtonMapping + { + DWORD mask; + TuiMouseButton button; + }; + ButtonMapping mappings[] = + { + { FROM_LEFT_1ST_BUTTON_PRESSED, TuiMouseButton::Left }, + { FROM_LEFT_2ND_BUTTON_PRESSED, TuiMouseButton::Middle }, + { RIGHTMOST_BUTTON_PRESSED, TuiMouseButton::Right }, + }; + for (auto mapping : mappings) + { + auto before = (mouseButtons & mapping.mask) != 0; + auto after = (record.dwButtonState & mapping.mask) != 0; + if (before != after) + { + QueueMouseButton(after ? unittest::TuiBackendEventType::MouseDown : unittest::TuiBackendEventType::MouseUp, mapping.button, info); + } + } + } + mouseButtons = record.dwButtonState; + } + + void DecodeRecord(const INPUT_RECORD& record) + { + switch (record.EventType) + { + case KEY_EVENT: + DecodeKey(record.Event.KeyEvent); + break; + case MOUSE_EVENT: + DecodeMouse(record.Event.MouseEvent); + break; + case WINDOW_BUFFER_SIZE_EVENT: + SynchronizeViewport(true); + break; + } + } + + void AppendColor(WString& output, TuiColor foreground, TuiColor background, TuiColorMode colorMode) + { + if (colorMode == TuiColorMode::TrueColor) + { + output += L"\x1B[38;2;" + itow(foreground.r) + L";" + itow(foreground.g) + L";" + itow(foreground.b) + + L";48;2;" + itow(background.r) + L";" + itow(background.g) + L";" + itow(background.b) + L"m"; + } + else + { + TuiColor custom16[16]; + const TuiColor* custom = nullptr; + if (colorMode == TuiColorMode::Color16 && screenInfo.cbSize == sizeof(screenInfo)) + { + for (vint i = 0; i < 16; i++) + { + auto color = screenInfo.ColorTable[AnsiToWindowsColor(i)]; + custom16[i] = { GetRValue(color), GetGValue(color), GetBValue(color) }; + } + custom = custom16; + } + auto foregroundIndex = QuantizeColor(foreground, colorMode, custom); + auto backgroundIndex = QuantizeColor(background, colorMode, custom); + if (colorMode == TuiColorMode::Color256) + { + output += L"\x1B[38;5;" + itow(foregroundIndex) + L";48;5;" + itow(backgroundIndex) + L"m"; + } + else + { + auto foregroundCode = foregroundIndex < 8 ? 30 + foregroundIndex : 90 + foregroundIndex - 8; + auto backgroundCode = backgroundIndex < 8 ? 40 + backgroundIndex : 100 + backgroundIndex - 8; + output += L"\x1B[" + itow(foregroundCode) + L";" + itow(backgroundCode) + L"m"; + } + } + } + + public: + TuiColorMode Start(const TuiStartOptions& options) override + { + CHECK_ERROR(!started, L"vl::console::TUI Windows backend is already active."); + inputHandle = GetStdHandle(STD_INPUT_HANDLE); + originalOutputHandle = GetStdHandle(STD_OUTPUT_HANDLE); + outputHandle = originalOutputHandle; + CHECK_ERROR(inputHandle != INVALID_HANDLE_VALUE && originalOutputHandle != INVALID_HANDLE_VALUE, L"vl::console::TUI requires valid console handles."); + CHECK_ERROR(GetConsoleMode(inputHandle, &inputMode) && GetConsoleMode(originalOutputHandle, &outputMode), L"vl::console::TUI requires interactive console handles."); + cursorInfoSaved = GetConsoleCursorInfo(originalOutputHandle, &cursorInfo) != 0; + screenInfo = {}; + screenInfo.cbSize = sizeof(screenInfo); + if (!GetConsoleScreenBufferInfoEx(originalOutputHandle, &screenInfo)) + { + screenInfo.cbSize = 0; + } + CHECK_ERROR(GetConsoleScreenBufferInfo(originalOutputHandle, &originalGeometry), L"vl::console::TUI failed to save the original console geometry."); + geometrySaved = true; + started = true; + + try + { + auto newInputMode = inputMode; + newInputMode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE | ENABLE_VIRTUAL_TERMINAL_INPUT); + newInputMode |= ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT; + CHECK_ERROR(SetConsoleMode(inputHandle, newInputMode), L"vl::console::TUI failed to activate console input mode."); + inputModeChanged = true; + + auto newOutputMode = outputMode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + usingVt = SetConsoleMode(originalOutputHandle, newOutputMode) != 0; + if (usingVt) + { + outputModeChanged = true; + const wchar_t sequence[] = L"\x1B[?1049h\x1B[?25l"; + WriteConsoleAll(outputHandle, sequence, sizeof(sequence) / sizeof(*sequence) - 1); + SynchronizeViewport(false); + if (options.colorMode == TuiColorMode::Auto) return TuiColorMode::TrueColor; + return options.colorMode; + } + + classicOutputHandle = CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, CONSOLE_TEXTMODE_BUFFER, nullptr); + CHECK_ERROR(classicOutputHandle != INVALID_HANDLE_VALUE, L"vl::console::TUI failed to create an alternate console screen buffer."); + auto width = (SHORT)(originalGeometry.srWindow.Right - originalGeometry.srWindow.Left + 1); + auto height = (SHORT)(originalGeometry.srWindow.Bottom - originalGeometry.srWindow.Top + 1); + SetConsoleGeometry(classicOutputHandle, { width, height }, { 0, 0, (SHORT)(width - 1), (SHORT)(height - 1) }); + CHECK_ERROR(SetConsoleActiveScreenBuffer(classicOutputHandle), L"vl::console::TUI failed to activate the alternate console screen buffer."); + outputHandle = classicOutputHandle; + viewportWidth = width; + viewportHeight = height; + return TuiColorMode::Color16; + } + catch (...) + { + auto exception = std::current_exception(); + try + { + Stop(); + } + catch (...) + { + } + std::rethrow_exception(exception); + } + } + + void Stop() override + { + if (!started) return; + auto restored = true; + if (usingVt) + { + auto width = originalGeometry.srWindow.Right - originalGeometry.srWindow.Left + 1; + auto height = originalGeometry.srWindow.Bottom - originalGeometry.srWindow.Top + 1; + auto sequence = WString::Unmanaged(L"\x1B[0m\x1B[8;") + + itow(height) + L";" + itow(width) + L"t\x1B[?1049l\x1B[?25h"; + try + { + WriteConsoleAll(outputHandle, sequence.Buffer(), sequence.Length()); + auto deadline = GetTickCount64() + 250; + vint stableCount = 0; + while (true) + { + CONSOLE_SCREEN_BUFFER_INFO info = {}; + CHECK_ERROR(GetConsoleScreenBufferInfo(outputHandle, &info), L"vl::console::TUI Windows backend failed to wait for terminal restoration."); + auto currentWidth = info.srWindow.Right - info.srWindow.Left + 1; + auto currentHeight = info.srWindow.Bottom - info.srWindow.Top + 1; + if (currentWidth == width && currentHeight == height) + { + if (++stableCount == 10) break; + } + else + { + stableCount = 0; + } + if (GetTickCount64() >= deadline) break; + ::Sleep(1); + } + } + catch (...) + { + restored = false; + } + } + if (classicOutputHandle != INVALID_HANDLE_VALUE) + { + if (!SetConsoleActiveScreenBuffer(originalOutputHandle)) restored = false; + if (!CloseHandle(classicOutputHandle)) restored = false; + classicOutputHandle = INVALID_HANDLE_VALUE; + } + if (geometrySaved) + { + try + { + SetConsoleGeometry(originalOutputHandle, originalGeometry.dwSize, originalGeometry.srWindow); + } + catch (...) + { + restored = false; + } + } + if (cursorInfoSaved && !SetConsoleCursorInfo(originalOutputHandle, &cursorInfo)) restored = false; + if (outputModeChanged && !SetConsoleMode(originalOutputHandle, outputMode)) restored = false; + if (inputModeChanged && !SetConsoleMode(inputHandle, inputMode)) restored = false; + pendingEvents.Clear(); + mouseButtons = 0; + viewportWidth = 0; + viewportHeight = 0; + outputHandle = INVALID_HANDLE_VALUE; + originalOutputHandle = INVALID_HANDLE_VALUE; + inputHandle = INVALID_HANDLE_VALUE; + usingVt = false; + inputModeChanged = false; + outputModeChanged = false; + cursorInfoSaved = false; + geometrySaved = false; + started = false; + CHECK_ERROR(restored, L"vl::console::TUI Windows backend failed to restore the original console state."); + } + + bool TryGetConsoleSize(vint& width, vint& height) override + { + auto handle = outputHandle != INVALID_HANDLE_VALUE ? outputHandle : GetStdHandle(STD_OUTPUT_HANDLE); + CONSOLE_SCREEN_BUFFER_INFO info = {}; + if (handle == INVALID_HANDLE_VALUE || !GetConsoleScreenBufferInfo(handle, &info)) return false; + width = info.srWindow.Right - info.srWindow.Left + 1; + height = info.srWindow.Bottom - info.srWindow.Top + 1; + return width > 0 && height > 0; + } + + vuint64_t GetMonotonicTime() override + { + return GetTickCount64(); + } + + bool ReadEvent(vint milliseconds, unittest::TuiBackendEvent& event) override + { + if (pendingEvents.Count() == 0) + { + SynchronizeViewport(true); + } + if (pendingEvents.Count() == 0) + { + auto result = WaitForSingleObject(inputHandle, milliseconds < 0 ? INFINITE : (DWORD)milliseconds); + if (result == WAIT_TIMEOUT) + { + SynchronizeViewport(true); + if (pendingEvents.Count() == 0) return false; + } + else + { + CHECK_ERROR(result == WAIT_OBJECT_0, L"vl::console::TUI Windows backend failed while waiting for console input."); + INPUT_RECORD records[32]; + DWORD count = 0; + CHECK_ERROR(ReadConsoleInputW(inputHandle, records, sizeof(records) / sizeof(*records), &count), L"vl::console::TUI Windows backend failed to read console input."); + for (DWORD i = 0; i < count; i++) DecodeRecord(records[i]); + SynchronizeViewport(true); + } + } + if (pendingEvents.Count() == 0) return false; + event = pendingEvents[0]; + pendingEvents.RemoveAt(0); + return true; + } + + void Render(const TuiPixel* buffer, vint width, vint height, TuiColorMode colorMode) override + { + if (usingVt) + { + WString output; + TuiColor lastForeground; + TuiColor lastBackground; + bool hasLastColor = false; + for (vint y = 0; y < height; y++) + { + output += L"\x1B[" + itow(y + 1) + L";1H"; + for (vint x = 0; x < width; x++) + { + auto& pixel = buffer[y * width + x]; + if (pixel.glyph == TuiPixelGlyph::WideCharContinuation) continue; + if (!hasLastColor || pixel.foregroundColor != lastForeground || pixel.backgroundColor != lastBackground) + { + AppendColor(output, pixel.foregroundColor, pixel.backgroundColor, colorMode); + lastForeground = pixel.foregroundColor; + lastBackground = pixel.backgroundColor; + hasLastColor = true; + } + auto code = pixel.GetChar32(); + if (code == 0) output += L" "; + else output += u32tow(U32String::CopyFrom(&code, 1)); + } + } + output += L"\x1B[0m"; + WriteConsoleAll(outputHandle, output.Buffer(), output.Length()); + } + else + { + Array output(width * height); + TuiColor custom16[16]; + for (vint i = 0; i < 16; i++) + { + auto color = screenInfo.ColorTable[AnsiToWindowsColor(i)]; + custom16[i] = { GetRValue(color), GetGValue(color), GetBValue(color) }; + } + for (vint i = 0; i < width * height; i++) + { + auto& pixel = buffer[i]; + auto code = pixel.GetChar32(); + auto measured = code == 0 ? 1 : TUI::MeasureChar(code); + output[i].Char.UnicodeChar = code == 0 ? L' ' : measured == 2 || code > 0xFFFF ? L'?' : (wchar_t)code; + if (pixel.glyph == TuiPixelGlyph::WideCharContinuation) output[i].Char.UnicodeChar = L' '; + auto foreground = AnsiToWindowsColor(QuantizeColor(pixel.foregroundColor, TuiColorMode::Color16, custom16)); + auto background = AnsiToWindowsColor(QuantizeColor(pixel.backgroundColor, TuiColorMode::Color16, custom16)); + output[i].Attributes = (WORD)(foreground | (background << 4)); + } + COORD size = { (SHORT)width, (SHORT)height }; + COORD origin = {}; + SMALL_RECT area = { 0, 0, (SHORT)(width - 1), (SHORT)(height - 1) }; + CHECK_ERROR(WriteConsoleOutputW(outputHandle, &output[0], size, origin, &area), L"vl::console::TUI Windows backend failed to render the classic console buffer."); + } + } + }; + + Ptr CreateTuiBackend() + { + return Ptr(new WindowsTuiBackend); + } + } + } +} + diff --git a/Import/VlppOS.cpp b/Import/VlppOS.cpp index 0bd75655..7bbf38a8 100644 --- a/Import/VlppOS.cpp +++ b/Import/VlppOS.cpp @@ -12374,6 +12374,7 @@ namespace vl::inter_process::async_tcp_socket Func claimed; Func completed; Func registered; + Func cancelBeforeResponse; INITIALIZE_GLOBAL_STORAGE_CLASS FINALIZE_GLOBAL_STORAGE_CLASS SPIN_LOCK(lock) @@ -12381,6 +12382,7 @@ namespace vl::inter_process::async_tcp_socket claimed = {}; completed = {}; registered = {}; + cancelBeforeResponse = {}; } END_GLOBAL_STORAGE_CLASS(SocketHttpServerTestHooks) @@ -12408,6 +12410,15 @@ namespace vl::inter_process::async_tcp_socket if (callback) try { callback(token); } catch (...) {} } + bool ShouldCancelPollBeforeResponse(const WString& token) + { + Func callback; + auto& hooks = GetSocketHttpServerTestHooks(); + SPIN_LOCK(hooks.lock) { callback = hooks.cancelBeforeResponse; } + if (callback) try { return callback(token); } catch (...) {} + return false; + } + class SocketHttpServerConnection; class SocketHttpServerLifecycle; class SocketHttpServerOutboundMessage : public Object @@ -12815,6 +12826,7 @@ namespace vl::inter_process::async_tcp_socket { if (!work) return; InvokePollClaimed(state->token); + if (ShouldCancelPollBeforeResponse(state->token)) work.context->Cancel(); bool submitted = false; try { @@ -13477,7 +13489,8 @@ namespace vl::inter_process::async_tcp_socket void SetSocketHttpServerPollCallbacksForTesting( const Func& claimed, const Func& completed, - const Func& registered + const Func& registered, + const Func& cancelBeforeResponse ) { auto& hooks = GetSocketHttpServerTestHooks(); @@ -13486,12 +13499,13 @@ namespace vl::inter_process::async_tcp_socket hooks.claimed = claimed; hooks.completed = completed; hooks.registered = registered; + hooks.cancelBeforeResponse = cancelBeforeResponse; } } void ResetSocketHttpServerPollCallbacksForTesting() { - SetSocketHttpServerPollCallbacksForTesting({}, {}, {}); + SetSocketHttpServerPollCallbacksForTesting({}, {}, {}, {}); } SocketHttpServer::SocketHttpServer(Ptr server, const WString& urlPrefix) @@ -15310,3 +15324,977 @@ namespace vl::inter_process } } + +/*********************************************************************** +.\TUI\TUI.CPP +***********************************************************************/ +/*********************************************************************** +Author: Zihan Chen (vczh) +Licensed under https://github.com/vczh-libraries/License +***********************************************************************/ + +#include + +using namespace vl; +using namespace vl::collections; + +namespace vl +{ + namespace console + { + namespace tui_internal + { + struct ListenerEntry + { + ITuiCallback* listener = nullptr; + vuint64_t generation = 0; + }; + } + + class TUI::Impl + { + public: + bool active = false; + bool stopRequested = false; + bool shuttingDown = false; + bool backendStarted = false; + bool consoleDisabled = false; + vint ownerThreadId = -1; + vint width = 0; + vint height = 0; + vint timerPeriod = 0; + vuint64_t nextTimer = 0; + TuiColorMode colorMode = TuiColorMode::Auto; + Array buffer; + List eventQueue; + Ptr backend; + std::exception_ptr callbackException; + std::exception_ptr* cleanupException = nullptr; + + ~Impl() + { + if (backendStarted) + { + try + { + backend->Stop(); + } + catch (...) + { + if (cleanupException && !*cleanupException) + { + *cleanupException = std::current_exception(); + } + } + } + if (consoleDisabled) + { + Console::Enable(); + } + } + }; + + class TUI::ListenerStorage + { + public: + vuint64_t nextGeneration = 0; + List listeners; + }; + + TUI::Impl* TUI::impl = nullptr; + TUI::ListenerStorage* TUI::listenerStorage = nullptr; + Ptr* TUI::injectedBackend = nullptr; + + namespace tui_internal + { + constexpr vuint8_t PackState(vuint8_t up, vuint8_t down, vuint8_t left, vuint8_t right) + { + return (vuint8_t)((up << 6) | (down << 4) | (left << 2) | right); + } + + char32_t GetMergeableChar(const TuiMergeablePixel& pixel) + { + auto up = (vuint8_t)pixel.up; + auto down = (vuint8_t)pixel.down; + auto left = (vuint8_t)pixel.left; + auto right = (vuint8_t)pixel.right; + if (up > 3 || down > 3 || left > 3 || right > 3) return 0; + + switch (PackState(up, down, left, right)) + { + case PackState(0, 0, 0, 0): return 0; + case PackState(0, 0, 1, 1): return U'\u2500'; + case PackState(0, 0, 2, 2): return U'\u2501'; + case PackState(1, 1, 0, 0): return U'\u2502'; + case PackState(2, 2, 0, 0): return U'\u2503'; + case PackState(0, 1, 0, 1): return U'\u250C'; + case PackState(0, 1, 0, 2): return U'\u250D'; + case PackState(0, 2, 0, 1): return U'\u250E'; + case PackState(0, 2, 0, 2): return U'\u250F'; + case PackState(0, 1, 1, 0): return U'\u2510'; + case PackState(0, 1, 2, 0): return U'\u2511'; + case PackState(0, 2, 1, 0): return U'\u2512'; + case PackState(0, 2, 2, 0): return U'\u2513'; + case PackState(1, 0, 0, 1): return U'\u2514'; + case PackState(1, 0, 0, 2): return U'\u2515'; + case PackState(2, 0, 0, 1): return U'\u2516'; + case PackState(2, 0, 0, 2): return U'\u2517'; + case PackState(1, 0, 1, 0): return U'\u2518'; + case PackState(1, 0, 2, 0): return U'\u2519'; + case PackState(2, 0, 1, 0): return U'\u251A'; + case PackState(2, 0, 2, 0): return U'\u251B'; + case PackState(1, 1, 0, 1): return U'\u251C'; + case PackState(1, 1, 0, 2): return U'\u251D'; + case PackState(2, 1, 0, 1): return U'\u251E'; + case PackState(1, 2, 0, 1): return U'\u251F'; + case PackState(2, 2, 0, 1): return U'\u2520'; + case PackState(2, 1, 0, 2): return U'\u2521'; + case PackState(1, 2, 0, 2): return U'\u2522'; + case PackState(2, 2, 0, 2): return U'\u2523'; + case PackState(1, 1, 1, 0): return U'\u2524'; + case PackState(1, 1, 2, 0): return U'\u2525'; + case PackState(2, 1, 1, 0): return U'\u2526'; + case PackState(1, 2, 1, 0): return U'\u2527'; + case PackState(2, 2, 1, 0): return U'\u2528'; + case PackState(2, 1, 2, 0): return U'\u2529'; + case PackState(1, 2, 2, 0): return U'\u252A'; + case PackState(2, 2, 2, 0): return U'\u252B'; + case PackState(0, 1, 1, 1): return U'\u252C'; + case PackState(0, 1, 2, 1): return U'\u252D'; + case PackState(0, 1, 1, 2): return U'\u252E'; + case PackState(0, 1, 2, 2): return U'\u252F'; + case PackState(0, 2, 1, 1): return U'\u2530'; + case PackState(0, 2, 2, 1): return U'\u2531'; + case PackState(0, 2, 1, 2): return U'\u2532'; + case PackState(0, 2, 2, 2): return U'\u2533'; + case PackState(1, 0, 1, 1): return U'\u2534'; + case PackState(1, 0, 2, 1): return U'\u2535'; + case PackState(1, 0, 1, 2): return U'\u2536'; + case PackState(1, 0, 2, 2): return U'\u2537'; + case PackState(2, 0, 1, 1): return U'\u2538'; + case PackState(2, 0, 2, 1): return U'\u2539'; + case PackState(2, 0, 1, 2): return U'\u253A'; + case PackState(2, 0, 2, 2): return U'\u253B'; + case PackState(1, 1, 1, 1): return U'\u253C'; + case PackState(1, 1, 2, 1): return U'\u253D'; + case PackState(1, 1, 1, 2): return U'\u253E'; + case PackState(1, 1, 2, 2): return U'\u253F'; + case PackState(2, 1, 1, 1): return U'\u2540'; + case PackState(1, 2, 1, 1): return U'\u2541'; + case PackState(2, 2, 1, 1): return U'\u2542'; + case PackState(2, 1, 2, 1): return U'\u2543'; + case PackState(2, 1, 1, 2): return U'\u2544'; + case PackState(1, 2, 2, 1): return U'\u2545'; + case PackState(1, 2, 1, 2): return U'\u2546'; + case PackState(2, 1, 2, 2): return U'\u2547'; + case PackState(1, 2, 2, 2): return U'\u2548'; + case PackState(2, 2, 2, 1): return U'\u2549'; + case PackState(2, 2, 1, 2): return U'\u254A'; + case PackState(2, 2, 2, 2): return U'\u254B'; + case PackState(0, 0, 3, 3): return U'\u2550'; + case PackState(3, 3, 0, 0): return U'\u2551'; + case PackState(0, 1, 0, 3): return U'\u2552'; + case PackState(0, 3, 0, 1): return U'\u2553'; + case PackState(0, 3, 0, 3): return U'\u2554'; + case PackState(0, 1, 3, 0): return U'\u2555'; + case PackState(0, 3, 1, 0): return U'\u2556'; + case PackState(0, 3, 3, 0): return U'\u2557'; + case PackState(1, 0, 0, 3): return U'\u2558'; + case PackState(3, 0, 0, 1): return U'\u2559'; + case PackState(3, 0, 0, 3): return U'\u255A'; + case PackState(1, 0, 3, 0): return U'\u255B'; + case PackState(3, 0, 1, 0): return U'\u255C'; + case PackState(3, 0, 3, 0): return U'\u255D'; + case PackState(1, 1, 0, 3): return U'\u255E'; + case PackState(3, 3, 0, 1): return U'\u255F'; + case PackState(3, 3, 0, 3): return U'\u2560'; + case PackState(1, 1, 3, 0): return U'\u2561'; + case PackState(3, 3, 1, 0): return U'\u2562'; + case PackState(3, 3, 3, 0): return U'\u2563'; + case PackState(0, 1, 3, 3): return U'\u2564'; + case PackState(0, 3, 1, 1): return U'\u2565'; + case PackState(0, 3, 3, 3): return U'\u2566'; + case PackState(1, 0, 3, 3): return U'\u2567'; + case PackState(3, 0, 1, 1): return U'\u2568'; + case PackState(3, 0, 3, 3): return U'\u2569'; + case PackState(1, 1, 3, 3): return U'\u256A'; + case PackState(3, 3, 1, 1): return U'\u256B'; + case PackState(3, 3, 3, 3): return U'\u256C'; + case PackState(0, 0, 1, 0): return U'\u2574'; + case PackState(1, 0, 0, 0): return U'\u2575'; + case PackState(0, 0, 0, 1): return U'\u2576'; + case PackState(0, 1, 0, 0): return U'\u2577'; + case PackState(0, 0, 2, 0): return U'\u2578'; + case PackState(2, 0, 0, 0): return U'\u2579'; + case PackState(0, 0, 0, 2): return U'\u257A'; + case PackState(0, 2, 0, 0): return U'\u257B'; + case PackState(0, 0, 1, 2): return U'\u257C'; + case PackState(1, 2, 0, 0): return U'\u257D'; + case PackState(0, 0, 2, 1): return U'\u257E'; + case PackState(2, 1, 0, 0): return U'\u257F'; + default: return 0; + } + } + + bool IsEmptyMergeable(const TuiMergeablePixel& pixel) + { + return + pixel.up == TuiMergeableGlyph::None && + pixel.down == TuiMergeableGlyph::None && + pixel.left == TuiMergeableGlyph::None && + pixel.right == TuiMergeableGlyph::None; + } + + bool IsLineGlyph(TuiMergeableGlyph glyph) + { + return + glyph == TuiMergeableGlyph::ThinLine || + glyph == TuiMergeableGlyph::ThickLine || + glyph == TuiMergeableGlyph::DoubleLine; + } + + bool IsColorMode(TuiColorMode colorMode, bool allowAuto) + { + return + (allowAuto && colorMode == TuiColorMode::Auto) || + colorMode == TuiColorMode::TrueColor || + colorMode == TuiColorMode::Color256 || + colorMode == TuiColorMode::Color16; + } + + char32_t GetUnmergeableChar(const TuiUnmergeablePixel& pixel) + { + if (pixel.glyph != TuiUnmergeableGlyph::RoundCorner) return 0; + switch (pixel.direction) + { + case TuiUnmergeableDirection::LeftTop: return U'\u256D'; + case TuiUnmergeableDirection::RightTop: return U'\u256E'; + case TuiUnmergeableDirection::LeftBottom: return U'\u2570'; + case TuiUnmergeableDirection::RightBottom: return U'\u256F'; + default: return 0; + } + } + + bool IsScalar(char32_t code) + { + return code <= 0x10FFFF && !(code >= 0xD800 && code <= 0xDFFF); + } + + TuiPixel EmptyPixel(TuiColor background = { 0, 0, 0 }) + { + TuiPixel pixel; + pixel.backgroundColor = background; + return pixel; + } + + void CheckBuffer(TuiPixel* buffer, vint width, vint height) + { + CHECK_ERROR(buffer != nullptr, L"vl::console::TUI drawing helper requires a non-null buffer."); + CHECK_ERROR(width >= 0 && height >= 0, L"vl::console::TUI drawing helper requires non-negative dimensions."); + } + + void RepairWide(TuiPixel* buffer, vint width, vint height, vint x, vint y) + { + if (x < 0 || x >= width || y < 0 || y >= height) return; + auto index = y * width + x; + auto& pixel = buffer[index]; + if (pixel.glyph == TuiPixelGlyph::WideCharContinuation) + { + pixel = EmptyPixel(pixel.backgroundColor); + if (x > 0) + { + auto& leading = buffer[index - 1]; + if (leading.glyph == TuiPixelGlyph::Char && TUI::MeasureChar(leading.c) == 2) + { + leading = EmptyPixel(leading.backgroundColor); + } + } + } + else if (pixel.glyph == TuiPixelGlyph::Char && TUI::MeasureChar(pixel.c) == 2) + { + pixel = EmptyPixel(pixel.backgroundColor); + if (x + 1 < width && buffer[index + 1].glyph == TuiPixelGlyph::WideCharContinuation) + { + auto background = buffer[index + 1].backgroundColor; + buffer[index + 1] = EmptyPixel(background); + } + } + } + + void PlaceMergeable(TuiPixel* buffer, vint width, vint height, vint x, vint y, const TuiMergeablePixel& drawing, TuiColor foreground, Nullable background) + { + if (x < 0 || x >= width || y < 0 || y >= height) return; + RepairWide(buffer, width, height, x, y); + auto& pixel = buffer[y * width + x]; + auto candidate = drawing; + if (pixel.glyph == TuiPixelGlyph::Mergeable) + { + candidate = pixel.mergeable; + if (drawing.up != TuiMergeableGlyph::None) candidate.up = drawing.up; + if (drawing.down != TuiMergeableGlyph::None) candidate.down = drawing.down; + if (drawing.left != TuiMergeableGlyph::None) candidate.left = drawing.left; + if (drawing.right != TuiMergeableGlyph::None) candidate.right = drawing.right; + if (GetMergeableChar(candidate) == 0) + { + candidate = drawing; + } + } + pixel.glyph = TuiPixelGlyph::Mergeable; + pixel.mergeable = candidate; + pixel.foregroundColor = foreground; + if (background) pixel.backgroundColor = background.Value(); + } + + void PlaceUnmergeable(TuiPixel* buffer, vint width, vint height, vint x, vint y, TuiUnmergeableDirection direction, TuiColor foreground, Nullable background) + { + if (x < 0 || x >= width || y < 0 || y >= height) return; + RepairWide(buffer, width, height, x, y); + auto& pixel = buffer[y * width + x]; + pixel.glyph = TuiPixelGlyph::Unmergeable; + pixel.unmergeable = { TuiUnmergeableGlyph::RoundCorner, direction }; + pixel.foregroundColor = foreground; + if (background) pixel.backgroundColor = background.Value(); + } + + void CheckOwner(auto& storage) + { + CHECK_ERROR(storage.active, L"vl::console::TUI operation requires an active TUI."); + CHECK_ERROR(storage.ownerThreadId == Thread::GetCurrentThreadId(), L"vl::console::TUI operation must run on the owner thread."); + } + + vint FindListener(auto* storage, ITuiCallback* listener, vuint64_t generation = 0) + { + if (!storage) return -1; + for (vint i = 0; i < storage->listeners.Count(); i++) + { + auto entry = storage->listeners[i]; + if (entry.listener == listener && (generation == 0 || entry.generation == generation)) return i; + } + return -1; + } + + template + void InvokeListeners(auto& storage, auto* listenerStorage, TCallback&& callback, bool stopping = false) + { + if (!listenerStorage) return; + List snapshot; + for (auto entry : listenerStorage->listeners) snapshot.Add(entry); + for (auto entry : snapshot) + { + if (!stopping && storage.stopRequested) break; + if (FindListener(listenerStorage, entry.listener, entry.generation) == -1) continue; + try + { + callback(entry.listener); + } + catch (...) + { + if (!storage.callbackException) storage.callbackException = std::current_exception(); + storage.stopRequested = true; + throw; + } + if (storage.callbackException) std::rethrow_exception(storage.callbackException); + } + } + + void ResizeBuffer(auto& storage, vint width, vint height) + { + CHECK_ERROR(width > 0 && height > 0, L"vl::console::TUI backend returned an invalid terminal size."); + Array newBuffer(width * height); + auto copyWidth = width < storage.width ? width : storage.width; + auto copyHeight = height < storage.height ? height : storage.height; + for (vint y = 0; y < copyHeight; y++) + { + for (vint x = 0; x < copyWidth; x++) + { + newBuffer[y * width + x] = storage.buffer[y * storage.width + x]; + } + } + for (vint y = 0; y < height; y++) + { + for (vint x = 0; x < width; x++) + { + auto index = y * width + x; + auto& pixel = newBuffer[index]; + if (pixel.glyph == TuiPixelGlyph::WideCharContinuation) + { + if (x == 0 || newBuffer[index - 1].glyph != TuiPixelGlyph::Char || TUI::MeasureChar(newBuffer[index - 1].c) != 2) + { + pixel = EmptyPixel(pixel.backgroundColor); + } + } + else if (pixel.glyph == TuiPixelGlyph::Char && TUI::MeasureChar(pixel.c) == 2) + { + if (x + 1 >= width || newBuffer[index + 1].glyph != TuiPixelGlyph::WideCharContinuation) + { + pixel = EmptyPixel(pixel.backgroundColor); + if (x + 1 < width && newBuffer[index + 1].glyph == TuiPixelGlyph::WideCharContinuation) + { + auto background = newBuffer[index + 1].backgroundColor; + newBuffer[index + 1] = EmptyPixel(background); + } + } + } + } + } + storage.buffer = std::move(newBuffer); + storage.width = width; + storage.height = height; + } + + void DispatchEvent(auto& storage, auto* listenerStorage, const unittest::TuiBackendEvent& event) + { + if (storage.stopRequested) return; + switch (event.type) + { + case unittest::TuiBackendEventType::Resize: + if (event.width != storage.width || event.height != storage.height) + { + ResizeBuffer(storage, event.width, event.height); + InvokeListeners(storage, listenerStorage, [](ITuiCallback* listener) { listener->BufferSizeChanged(); }); + } + break; + case unittest::TuiBackendEventType::MouseMove: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseMove(event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::MouseDown: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseDown(event.mouseButton, event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::MouseUp: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseUp(event.mouseButton, event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::MouseDoubleClick: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseDoubleClick(event.mouseButton, event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::MouseVerticalWheel: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseVerticalWheel(event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::MouseHorizontalWheel: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->MouseHorizontalWheel(event.mouseInfo); }); + break; + case unittest::TuiBackendEventType::KeyDown: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->KeyDown(event.keyInfo); }); + break; + case unittest::TuiBackendEventType::KeyUp: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->KeyUp(event.keyInfo); }); + break; + case unittest::TuiBackendEventType::Char: + InvokeListeners(storage, listenerStorage, [&](ITuiCallback* listener) { listener->Char(event.charInfo); }); + break; + default: + break; + } + } + + const TuiColor canonicalColor16[] = + { + { 0x00, 0x00, 0x00 }, { 0x80, 0x00, 0x00 }, { 0x00, 0x80, 0x00 }, { 0x80, 0x80, 0x00 }, + { 0x00, 0x00, 0x80 }, { 0x80, 0x00, 0x80 }, { 0x00, 0x80, 0x80 }, { 0xC0, 0xC0, 0xC0 }, + { 0x80, 0x80, 0x80 }, { 0xFF, 0x00, 0x00 }, { 0x00, 0xFF, 0x00 }, { 0xFF, 0xFF, 0x00 }, + { 0x00, 0x00, 0xFF }, { 0xFF, 0x00, 0xFF }, { 0x00, 0xFF, 0xFF }, { 0xFF, 0xFF, 0xFF }, + }; + + TuiColor GetCanonicalColor(vint index) + { + CHECK_ERROR(index >= 0 && index < 256, L"vl::console::tui_internal::GetCanonicalColor(vint)#Index out of range."); + if (index < 16) return canonicalColor16[index]; + if (index < 232) + { + auto value = index - 16; + vuint8_t levels[] = { 0, 95, 135, 175, 215, 255 }; + return { levels[value / 36], levels[(value / 6) % 6], levels[value % 6] }; + } + auto level = (vuint8_t)(8 + 10 * (index - 232)); + return { level, level, level }; + } + + vint QuantizeColor(TuiColor color, TuiColorMode colorMode, const TuiColor* customColor16) + { + auto count = colorMode == TuiColorMode::Color16 ? 16 : 256; + vint best = 0; + vint bestDistance = -1; + for (vint i = 0; i < count; i++) + { + auto candidate = customColor16 && i < 16 ? customColor16[i] : GetCanonicalColor(i); + auto dr = (vint)color.r - candidate.r; + auto dg = (vint)color.g - candidate.g; + auto db = (vint)color.b - candidate.b; + auto distance = dr * dr + dg * dg + db * db; + if (bestDistance == -1 || distance < bestDistance) + { + best = i; + bestDistance = distance; + } + } + return best; + } + } + + using namespace tui_internal; + +/*********************************************************************** +TuiPixel +***********************************************************************/ + + char32_t TuiPixel::GetChar32() const + { + switch (glyph) + { + case TuiPixelGlyph::Char: + return c; + case TuiPixelGlyph::Mergeable: + return GetMergeableChar(mergeable); + case TuiPixelGlyph::Unmergeable: + return GetUnmergeableChar(unmergeable); + default: + return 0; + } + } + + wchar_t TuiPixel::GetWChar() const + { + auto code = GetChar32(); + if (!IsScalar(code) || code == 0) return 0; + if constexpr (sizeof(wchar_t) == 2) + { + return code <= 0xFFFF ? (wchar_t)code : 0; + } + else + { + return (wchar_t)code; + } + } + +/*********************************************************************** +ITuiCallback +***********************************************************************/ + + void ITuiCallback::Starting() {} + void ITuiCallback::Stopping() {} + void ITuiCallback::BufferSizeChanged() {} + void ITuiCallback::MouseMove(const TuiMouseInfo&) {} + void ITuiCallback::MouseDown(TuiMouseButton, const TuiMouseInfo&) {} + void ITuiCallback::MouseUp(TuiMouseButton, const TuiMouseInfo&) {} + void ITuiCallback::MouseDoubleClick(TuiMouseButton, const TuiMouseInfo&) {} + void ITuiCallback::MouseVerticalWheel(const TuiMouseInfo&) {} + void ITuiCallback::MouseHorizontalWheel(const TuiMouseInfo&) {} + void ITuiCallback::KeyDown(const TuiKeyInfo&) {} + void ITuiCallback::KeyUp(const TuiKeyInfo&) {} + void ITuiCallback::Char(const TuiCharInfo&) {} + void ITuiCallback::Timer() {} + +/*********************************************************************** +TUI +***********************************************************************/ + + TUI::Impl& TUI::GetImpl() + { + CHECK_ERROR(impl, L"vl::console::TUI operation requires an active TUI."); + CheckOwner(*impl); + return *impl; + } + + bool TUI::TryGetConsoleSize(vint& width, vint& height) + { + if (impl && impl->active) + { + CheckOwner(*impl); + return impl->backend->TryGetConsoleSize(width, height); + } + if (injectedBackend) + { + return (*injectedBackend)->TryGetConsoleSize(width, height); + } + auto backend = CreateTuiBackend(); + return backend->TryGetConsoleSize(width, height); + } + + void TUI::Start(const TuiStartOptions& options) + { + if (impl) + { + if (impl->active) CheckOwner(*impl); + return; + } + CHECK_ERROR(IsColorMode(options.colorMode, true), L"vl::console::TUI::Start(const TuiStartOptions&)#The requested color mode is invalid."); + CHECK_ERROR(Console::IsEnabled(), L"vl::console::TUI::Start(const TuiStartOptions&)#Console must be enabled before TUI starts."); + + auto storage = new Impl; + impl = storage; + std::exception_ptr thrown; + try + { + storage->backend = injectedBackend ? *injectedBackend : CreateTuiBackend(); + storage->colorMode = storage->backend->Start(options); + storage->backendStarted = true; + CHECK_ERROR(IsColorMode(storage->colorMode, false), L"vl::console::TUI::Start(const TuiStartOptions&)#The backend selected an invalid color mode."); + + vint width = 0; + vint height = 0; + CHECK_ERROR(storage->backend->TryGetConsoleSize(width, height), L"vl::console::TUI::Start(const TuiStartOptions&)#Failed to query the terminal size."); + Console::Disable(); + storage->consoleDisabled = true; + ResizeBuffer(*storage, width, height); + storage->ownerThreadId = Thread::GetCurrentThreadId(); + storage->active = true; + + InvokeListeners(*storage, listenerStorage, [](ITuiCallback* listener) { listener->Starting(); }); + if (!storage->stopRequested) + { + InvokeListeners(*storage, listenerStorage, [](ITuiCallback* listener) { listener->BufferSizeChanged(); }); + } + while (!storage->stopRequested) + { + if (!RunOneCycle()) break; + } + if (!storage->callbackException) + { + storage->shuttingDown = true; + InvokeListeners(*storage, listenerStorage, [](ITuiCallback* listener) { listener->Stopping(); }, true); + } + } + catch (...) + { + thrown = std::current_exception(); + } + storage->cleanupException = &thrown; + impl = nullptr; + delete storage; + if (listenerStorage && listenerStorage->listeners.Count() == 0) + { + delete listenerStorage; + listenerStorage = nullptr; + } + if (thrown) std::rethrow_exception(thrown); + } + + bool TUI::RunOneCycle() + { + auto& storage = GetImpl(); + if (storage.callbackException) std::rethrow_exception(storage.callbackException); + if (storage.stopRequested) return false; + + if (storage.eventQueue.Count() == 0) + { + auto now = storage.backend->GetMonotonicTime(); + vint timeout = -1; + if (storage.timerPeriod > 0) + { + if (now >= storage.nextTimer) + { + storage.nextTimer += storage.timerPeriod; + InvokeListeners(storage, listenerStorage, [](ITuiCallback* listener) { listener->Timer(); }); + return !storage.stopRequested; + } + auto remaining = storage.nextTimer - now; + timeout = remaining > (vuint64_t)0x7FFFFFFF ? 0x7FFFFFFF : (vint)remaining; + } + unittest::TuiBackendEvent event; + if (storage.backend->ReadEvent(timeout, event)) + { + storage.eventQueue.Add(event); + } + else if (storage.timerPeriod > 0 && storage.backend->GetMonotonicTime() >= storage.nextTimer) + { + storage.nextTimer += storage.timerPeriod; + InvokeListeners(storage, listenerStorage, [](ITuiCallback* listener) { listener->Timer(); }); + return !storage.stopRequested; + } + } + + if (storage.eventQueue.Count() > 0) + { + auto event = storage.eventQueue[0]; + storage.eventQueue.RemoveAt(0); + DispatchEvent(storage, listenerStorage, event); + } + if (storage.callbackException) std::rethrow_exception(storage.callbackException); + return !storage.stopRequested; + } + + void TUI::Stop() + { + if (!impl || !impl->active) return; + auto& storage = *impl; + CheckOwner(storage); + if (storage.shuttingDown) return; + storage.stopRequested = true; + } + + bool TUI::IsInUse() + { + if (!impl || !impl->active) return false; + CheckOwner(*impl); + return true; + } + + bool TUI::IsStopRequested() + { + if (!impl || !impl->active) return false; + auto& storage = *impl; + CheckOwner(storage); + return storage.stopRequested; + } + + TuiColorMode TUI::GetColorMode() + { + auto& storage = GetImpl(); + return storage.colorMode; + } + + bool TUI::InstallListener(ITuiCallback* listener) + { + if (impl && impl->active) CheckOwner(*impl); + if (!listener || FindListener(listenerStorage, listener) != -1) return false; + if (!listenerStorage) listenerStorage = new ListenerStorage; + listenerStorage->listeners.Add({ listener, ++listenerStorage->nextGeneration }); + return true; + } + + bool TUI::UninstallListener(ITuiCallback* listener) + { + if (impl && impl->active) CheckOwner(*impl); + if (!listener) return false; + auto index = FindListener(listenerStorage, listener); + if (index == -1) return false; + listenerStorage->listeners.RemoveAt(index); + if (!impl && listenerStorage->listeners.Count() == 0) + { + delete listenerStorage; + listenerStorage = nullptr; + } + return true; + } + + void TUI::StartTimer(vint milliseconds) + { + auto& storage = GetImpl(); + CHECK_ERROR(milliseconds > 0, L"vl::console::TUI::StartTimer(vint)#The timer period must be positive."); + storage.timerPeriod = milliseconds; + storage.nextTimer = storage.backend->GetMonotonicTime() + milliseconds; + } + + void TUI::StopTimer() + { + auto& storage = GetImpl(); + storage.timerPeriod = 0; + storage.nextTimer = 0; + } + + TuiPixel* TUI::GetBuffer() + { + auto& storage = GetImpl(); + return storage.buffer.Count() == 0 ? nullptr : &storage.buffer[0]; + } + + vint TUI::GetBufferWidth() + { + auto& storage = GetImpl(); + return storage.width; + } + + vint TUI::GetBufferHeight() + { + auto& storage = GetImpl(); + return storage.height; + } + + void TUI::RenderBuffer() + { + auto& storage = GetImpl(); + for (vint y = 0; y < storage.height; y++) + { + for (vint x = 0; x < storage.width; x++) + { + auto index = y * storage.width + x; + auto& pixel = storage.buffer[index]; + switch (pixel.glyph) + { + case TuiPixelGlyph::Char: + if (pixel.c != 0) + { + CHECK_ERROR(IsScalar(pixel.c), L"vl::console::TUI::RenderBuffer()#A Char cell contains an invalid Unicode scalar."); + auto width = MeasureChar(pixel.c); + CHECK_ERROR(width == 1 || width == 2, L"vl::console::TUI::RenderBuffer()#A Char cell contains a zero-width or non-printable scalar."); + if (width == 2) + { + CHECK_ERROR(x + 1 < storage.width && storage.buffer[index + 1].glyph == TuiPixelGlyph::WideCharContinuation, L"vl::console::TUI::RenderBuffer()#A width-two Char cell has no continuation."); + CHECK_ERROR(storage.buffer[index + 1].foregroundColor == pixel.foregroundColor && storage.buffer[index + 1].backgroundColor == pixel.backgroundColor, L"vl::console::TUI::RenderBuffer()#A width-two continuation has different colors."); + } + } + break; + case TuiPixelGlyph::Mergeable: + CHECK_ERROR(GetMergeableChar(pixel.mergeable) != 0 || IsEmptyMergeable(pixel.mergeable), L"vl::console::TUI::RenderBuffer()#A Mergeable cell contains an unsupported arm state."); + break; + case TuiPixelGlyph::Unmergeable: + CHECK_ERROR(GetUnmergeableChar(pixel.unmergeable) != 0, L"vl::console::TUI::RenderBuffer()#An Unmergeable cell contains an invalid glyph."); + break; + case TuiPixelGlyph::WideCharContinuation: + CHECK_ERROR(x > 0 && storage.buffer[index - 1].glyph == TuiPixelGlyph::Char && MeasureChar(storage.buffer[index - 1].c) == 2, L"vl::console::TUI::RenderBuffer()#A continuation cell has no width-two leading cell."); + break; + default: + CHECK_FAIL(L"vl::console::TUI::RenderBuffer()#A cell contains an invalid glyph type."); + } + } + } + storage.backend->Render(&storage.buffer[0], storage.width, storage.height, storage.colorMode); + } + + void TUI::PrintChar(const TuiPrintOptions& options, char32_t code, vint x, vint y) + { + PrintChar(GetBuffer(), GetBufferWidth(), GetBufferHeight(), options, code, x, y); + } + + void TUI::DrawLineV(const TuiLineOptions& options, vint x, vint y1, vint y2) + { + DrawLineV(GetBuffer(), GetBufferWidth(), GetBufferHeight(), options, x, y1, y2); + } + + void TUI::DrawLineH(const TuiLineOptions& options, vint x1, vint x2, vint y) + { + DrawLineH(GetBuffer(), GetBufferWidth(), GetBufferHeight(), options, x1, x2, y); + } + + void TUI::DrawRect(const TuiRectOptions& options, vint x1, vint y1, vint x2, vint y2) + { + DrawRect(GetBuffer(), GetBufferWidth(), GetBufferHeight(), options, x1, y1, x2, y2); + } + + void TUI::Clear(TuiColor backgroundColor, vint x1, vint y1, vint x2, vint y2) + { + Clear(GetBuffer(), GetBufferWidth(), GetBufferHeight(), backgroundColor, x1, y1, x2, y2); + } + + void TUI::PrintChar(TuiPixel* buffer, vint width, vint height, const TuiPrintOptions& options, char32_t code, vint x, vint y) + { + CheckBuffer(buffer, width, height); + CHECK_ERROR(IsScalar(code), L"vl::console::TUI::PrintChar(...)#The character must be a Unicode scalar."); + auto charWidth = MeasureChar(code); + if (charWidth == 0 || x < 0 || x >= width || y < 0 || y >= height) return; + if (charWidth == 2 && x + 1 >= width) return; + RepairWide(buffer, width, height, x, y); + if (charWidth == 2) RepairWide(buffer, width, height, x + 1, y); + auto& leading = buffer[y * width + x]; + leading.glyph = TuiPixelGlyph::Char; + leading.c = code; + leading.foregroundColor = options.foregroundColor; + leading.backgroundColor = options.backgroundColor; + if (charWidth == 2) + { + auto& continuation = buffer[y * width + x + 1]; + continuation.glyph = TuiPixelGlyph::WideCharContinuation; + continuation.c = 0; + continuation.foregroundColor = options.foregroundColor; + continuation.backgroundColor = options.backgroundColor; + } + } + + void TUI::DrawLineV(TuiPixel* buffer, vint width, vint height, const TuiLineOptions& options, vint x, vint y1, vint y2) + { + CheckBuffer(buffer, width, height); + CHECK_ERROR(y1 <= y2, L"vl::console::TUI::DrawLineV(...)#The ordered range is invalid."); + CHECK_ERROR(IsLineGlyph(options.glyph), L"vl::console::TUI::DrawLineV(...)#The line style is invalid."); + if (x < 0 || x >= width || y2 < 0 || y1 >= height) return; + auto begin = y1 < 0 ? 0 : y1; + auto end = y2 >= height ? height - 1 : y2; + TuiMergeablePixel drawing = { options.glyph, options.glyph, TuiMergeableGlyph::None, TuiMergeableGlyph::None }; + for (vint y = begin; y <= end; y++) PlaceMergeable(buffer, width, height, x, y, drawing, options.foregroundColor, options.backgroundColor); + } + + void TUI::DrawLineH(TuiPixel* buffer, vint width, vint height, const TuiLineOptions& options, vint x1, vint x2, vint y) + { + CheckBuffer(buffer, width, height); + CHECK_ERROR(x1 <= x2, L"vl::console::TUI::DrawLineH(...)#The ordered range is invalid."); + CHECK_ERROR(IsLineGlyph(options.glyph), L"vl::console::TUI::DrawLineH(...)#The line style is invalid."); + if (y < 0 || y >= height || x2 < 0 || x1 >= width) return; + auto begin = x1 < 0 ? 0 : x1; + auto end = x2 >= width ? width - 1 : x2; + TuiMergeablePixel drawing = { TuiMergeableGlyph::None, TuiMergeableGlyph::None, options.glyph, options.glyph }; + for (vint x = begin; x <= end; x++) PlaceMergeable(buffer, width, height, x, y, drawing, options.foregroundColor, options.backgroundColor); + } + + void TUI::DrawRect(TuiPixel* buffer, vint width, vint height, const TuiRectOptions& options, vint x1, vint y1, vint x2, vint y2) + { + CheckBuffer(buffer, width, height); + CHECK_ERROR(x1 < x2 && y1 < y2, L"vl::console::TUI::DrawRect(...)#A rectangle must have distinct corners."); + CHECK_ERROR(IsLineGlyph(options.glyph), L"vl::console::TUI::DrawRect(...)#The line style is invalid."); + CHECK_ERROR(options.corner == TuiRectCorner::Sharp || options.corner == TuiRectCorner::Round, L"vl::console::TUI::DrawRect(...)#The corner style is invalid."); + CHECK_ERROR(options.corner == TuiRectCorner::Sharp || options.glyph == TuiMergeableGlyph::ThinLine, L"vl::console::TUI::DrawRect(...)#Rounded corners require a thin line."); + if (x2 < 0 || y2 < 0 || x1 >= width || y1 >= height) return; + + TuiMergeablePixel horizontal = { TuiMergeableGlyph::None, TuiMergeableGlyph::None, options.glyph, options.glyph }; + TuiMergeablePixel vertical = { options.glyph, options.glyph, TuiMergeableGlyph::None, TuiMergeableGlyph::None }; + auto left = x1 + 1 < 0 ? 0 : x1 + 1; + auto right = x2 - 1 >= width ? width - 1 : x2 - 1; + for (vint x = left; x <= right; x++) + { + PlaceMergeable(buffer, width, height, x, y1, horizontal, options.foregroundColor, options.backgroundColor); + PlaceMergeable(buffer, width, height, x, y2, horizontal, options.foregroundColor, options.backgroundColor); + } + auto top = y1 + 1 < 0 ? 0 : y1 + 1; + auto bottom = y2 - 1 >= height ? height - 1 : y2 - 1; + for (vint y = top; y <= bottom; y++) + { + PlaceMergeable(buffer, width, height, x1, y, vertical, options.foregroundColor, options.backgroundColor); + PlaceMergeable(buffer, width, height, x2, y, vertical, options.foregroundColor, options.backgroundColor); + } + + if (options.corner == TuiRectCorner::Round) + { + PlaceUnmergeable(buffer, width, height, x1, y1, TuiUnmergeableDirection::LeftTop, options.foregroundColor, options.backgroundColor); + PlaceUnmergeable(buffer, width, height, x2, y1, TuiUnmergeableDirection::RightTop, options.foregroundColor, options.backgroundColor); + PlaceUnmergeable(buffer, width, height, x1, y2, TuiUnmergeableDirection::LeftBottom, options.foregroundColor, options.backgroundColor); + PlaceUnmergeable(buffer, width, height, x2, y2, TuiUnmergeableDirection::RightBottom, options.foregroundColor, options.backgroundColor); + } + else + { + PlaceMergeable(buffer, width, height, x1, y1, { TuiMergeableGlyph::None, options.glyph, TuiMergeableGlyph::None, options.glyph }, options.foregroundColor, options.backgroundColor); + PlaceMergeable(buffer, width, height, x2, y1, { TuiMergeableGlyph::None, options.glyph, options.glyph, TuiMergeableGlyph::None }, options.foregroundColor, options.backgroundColor); + PlaceMergeable(buffer, width, height, x1, y2, { options.glyph, TuiMergeableGlyph::None, TuiMergeableGlyph::None, options.glyph }, options.foregroundColor, options.backgroundColor); + PlaceMergeable(buffer, width, height, x2, y2, { options.glyph, TuiMergeableGlyph::None, options.glyph, TuiMergeableGlyph::None }, options.foregroundColor, options.backgroundColor); + } + } + + void TUI::Clear(TuiPixel* buffer, vint width, vint height, TuiColor backgroundColor, vint x1, vint y1, vint x2, vint y2) + { + CheckBuffer(buffer, width, height); + CHECK_ERROR(x1 <= x2 && y1 <= y2, L"vl::console::TUI::Clear(...)#The ordered rectangle is invalid."); + if (x2 < 0 || y2 < 0 || x1 >= width || y1 >= height) return; + auto left = x1 < 0 ? 0 : x1; + auto top = y1 < 0 ? 0 : y1; + auto right = x2 >= width ? width - 1 : x2; + auto bottom = y2 >= height ? height - 1 : y2; + for (vint y = top; y <= bottom; y++) + { + for (vint x = left; x <= right; x++) + { + RepairWide(buffer, width, height, x, y); + buffer[y * width + x] = EmptyPixel(backgroundColor); + } + } + } + +/*********************************************************************** +ScopedTuiBackend +***********************************************************************/ + + namespace unittest + { + ScopedTuiBackend::ScopedTuiBackend(Ptr backend) + { + CHECK_ERROR(!TUI::impl, L"vl::console::unittest::ScopedTuiBackend::ScopedTuiBackend(...)#Cannot replace the backend while TUI is active."); + CHECK_ERROR(backend, L"vl::console::unittest::ScopedTuiBackend::ScopedTuiBackend(...)#The backend cannot be null."); + previous = TUI::injectedBackend; + current = backend; + TUI::injectedBackend = ¤t; + } + + ScopedTuiBackend::~ScopedTuiBackend() noexcept(false) + { + CHECK_ERROR(!TUI::impl, L"vl::console::unittest::ScopedTuiBackend::~ScopedTuiBackend()#Cannot restore the backend while TUI is active."); + TUI::injectedBackend = previous; + } + } + } +} + diff --git a/Import/VlppOS.h b/Import/VlppOS.h index 6510ea60..bc9bd038 100644 --- a/Import/VlppOS.h +++ b/Import/VlppOS.h @@ -7179,3 +7179,322 @@ namespace vl::inter_process::async_tcp_socket #endif + +/*********************************************************************** +.\TUI\TUI.H +***********************************************************************/ +/*********************************************************************** +Author: Zihan Chen (vczh) +Licensed under https://github.com/vczh-libraries/License +***********************************************************************/ + +#ifndef VCZH_TUI +#define VCZH_TUI + + +namespace vl +{ + namespace console + { + enum class TuiColorMode + { + Auto, + TrueColor, + Color256, + Color16, + }; + + struct TuiStartOptions + { + TuiColorMode colorMode = TuiColorMode::Auto; + }; + + struct TuiColor + { + vuint8_t r = 0; + vuint8_t g = 0; + vuint8_t b = 0; + + auto operator<=>(const TuiColor&) const = default; + }; + + enum class TuiMergeableGlyph : vuint8_t + { + None = 0, + ThinLine = 1, + ThickLine = 2, + DoubleLine = 3, + }; + + struct TuiMergeablePixel + { + TuiMergeableGlyph up = TuiMergeableGlyph::None; + TuiMergeableGlyph down = TuiMergeableGlyph::None; + TuiMergeableGlyph left = TuiMergeableGlyph::None; + TuiMergeableGlyph right = TuiMergeableGlyph::None; + }; + + enum class TuiUnmergeableGlyph : vuint8_t + { + RoundCorner, + }; + + enum class TuiUnmergeableDirection : vuint8_t + { + LeftTop, + RightTop, + LeftBottom, + RightBottom, + }; + + struct TuiUnmergeablePixel + { + TuiUnmergeableGlyph glyph = TuiUnmergeableGlyph::RoundCorner; + TuiUnmergeableDirection direction = TuiUnmergeableDirection::LeftTop; + }; + + enum class TuiPixelGlyph : vuint8_t + { + Char, + Mergeable, + Unmergeable, + WideCharContinuation, + }; + + struct TuiPixel + { + TuiPixelGlyph glyph = TuiPixelGlyph::Char; + union + { + char32_t c = 0; + TuiMergeablePixel mergeable; + TuiUnmergeablePixel unmergeable; + }; + TuiColor foregroundColor = { 255, 255, 255 }; + TuiColor backgroundColor = { 0, 0, 0 }; + + char32_t GetChar32() const; + wchar_t GetWChar() const; + }; + + struct TuiMouseInfo + { + vint x = 0; + vint y = 0; + vint wheel = 0; + bool ctrl = false; + bool shift = false; + bool alt = false; + bool left = false; + bool middle = false; + bool right = false; + }; + + enum class TuiMouseButton + { + Left, + Middle, + Right, + }; + + struct TuiKeyInfo + { + vint code = 0; + bool ctrl = false; + bool shift = false; + bool alt = false; + bool capslock = false; + bool autoRepeatKeyDown = false; + }; + + struct TuiCharInfo + { + wchar_t code = 0; + bool ctrl = false; + bool shift = false; + bool alt = false; + bool capslock = false; + }; + + class ITuiCallback : public Interface + { + public: + virtual void Starting(); + virtual void Stopping(); + virtual void BufferSizeChanged(); + virtual void MouseMove(const TuiMouseInfo& info); + virtual void MouseDown(TuiMouseButton button, const TuiMouseInfo& info); + virtual void MouseUp(TuiMouseButton button, const TuiMouseInfo& info); + virtual void MouseDoubleClick(TuiMouseButton button, const TuiMouseInfo& info); + virtual void MouseVerticalWheel(const TuiMouseInfo& info); + virtual void MouseHorizontalWheel(const TuiMouseInfo& info); + virtual void KeyDown(const TuiKeyInfo& info); + virtual void KeyUp(const TuiKeyInfo& info); + virtual void Char(const TuiCharInfo& info); + virtual void Timer(); + }; + + struct TuiPrintOptions + { + TuiColor foregroundColor = { 255, 255, 255 }; + TuiColor backgroundColor = { 0, 0, 0 }; + }; + + struct TuiLineOptions + { + TuiMergeableGlyph glyph = TuiMergeableGlyph::ThinLine; + TuiColor foregroundColor = { 255, 255, 255 }; + Nullable backgroundColor; + }; + + enum class TuiRectCorner + { + Sharp, + Round, + }; + + struct TuiRectOptions + { + TuiMergeableGlyph glyph = TuiMergeableGlyph::ThinLine; + TuiColor foregroundColor = { 255, 255, 255 }; + Nullable backgroundColor; + TuiRectCorner corner = TuiRectCorner::Sharp; + }; + + namespace unittest + { + class ITuiBackend; + class ScopedTuiBackend; + } + + class TUI abstract + { + friend class unittest::ScopedTuiBackend; + + private: + class Impl; + class ListenerStorage; + + static Impl* impl; + static ListenerStorage* listenerStorage; + static Ptr* injectedBackend; + static Impl& GetImpl(); + + public: + static bool TryGetConsoleSize(vint& width, vint& height); + static void Start(const TuiStartOptions& options); + static bool RunOneCycle(); + static void Stop(); + static bool IsInUse(); + static bool IsStopRequested(); + static TuiColorMode GetColorMode(); + + static bool InstallListener(ITuiCallback* listener); + static bool UninstallListener(ITuiCallback* listener); + static void StartTimer(vint milliseconds); + static void StopTimer(); + + static TuiPixel* GetBuffer(); + static vint GetBufferWidth(); + static vint GetBufferHeight(); + static vint MeasureChar(char32_t code); + static void RenderBuffer(); + + static void PrintChar(const TuiPrintOptions& options, char32_t code, vint x, vint y); + static void DrawLineV(const TuiLineOptions& options, vint x, vint y1, vint y2); + static void DrawLineH(const TuiLineOptions& options, vint x1, vint x2, vint y); + static void DrawRect(const TuiRectOptions& options, vint x1, vint y1, vint x2, vint y2); + static void Clear(TuiColor backgroundColor, vint x1, vint y1, vint x2, vint y2); + + static void PrintChar(TuiPixel* buffer, vint width, vint height, const TuiPrintOptions& options, char32_t code, vint x, vint y); + static void DrawLineV(TuiPixel* buffer, vint width, vint height, const TuiLineOptions& options, vint x, vint y1, vint y2); + static void DrawLineH(TuiPixel* buffer, vint width, vint height, const TuiLineOptions& options, vint x1, vint x2, vint y); + static void DrawRect(TuiPixel* buffer, vint width, vint height, const TuiRectOptions& options, vint x1, vint y1, vint x2, vint y2); + static void Clear(TuiPixel* buffer, vint width, vint height, TuiColor backgroundColor, vint x1, vint y1, vint x2, vint y2); + }; + + namespace unittest + { + enum class TuiBackendEventType + { + None, + Resize, + MouseMove, + MouseDown, + MouseUp, + MouseDoubleClick, + MouseVerticalWheel, + MouseHorizontalWheel, + KeyDown, + KeyUp, + Char, + }; + + struct TuiBackendEvent + { + TuiBackendEventType type = TuiBackendEventType::None; + vint width = 0; + vint height = 0; + TuiMouseButton mouseButton = TuiMouseButton::Left; + TuiMouseInfo mouseInfo; + TuiKeyInfo keyInfo; + TuiCharInfo charInfo; + }; + + class ITuiBackend : public Interface + { + public: + virtual TuiColorMode Start(const TuiStartOptions& options) = 0; + virtual void Stop() = 0; + virtual bool TryGetConsoleSize(vint& width, vint& height) = 0; + virtual vuint64_t GetMonotonicTime() = 0; + virtual bool ReadEvent(vint milliseconds, TuiBackendEvent& event) = 0; + virtual void Render(const TuiPixel* buffer, vint width, vint height, TuiColorMode colorMode) = 0; + }; + + class ScopedTuiBackend + { + private: + Ptr* previous = nullptr; + Ptr current; + + public: + NOT_COPYABLE(ScopedTuiBackend); + ScopedTuiBackend(Ptr backend); + ~ScopedTuiBackend() noexcept(false); + }; + } + } +} + +#endif + + +/*********************************************************************** +.\TUI\TUI.INTERNAL.H +***********************************************************************/ +/*********************************************************************** +Author: Zihan Chen (vczh) +Licensed under https://github.com/vczh-libraries/License +***********************************************************************/ + +#ifndef VCZH_TUI_INTERNAL +#define VCZH_TUI_INTERNAL + + +namespace vl +{ + namespace console + { + namespace tui_internal + { + extern bool IsScalar(char32_t code); + extern vint QuantizeColor(TuiColor color, TuiColorMode colorMode, const TuiColor* customColor16 = nullptr); + extern TuiColor GetCanonicalColor(vint index); + extern Ptr CreateTuiBackend(); + } + } +} + +#endif +