diff options
Diffstat (limited to 'xW')
-rw-r--r-- | xW/.clang-format | 11 | ||||
-rw-r--r-- | xW/CMakeLists.txt | 97 | ||||
-rw-r--r-- | xW/config.h.in | 6 | ||||
-rw-r--r-- | xW/xW-highlighted.svg | 24 | ||||
-rw-r--r-- | xW/xW-resources.h | 12 | ||||
-rw-r--r-- | xW/xW.cpp | 1864 | ||||
-rw-r--r-- | xW/xW.manifest | 11 | ||||
-rw-r--r-- | xW/xW.rc | 23 | ||||
-rw-r--r-- | xW/xW.svg | 24 |
9 files changed, 2072 insertions, 0 deletions
diff --git a/xW/.clang-format b/xW/.clang-format new file mode 100644 index 0000000..025ef20 --- /dev/null +++ b/xW/.clang-format @@ -0,0 +1,11 @@ +BasedOnStyle: LLVM +ColumnLimit: 80 +IndentWidth: 4 +TabWidth: 4 +UseTab: ForContinuationAndIndentation +AlwaysBreakAfterReturnType: AllDefinitions +BreakBeforeBraces: Linux +SpaceAfterCStyleCast: true +AlignAfterOpenBracket: DontAlign +AlignOperands: DontAlign +SpacesBeforeTrailingComments: 2 diff --git a/xW/CMakeLists.txt b/xW/CMakeLists.txt new file mode 100644 index 0000000..eb374b9 --- /dev/null +++ b/xW/CMakeLists.txt @@ -0,0 +1,97 @@ +# The last version with Windows XP support is 3.13, we want to keep that +cmake_minimum_required (VERSION 3.10) + +file (READ ../xK-version project_version) +configure_file (../xK-version xK-version.tag COPYONLY) +string (STRIP "${project_version}" project_version) + +# This is an entirely separate CMake project--the main executables only build +# on Windows within Cygwin, and this Windows executable only builds on Linux +# cross-compiled, so you'd want to build them independently anyway. +project (xW VERSION "${project_version}" + DESCRIPTION "Win32 frontend for xC" LANGUAGES CXX) + +set (CMAKE_CXX_STANDARD 17) + +add_definitions (-DUNICODE -D_UNICODE) +add_compile_options ("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>") +add_compile_options ("$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>") +add_compile_options ("$<$<CXX_COMPILER_ID:Clang>:-Wall;-Wextra>") +add_link_options ("$<$<CXX_COMPILER_ID:GNU>:-static;-municode>") + +set (project_config ${PROJECT_BINARY_DIR}/config.h) +configure_file (${PROJECT_SOURCE_DIR}/config.h.in ${project_config}) +include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) + +# Icon generation utilities +# TODO: Shove this into liberty as a CMake module, similar to AddThreads, +# and remove the copies in the parent CMakeLists.txt as well as in tdv. +if (NOT ${CMAKE_VERSION} VERSION_LESS 3.18.0) + set (find_program_REQUIRE REQUIRED) +endif () + +function (icon_to_png name svg size output_dir output) + set (_dimensions ${size}x${size}) + set (_png_path ${output_dir}/hicolor/${_dimensions}/apps) + set (_png ${_png_path}/${name}.png) + set (${output} ${_png} PARENT_SCOPE) + + find_program (rsvg_convert_EXECUTABLE rsvg-convert ${find_program_REQUIRE}) + add_custom_command (OUTPUT ${_png} + COMMAND ${CMAKE_COMMAND} -E make_directory ${_png_path} + COMMAND ${rsvg_convert_EXECUTABLE} --output=${_png} + --width=${size} --height=${size} ${svg} + DEPENDS ${svg} + COMMENT "Generating ${name} ${_dimensions} application icon" VERBATIM) +endfunction () + +function (icon_for_win32 pngs ico) + find_program (icotool_EXECUTABLE icotool ${find_program_REQUIRE}) + add_custom_command (OUTPUT ${ico} + COMMAND ${icotool_EXECUTABLE} -c -o ${ico} ${pngs} + DEPENDS ${pngs} + COMMENT "Generating Windows program icon" VERBATIM) +endfunction () + +# Rasterize SVG icons +set (icon_ico_list) +foreach (icon xW xW-highlighted) + set (icon_png_list) + foreach (icon_size 16 32 48 256) + icon_to_png (${icon} ${PROJECT_SOURCE_DIR}/${icon}.svg + ${icon_size} ${PROJECT_BINARY_DIR}/icons icon_png) + list (APPEND icon_png_list ${icon_png}) + endforeach () + set (icon_ico ${PROJECT_BINARY_DIR}/${icon}.ico) + icon_for_win32 ("${icon_png_list}" ${icon_ico}) + list (APPEND icon_ico_list ${icon_ico}) +endforeach () + +set_property (SOURCE xW.rc APPEND PROPERTY OBJECT_DEPENDS ${icon_ico_list}) + +# Build the main executable and link it +set (root "${PROJECT_SOURCE_DIR}/..") + +find_program (awk_EXECUTABLE awk ${find_program_REQUIRE}) +add_custom_command (OUTPUT xC-proto.cpp + COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE} + -f ${root}/liberty/tools/lxdrgen.awk + -f ${root}/liberty/tools/lxdrgen-cpp.awk + -v PrefixCamel=Relay + ${root}/xC.lxdr > xC-proto.cpp + DEPENDS + ${root}/liberty/tools/lxdrgen.awk + ${root}/liberty/tools/lxdrgen-cpp.awk + ${root}/xC.lxdr + COMMENT "Generating xC relay protocol code" VERBATIM) +add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.cpp) + +add_executable (xW WIN32 xW.cpp xW.rc xW.manifest ${project_config} + ${root}/liberty/tools/lxdrgen-cpp-win32.cpp) +target_link_libraries (xW comctl32 ws2_32) +add_dependencies (xW xC-proto) + +# At least with MinGW, this is a fully independent portable executable +install (TARGETS xW DESTINATION .) +set (CPACK_GENERATOR ZIP) +include (CPack) diff --git a/xW/config.h.in b/xW/config.h.in new file mode 100644 index 0000000..2bfe8fc --- /dev/null +++ b/xW/config.h.in @@ -0,0 +1,6 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#define PROGRAM_VERSION "${project_version}" + +#endif // ! CONFIG_H diff --git a/xW/xW-highlighted.svg b/xW/xW-highlighted.svg new file mode 100644 index 0000000..cdd8105 --- /dev/null +++ b/xW/xW-highlighted.svg @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="48" height="48" viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <clipPath id="outer"> + <rect x="-1" y="-0.15" width="5" height="3.30" /> + </clipPath> + <clipPath id="inner"> + <rect x="-1" y="0" width="5" height="3" /> + </clipPath> + </defs> + + <g transform="translate(6, 6) scale(12)" stroke-linecap="square"> + <g clip-path="url(#outer)"> + <path stroke="#ffffff" stroke-width="1.5" d="M 0.5,0 2.5,3" /> + <path stroke="#ffffff" stroke-width="1.5" d="M 0.5,3 2.5,0" /> + </g> + <g clip-path="url(#inner)"> + <path stroke="#ff0000" stroke-width="0.9" d="M 0.5,0 2.5,3" /> + <path stroke="#ff0000" stroke-width="0.9" d="M 0.5,3 2.5,0" /> + </g> + </g> +</svg> diff --git a/xW/xW-resources.h b/xW/xW-resources.h new file mode 100644 index 0000000..caa37ad --- /dev/null +++ b/xW/xW-resources.h @@ -0,0 +1,12 @@ +#define IDI_ICON 1 +#define IDI_HIGHLIGHTED 2 +#define IDA_ACCELERATORS 10 + +// Named after input_add_functions() in xC. +#define ID_PREVIOUS_BUFFER 11 +#define ID_NEXT_BUFFER 12 +#define ID_SWITCH_BUFFER 13 +#define ID_GOTO_HIGHLIGHT 14 +#define ID_GOTO_ACTIVITY 15 +#define ID_TOGGLE_UNIMPORTANT 16 +#define ID_DISPLAY_FULL_LOG 17 diff --git a/xW/xW.cpp b/xW/xW.cpp new file mode 100644 index 0000000..bbd208a --- /dev/null +++ b/xW/xW.cpp @@ -0,0 +1,1864 @@ +/* + * xW.cpp: Win32 frontend for xC + * + * Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "xC-proto.cpp" +#include "xW-resources.h" + +#include <winsock2.h> +#include <ws2tcpip.h> + +#define WIN32_LEAN_AND_MEAN +#include <windows.h> +#include <commctrl.h> +#include <richedit.h> +#undef ERROR +#undef REGISTERED + +#include <algorithm> +#include <clocale> +#include <ctime> +#include <functional> +#include <map> +#include <string> + +struct Server { + Relay::ServerState state = {}; + std::wstring user; + std::wstring user_modes; +}; + +struct BufferLineItem { + CHARFORMAT2 format = {}; + std::wstring text; +}; + +struct BufferLine { + /// Leaked from another buffer, but temporarily staying in another one. + bool leaked = {}; + + bool is_unimportant = {}; + bool is_highlight = {}; + Relay::Rendition rendition = {}; + uint64_t when = {}; + std::vector<BufferLineItem> items; +}; + +struct Buffer { + std::wstring buffer_name; + bool hide_unimportant = {}; + Relay::BufferKind kind = {}; + std::wstring server_name; + std::vector<BufferLine> lines; + + // Channel: + + std::vector<BufferLineItem> topic; + std::wstring modes; + + // Stats: + + uint32_t new_messages = {}; + uint32_t new_unimportant_messages = {}; + bool highlighted = {}; + + // Input: + + std::wstring input; + DWORD input_start = {}; + DWORD input_end = {}; + std::vector<std::wstring> history; + size_t history_at = {}; +}; + +using Callback = std::function< + void(std::wstring error, const Relay::ResponseData *response)>; + +struct { + HWND hwndMain; ///< Main program window + HWND hwndTopic; ///< static: channel topic + HWND hwndBufferList; ///< listbox: buffer list + HWND hwndBuffer; ///< richedit: buffer backlog + HWND hwndBufferLog; ///< edit: buffer log + HWND hwndPrompt; ///< static: user name, etc. + HWND hwndStatus; ///< static: buffer name, etc. + HWND hwndInput; ///< edit: user input + + HWND hwndLastFocused; ///< For Alt+Tab, e.g. + + HICON hicon; ///< Normal program icon + HICON hiconHighlighted; ///< Highlighted program icon + + HFONT hfont; ///< Normal variant of the UI font + HFONT hfontBold; ///< Bold variant of the UI font + + LOGFONT fontlog; ///< UI font characteristics + LONG font_height; ///< UI font height in pixels + + // Networking: + + addrinfoW *addresses; ///< GetAddrInfo() result + addrinfoW *addresses_iterator; ///< Currently processed address + SOCKET socket; ///< Relay socket + WSAEVENT event; ///< Relay socket event + std::vector<uint8_t> write_buffer; ///< Write buffer + std::vector<uint8_t> read_buffer; ///< Read buffer + + // Relay protocol: + + uint32_t command_seq; ///< Outgoing message counter + + std::map<uint32_t, Callback> command_callbacks; + + std::vector<Buffer> buffers; ///< List of all buffers + std::wstring buffer_current; ///< Current buffer name or "" + std::wstring buffer_last; ///< Previous buffer name or "" + + std::map<std::wstring, Server> servers; +} g; + +static void +show_error_message(const wchar_t *message) +{ + MessageBox(g.hwndMain, message, NULL, MB_ICONERROR | MB_OK | MB_APPLMODAL); +} + +static std::wstring +format_error_message(int err) +{ + wchar_t *message = NULL; + if (!FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, err, 0, (LPWSTR) &message, 0, NULL)) + return std::to_wstring(err); + + std::wstring copy = message; + LocalFree(message); + return copy; +} + +// --- Networking -------------------------------------------------------------- + +static bool +relay_try_read(std::wstring &error) +{ + auto &r = g.read_buffer; + char buffer[8192] = {}; + int err = {}; + while (true) { + int n = recv(g.socket, buffer, sizeof buffer, 0); + if (!n) { + error = L"Server closed the connection."; + return false; + } else if (n != SOCKET_ERROR) { + r.insert(r.end(), buffer, buffer + n); + } else if ((err = WSAGetLastError()) != WSAEWOULDBLOCK) { + error = format_error_message(err); + return false; + } else { + break; + } + } + return true; +} + +static bool +relay_try_write(std::wstring &error) +{ + auto &w = g.write_buffer; + int err = {}; + while (!w.empty()) { + int n = send(g.socket, + reinterpret_cast<const char *>(w.data()), w.size(), 0); + if (n != SOCKET_ERROR) { + w.erase(w.begin(), w.begin() + n); + } else if ((err = WSAGetLastError()) != WSAEWOULDBLOCK) { + error = format_error_message(err); + return false; + } else { + break; + } + } + return true; +} + +static void +relay_send(Relay::CommandData *data, Callback callback = {}) +{ + Relay::CommandMessage m = {}; + m.command_seq = ++g.command_seq; + m.data.reset(data); + LibertyXDR::Writer w; + m.serialize(w); + + if (callback) + g.command_callbacks[m.command_seq] = std::move(callback); + + uint32_t len = htonl(w.data.size()); + uint8_t *prefix = reinterpret_cast<uint8_t *>(&len); + g.write_buffer.insert(g.write_buffer.end(), prefix, prefix + sizeof len); + g.write_buffer.insert(g.write_buffer.end(), w.data.begin(), w.data.end()); + + // Call relay_try_write() separately. +} + +static void +relay_send_now(Relay::CommandData *data, Callback callback = {}) +{ + relay_send(data, callback); + + // TODO(p): Either tear down here, or run relay_try_write() from a timer. + std::wstring error; + if (!relay_try_write(error)) + show_error_message(error.c_str()); +} + +// --- Buffers ----------------------------------------------------------------- + +static Buffer * +buffer_by_name(const std::wstring &name) +{ + for (auto &b : g.buffers) + if (b.buffer_name == name) + return &b; + return nullptr; +} + +static void +buffer_activate(const std::wstring &name) +{ + auto activate = new Relay::CommandData_BufferActivate(); + activate->buffer_name = name; + relay_send_now(activate); +} + +static void +buffer_toggle_unimportant(const std::wstring &name) +{ + auto toggle = new Relay::CommandData_BufferToggleUnimportant(); + toggle->buffer_name = name; + relay_send_now(toggle); +} + +// --- Current buffer ---------------------------------------------------------- + +static void +buffer_toggle_log( + const std::wstring &error, const Relay::ResponseData_BufferLog *response) +{ + if (!response) { + show_error_message(error.c_str()); + return; + } + + std::wstring log; + if (!LibertyXDR::utf8_to_wstring( + response->log.data(), response->log.size(), log)) { + show_error_message(L"Invalid encoding."); + return; + } + + std::wstring filtered; + for (auto wch : log) { + if (wch == L'\n') + filtered += L"\r\n"; + else + filtered += wch; + } + + SetWindowText(g.hwndBufferLog, filtered.c_str()); + ShowWindow(g.hwndBuffer, SW_HIDE); + ShowWindow(g.hwndBufferLog, SW_SHOW); +} + +static void +buffer_toggle_log() +{ + if (IsWindowVisible(g.hwndBufferLog)) { + ShowWindow(g.hwndBufferLog, SW_HIDE); + ShowWindow(g.hwndBuffer, SW_SHOW); + SetWindowText(g.hwndBufferLog, L""); + return; + } + + auto log = new Relay::CommandData_BufferLog(); + log->buffer_name = g.buffer_current; + relay_send_now(log, [name = g.buffer_current](auto error, auto response) { + if (g.buffer_current != name) + return; + buffer_toggle_log(error, + dynamic_cast<const Relay::ResponseData_BufferLog *>(response)); + }); +} + +static bool +buffer_at_bottom() +{ + // It is created with this style, and should retain it indefinitely, + // however this check works. It is necessary because when richedit + // hides its scrollbar, it does not bother resetting its values. + if (!(GetWindowLong(g.hwndBuffer, GWL_STYLE) & WS_VSCROLL)) + return true; + + SCROLLINFO si = {}; + si.cbSize = sizeof si; + si.fMask = SIF_ALL; + GetScrollInfo(g.hwndBuffer, SB_VERT, &si); + return si.nPos + (int) si.nPage >= si.nMax; +} + +static void +buffer_scroll_to_bottom() +{ + SendMessage(g.hwndBuffer, EM_SCROLL, SB_BOTTOM, 0); +} + +// --- UI state refresh -------------------------------------------------------- + +static void +refresh_icon() +{ + HICON icon = g.hicon; + for (const auto &b : g.buffers) + if (b.highlighted) + icon = g.hiconHighlighted; + + SendMessage(g.hwndMain, WM_SETICON, ICON_SMALL, (LPARAM) icon); + SendMessage(g.hwndMain, WM_SETICON, ICON_BIG, (LPARAM) icon); +} + +static void +richedit_replacesel(HWND hWnd, const CHARFORMAT2 *cf, const wchar_t *text) +{ + SendMessage(hWnd, EM_SETCHARFORMAT, SCF_SELECTION, (LPARAM) cf); + SendMessage(hWnd, EM_REPLACESEL, FALSE, (LPARAM) text); +} + +static void +refresh_topic(const std::vector<BufferLineItem> &topic) +{ + SetWindowText(g.hwndTopic, L""); + for (const auto &it : topic) + richedit_replacesel(g.hwndTopic, &it.format, it.text.c_str()); +} + +static void +refresh_buffer_list() +{ + InvalidateRect(g.hwndBufferList, NULL, TRUE); +} + +static std::wstring +server_state_to_string(Relay::ServerState state) +{ + switch (state) { + case Relay::ServerState::DISCONNECTED: return L"disconnected"; + case Relay::ServerState::CONNECTING: return L"connecting"; + case Relay::ServerState::CONNECTED: return L"connected"; + case Relay::ServerState::REGISTERED: return L"registered"; + case Relay::ServerState::DISCONNECTING: return L"disconnecting"; + } + return {}; +} + +static void +refresh_prompt() +{ + std::wstring prompt; + auto b = buffer_by_name(g.buffer_current); + if (!b) { + prompt = L"Synchronizing..."; + } else if (auto server = g.servers.find(b->server_name); + server != g.servers.end()) { + prompt = server->second.user; + if (!server->second.user_modes.empty()) + prompt += L"(" + server->second.user_modes + L")"; + if (prompt.empty()) + prompt = L"(" + server_state_to_string(server->second.state) + L")"; + } + SetWindowText(g.hwndPrompt, prompt.c_str()); +} + +static void +refresh_status() +{ + std::wstring status; + if (!buffer_at_bottom()) + status += L"🡇 "; + + status += g.buffer_current; + auto b = buffer_by_name(g.buffer_current); + if (b) { + if (!b->modes.empty()) + status += L"(+" + b->modes + L")"; + if (b->hide_unimportant) + status += L"<H>"; + } + + // Buffer scrolling would cause a ton of flickering redraws. + int length = GetWindowTextLength(g.hwndStatus); + std::wstring buffer(length, {}); + GetWindowText(g.hwndStatus, buffer.data(), length + 1); + if (buffer != status) + SetWindowText(g.hwndStatus, status.c_str()); +} + +// --- Rich Edit formatting ---------------------------------------------------- + +static COLORREF +convert_color(int16_t color) +{ + static const uint16_t base16[] = { + 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc, + 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, + }; + if (color < 16) { + uint8_t r = 0xf & (base16[color] >> 8); + uint8_t g = 0xf & (base16[color] >> 4); + uint8_t b = 0xf & (base16[color]); + return RGB(r * 0x11, g * 0x11, b * 0x11); + } + if (color >= 216) { + uint8_t g = 8 + (color - 216) * 10; + return RGB(g, g, g); + } + + uint8_t i = color - 16, r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6; + return RGB( + !r ? 0 : 55 + 40 * r, + !g ? 0 : 55 + 40 * g, + !b ? 0 : 55 + 40 * b); +} + +static CHARFORMAT2 +default_charformat() +{ + // Everything we leave out will be kept as it was. + // So, e.g., there is no way to "unset" a monospace font. + CHARFORMAT2 reset = {}; + reset.cbSize = sizeof reset; + reset.dwMask = CFM_BOLD | CFM_ITALIC | CFM_UNDERLINE | CFM_STRIKEOUT | + CFM_COLOR | CFM_BACKCOLOR | CFM_FACE | CFM_LINK; + reset.dwEffects = CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR; + lstrcpyn(reset.szFaceName, g.fontlog.lfFaceName, sizeof reset.szFaceName); + return reset; +} + +static void +convert_item_formatting(Relay::ItemData *item, CHARFORMAT2 &cf, bool &inverse) +{ + if (dynamic_cast<Relay::ItemData_Reset *>(item)) { + cf = default_charformat(); + inverse = false; + } else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) { + cf.dwEffects ^= CFE_BOLD; + } else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) { + cf.dwEffects ^= CFE_ITALIC; + } else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) { + cf.dwEffects ^= CFE_UNDERLINE; + } else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) { + cf.dwEffects ^= CFE_STRIKEOUT; + } else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) { + inverse = !inverse; + } else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) { + auto reset = default_charformat(); + const auto face = !lstrcmp(cf.szFaceName, reset.szFaceName) + ? L"Courier New" + : reset.szFaceName; + lstrcpyn(cf.szFaceName, face, sizeof cf.szFaceName); + } else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) { + if (data->color < 0) { + cf.dwEffects |= CFE_AUTOCOLOR; + } else { + cf.dwEffects &= ~CFE_AUTOCOLOR; + cf.crTextColor = convert_color(data->color); + } + } else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) { + if (data->color < 0) { + cf.dwEffects |= CFE_AUTOBACKCOLOR; + } else { + cf.dwEffects &= ~CFE_AUTOBACKCOLOR; + cf.crBackColor = convert_color(data->color); + } + } +} + +static std::vector<BufferLineItem> +convert_items(std::vector<std::unique_ptr<Relay::ItemData>> &items) +{ + CHARFORMAT2 cf = default_charformat(); + std::vector<BufferLineItem> result; + bool inverse = false; + for (const auto &it : items) { + auto text = dynamic_cast<Relay::ItemData_Text *>(it.get()); + if (!text) { + convert_item_formatting(it.get(), cf, inverse); + continue; + } + + BufferLineItem item = {}; + item.format = cf; + item.text = text->text; + if (inverse) { + std::swap(item.format.crTextColor, item.format.crBackColor); + item.format.dwEffects &= ~(CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR); + if (cf.dwEffects & CFE_AUTOCOLOR) + item.format.crBackColor = GetSysColor(COLOR_WINDOWTEXT); + if (cf.dwEffects & CFE_AUTOBACKCOLOR) + item.format.crTextColor = GetSysColor(COLOR_WINDOW); + } + result.push_back(std::move(item)); + } + return result; +} + +// --- Buffer output ----------------------------------------------------------- + +static BufferLine +convert_buffer_line(Relay::EventData_BufferLine &line) +{ + BufferLine self = {}; + self.items = convert_items(line.items); + self.is_unimportant = line.is_unimportant; + self.is_highlight = line.is_highlight; + self.rendition = line.rendition; + self.when = line.when; + return self; +} + +static void +buffer_print_line(std::vector<BufferLine>::const_iterator begin, + std::vector<BufferLine>::const_iterator line) +{ + CHARRANGE cr = {}; + cr.cpMin = cr.cpMax = GetWindowTextLength(g.hwndBuffer); + SendMessage(g.hwndBuffer, EM_EXSETSEL, 0, (LPARAM) &cr); + + // The Rich Edit control makes the window cursor transparent + // each time you add an independent newline character. Avoid that. + // (Sadly, this also makes Windows 7 end lines with a bogus space that + // has the CHARFORMAT2 of what we flush that newline together with.) + bool sameline = !cr.cpMin; + + time_t current_unix = line->when / 1000; + time_t last_unix = (line != begin) + ? (line - 1)->when / 1000 + : time(NULL); + + tm current = {}, last = {}; + (void) localtime_s(¤t, ¤t_unix); + (void) localtime_s(&last, &last_unix); + if (last.tm_year != current.tm_year || + last.tm_mon != current.tm_mon || + last.tm_mday != current.tm_mday) { + wchar_t buffer[64] = {}; + wcsftime(buffer, sizeof buffer, &L"\n%x\n"[sameline], ¤t); + sameline = true; + + CHARFORMAT2 cf = default_charformat(); + cf.dwEffects |= CFE_BOLD; + richedit_replacesel(g.hwndBuffer, &cf, buffer); + } + { + wchar_t buffer[64] = {}; + wcsftime(buffer, sizeof buffer, &L"\n%H:%M:%S"[sameline], ¤t); + + CHARFORMAT2 cf = default_charformat(); + cf.dwEffects &= ~(CFE_AUTOCOLOR | CFE_AUTOBACKCOLOR); + cf.crTextColor = RGB(0xbb, 0xbb, 0xbb); + cf.crBackColor = RGB(0xf8, 0xf8, 0xf8); + richedit_replacesel(g.hwndBuffer, &cf, buffer); + cf = default_charformat(); + richedit_replacesel(g.hwndBuffer, &cf, L" "); + } + + // Tabstops won't quite help us here, since we need it centred. + std::wstring prefix; + CHARFORMAT2 pcf = default_charformat(); + lstrcpyn(pcf.szFaceName, L"Courier New", sizeof pcf.szFaceName); + // This looks better, but it may trigger a repaint bug in richedit. +#if 1 + pcf.dwEffects |= CFE_BOLD; +#endif + switch (line->rendition) { + break; case Relay::Rendition::BARE: + break; case Relay::Rendition::INDENT: + prefix = L" "; + break; case Relay::Rendition::STATUS: + prefix = L" - "; + break; case Relay::Rendition::ERROR: + prefix = L"=!= "; + pcf.dwEffects &= ~CFE_AUTOCOLOR; + pcf.crTextColor = RGB(0xff, 0, 0); + break; case Relay::Rendition::JOIN: + prefix = L"——> "; + pcf.dwEffects &= ~CFE_AUTOCOLOR; + pcf.crTextColor = RGB(0, 0x88, 0); + break; case Relay::Rendition::PART: + prefix = L"<—— "; + pcf.dwEffects &= ~CFE_AUTOCOLOR; + pcf.crTextColor = RGB(0x88, 0, 0); + break; case Relay::Rendition::ACTION: + prefix = L" * "; + pcf.dwEffects &= ~CFE_AUTOCOLOR; + pcf.crTextColor = RGB(0x88, 0, 0); + } + + if (line->leaked) { + pcf.dwEffects &= ~CFE_AUTOCOLOR; + pcf.crTextColor = GetSysColor(COLOR_GRAYTEXT); + if (!prefix.empty()) + richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str()); + + std::wstring text; + for (const auto &it : line->items) + text += it.text; + + CHARFORMAT2 format = default_charformat(); + format.dwEffects &= ~CFE_AUTOCOLOR; + format.crTextColor = GetSysColor(COLOR_GRAYTEXT); + richedit_replacesel(g.hwndBuffer, &format, text.c_str()); + } else { + if (!prefix.empty()) + richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str()); + for (const auto &it : line->items) + richedit_replacesel(g.hwndBuffer, &it.format, it.text.c_str()); + } +} + +static void +buffer_print_separator() +{ + CHARFORMAT2 format = default_charformat(); + format.dwEffects &= ~CFE_AUTOCOLOR; + format.crTextColor = RGB(0xff, 0x5f, 0x00); + richedit_replacesel(g.hwndBuffer, &format, L"\n---"); +} + +static void +refresh_buffer(const Buffer &b) +{ + HCURSOR oldCursor = SetCursor(LoadCursor(NULL, IDC_WAIT)); + SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) FALSE, 0); + SetWindowText(g.hwndBuffer, L""); + + // PFM_OFFSET could also be used, but the result isn't very nice. + // + // PFM_BORDER is not implemented, at most we can try to construct + // an OLE object the width of the screen and see how it clips + // (this is a lot of code). + size_t i = 0, mark_before = b.lines.size() - + b.new_messages - b.new_unimportant_messages; + for (auto line = b.lines.begin(); line != b.lines.end(); ++line) { + if (i == mark_before) + buffer_print_separator(); + if (!line->is_unimportant || !b.hide_unimportant) + buffer_print_line(b.lines.begin(), line); + + i++; + } + + buffer_scroll_to_bottom(); + + SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) TRUE, 0); + InvalidateRect(g.hwndBuffer, NULL, TRUE); + SetCursor(oldCursor); +} + +// --- Event processing -------------------------------------------------------- + +static void +relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m) +{ + // Initial sync: skip all other processing, let highlights be. + auto bc = buffer_by_name(g.buffer_current); + if (!bc) { + b.lines.push_back(convert_buffer_line(m)); + return; + } + + // Retained mode is complicated. + bool display = (!m.is_unimportant || !bc->hide_unimportant) && + (b.buffer_name == g.buffer_current || m.leak_to_active); + bool to_bottom = display && + buffer_at_bottom(); + bool visible = display && + to_bottom && + !IsIconic(g.hwndMain) && + !IsWindowVisible(g.hwndBufferLog); + bool separate = display && + !visible && !bc->new_messages && !bc->new_unimportant_messages; + + auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m)); + if (!(visible || m.leak_to_active) || + b.new_messages || b.new_unimportant_messages) { + if (line->is_unimportant || m.leak_to_active) + b.new_unimportant_messages++; + else + b.new_messages++; + } + + if (m.leak_to_active) { + auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m)); + line->leaked = true; + if (!visible || bc->new_messages || bc->new_unimportant_messages) { + if (line->is_unimportant) + bc->new_unimportant_messages++; + else + bc->new_messages++; + } + } + if (separate) + buffer_print_separator(); + if (display) + buffer_print_line(bc->lines.begin(), bc->lines.end() - 1); + if (to_bottom) + buffer_scroll_to_bottom(); + + if (line->is_highlight || (!visible && !line->is_unimportant && + b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) { + // TODO(p): Avoid the PC speaker, which is also unreliable. + Beep(800, 100); + + if (!visible) { + b.highlighted = true; + refresh_icon(); + } + } + + refresh_buffer_list(); +} + +static void +relay_process_callbacks(uint32_t command_seq, + const std::wstring& error, const Relay::ResponseData *response) +{ + auto &callbacks = g.command_callbacks; + auto handler = callbacks.find(command_seq); + if (handler == callbacks.end()) { + // TODO(p): Warn about an unawaited response. + } else { + if (handler->second) + handler->second(error, response); + callbacks.erase(handler); + } + + // We don't particularly care about wraparound issues. + while (!callbacks.empty() && callbacks.begin()->first <= command_seq) { + auto front = callbacks.begin(); + if (front->second) + front->second(L"No response", nullptr); + callbacks.erase(front); + } +} + +static std::wstring input_get_contents(); + +static void +relay_process_message(const Relay::EventMessage &m) +{ + switch (m.data->event) { + case Relay::Event::ERROR: + { + auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get()); + relay_process_callbacks(data->command_seq, data->error, nullptr); + break; + } + case Relay::Event::RESPONSE: + { + auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get()); + relay_process_callbacks(data->command_seq, {}, data->data.get()); + break; + } + + case Relay::Event::PING: + { + auto pong = new Relay::CommandData_PingResponse(); + pong->event_seq = m.event_seq; + relay_send(pong); + break; + } + + case Relay::Event::BUFFER_LINE: + { + auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + relay_process_buffer_line(*b, data); + break; + } + case Relay::Event::BUFFER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) { + b = &*g.buffers.insert(g.buffers.end(), Buffer()); + b->buffer_name = data.buffer_name; + SendMessage(g.hwndBufferList, LB_ADDSTRING, 0, 0); + } + + bool hiding_toggled = b->hide_unimportant != data.hide_unimportant; + b->hide_unimportant = data.hide_unimportant; + b->kind = data.context->kind; + b->server_name.clear(); + if (auto context = dynamic_cast<Relay::BufferContext_Server *>( + data.context.get())) + b->server_name = context->server_name; + if (auto context = dynamic_cast<Relay::BufferContext_Channel *>( + data.context.get())) { + b->server_name = context->server_name; + b->modes = context->modes; + b->topic = convert_items(context->topic); + } + if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>( + data.context.get())) + b->server_name = context->server_name; + + if (b->buffer_name == g.buffer_current) { + refresh_topic(b->topic); + refresh_status(); + + if (hiding_toggled) + refresh_buffer(*b); + } + break; + } + case Relay::Event::BUFFER_STATS: + { + auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + b->new_messages = data.new_messages; + b->new_unimportant_messages = data.new_unimportant_messages; + b->highlighted = data.highlighted; + + refresh_icon(); + break; + } + case Relay::Event::BUFFER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + b->buffer_name = data.buffer_name; + + refresh_buffer_list(); + if (b->buffer_name == g.buffer_current) + refresh_status(); + break; + } + case Relay::Event::BUFFER_REMOVE: + { + auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + int index = b - g.buffers.data(); + SendMessage(g.hwndBufferList, LB_DELETESTRING, index, 0); + g.buffers.erase(g.buffers.begin() + index); + + refresh_icon(); + break; + } + case Relay::Event::BUFFER_ACTIVATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data); + Buffer *old = buffer_by_name(g.buffer_current); + g.buffer_last = g.buffer_current; + g.buffer_current = data.buffer_name; + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + if (old) { + old->new_messages = 0; + old->new_unimportant_messages = 0; + old->highlighted = false; + + old->input = input_get_contents(); + SendMessage(g.hwndInput, EM_GETSEL, + (WPARAM) &old->input_start, (LPARAM) &old->input_end); + + // Note that we effectively overwrite the newest line + // with the current textarea contents, and jump there. + old->history_at = old->history.size(); + } + + if (IsWindowVisible(g.hwndBufferLog)) + buffer_toggle_log(); + if (!IsIconic(g.hwndMain)) + b->highlighted = false; + SendMessage(g.hwndBufferList, LB_SETCURSEL, b - g.buffers.data(), 0); + + refresh_topic(b->topic); + refresh_buffer(*b); + refresh_prompt(); + refresh_status(); + + SetWindowText(g.hwndInput, b->input.c_str()); + SendMessage(g.hwndInput, EM_SETSEL, + (WPARAM) b->input_start, (LPARAM) b->input_end); + SetFocus(g.hwndInput); + break; + } + case Relay::Event::BUFFER_INPUT: + { + auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + if (b->history_at == b->history.size()) + b->history_at++; + b->history.push_back(data.text); + break; + } + case Relay::Event::BUFFER_CLEAR: + { + auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + b->lines.clear(); + if (b->buffer_name == g.buffer_current) + refresh_buffer(*b); + break; + } + + case Relay::Event::SERVER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data); + if (!g.servers.count(data.server_name)) + g.servers.emplace(data.server_name, Server()); + + auto &server = g.servers.at(data.server_name); + server.state = data.data->state; + + server.user.clear(); + server.user_modes.clear(); + if (auto registered = dynamic_cast<Relay::ServerData_Registered *>( + data.data.get())) { + server.user = registered->user; + server.user_modes = registered->user_modes; + } + + refresh_prompt(); + break; + } + case Relay::Event::SERVER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data); + g.servers.insert_or_assign(data.new_, g.servers.at(data.server_name)); + g.servers.erase(data.server_name); + break; + } + case Relay::Event::SERVER_REMOVE: + { + auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data); + g.servers.erase(data.server_name); + break; + } + } +} + +// --- Networking -------------------------------------------------------------- + +static bool +relay_process_buffer(std::wstring &error) +{ + auto &b = g.read_buffer; + size_t offset = 0; + while (true) { + LibertyXDR::Reader r; + r.data = b.data() + offset; + r.length = b.size() - offset; + + uint32_t frame_len = 0; + if (!r.read(frame_len)) + break; + + r.length = std::min<size_t>(r.length, frame_len); + if (r.length < frame_len) + break; + + Relay::EventMessage m = {}; + if (!m.deserialize(r) || r.length) { + error = L"Deserialization failed."; + return false; + } + + relay_process_message(m); + offset += sizeof frame_len + frame_len; + } + + b.erase(b.begin(), b.begin() + offset); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +relay_destroy_socket() +{ + closesocket(g.socket); + g.socket = INVALID_SOCKET; + WSACloseEvent(g.event); + g.event = NULL; + + g.read_buffer.clear(); + g.write_buffer.clear(); +} + +static bool +relay_connect_step(std::wstring& error) +{ + addrinfoW *&p = g.addresses_iterator; + while (p) { + g.socket = socket(p->ai_family, p->ai_socktype, p->ai_protocol); + if (g.socket != INVALID_SOCKET) + break; + p = p->ai_next; + } + if (!p) { + error = L"Failed to create a socket."; + return false; + } + + g.event = WSACreateEvent(); + if (WSAEventSelect(g.socket, g.event, + FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE)) + error = format_error_message(WSAGetLastError()); + else if (!connect(g.socket, p->ai_addr, (int) p->ai_addrlen)) + error = L"Connection succeeded unexpectedly early."; + else if (int err = WSAGetLastError(); err != WSAEWOULDBLOCK) + error = format_error_message(err); + else + return true; + + relay_destroy_socket(); + return false; +} + +static bool +relay_process_connect_event(int err, std::wstring &error) +{ + addrinfoW *&p = g.addresses_iterator; + if (err) { + relay_destroy_socket(); + if (!(p = p->ai_next)) { + error = L"Connection failed."; + return false; + } + return relay_connect_step(error); + } + + g.read_buffer.clear(); + g.write_buffer.clear(); + + auto hello = new Relay::CommandData_Hello(); + hello->version = Relay::VERSION; + relay_send(hello); + // The message will be flushed at the upcoming FD_WRITE notification. + return true; +} + +static bool +relay_process_socket_event(int event, int err, std::wstring &error) +{ + if (err) { + relay_destroy_socket(); + error = format_error_message(err); + return false; + } + + switch (event) { + case FD_READ: + if (!relay_try_read(error) || + !relay_process_buffer(error) || + !relay_try_write(error)) + return false; + break; + case FD_WRITE: + if (!relay_try_write(error)) + return false; + break; + case FD_CLOSE: + // Handling this seems excessive, since we also get EOF while reading. + // But we may not receive an FD_READ notification for it. + error = L"Connection closed."; + return false; + } + return true; +} + +static bool +relay_process_socket_events(std::wstring &error) +{ + WSANETWORKEVENTS wne = {}; + if (WSAEnumNetworkEvents(g.socket, g.event, &wne)) { + error = format_error_message(WSAGetLastError()); + return false; + } + + // TODO(p): Offer reconnecting. + // TODO(p): Consider disabling UI controls while disconnected. + if (wne.lNetworkEvents & FD_CONNECT && + !relay_process_connect_event(wne.iErrorCode[FD_CONNECT_BIT], error)) + return false; + + for (auto bit : {FD_READ_BIT, FD_WRITE_BIT, FD_CLOSE_BIT}) + if ((wne.lNetworkEvents & (1 << bit)) && + !relay_process_socket_event((1 << bit), wne.iErrorCode[bit], error)) + return false; + return true; +} + +// --- Input line -------------------------------------------------------------- + +static std::wstring +input_get_contents() +{ + int length = GetWindowTextLength(g.hwndInput); + std::wstring buffer(length, {}); + GetWindowText(g.hwndInput, buffer.data(), length + 1); + return buffer; +} + +static void +input_set_contents(const std::wstring &input) +{ + SetWindowText(g.hwndInput, input.c_str()); + if (input.size()) + SendMessage(g.hwndInput, EM_SETSEL, input.size(), input.size()); +} + +static bool +input_submit() +{ + auto b = buffer_by_name(g.buffer_current); + if (!b) + return false; + + auto input = new Relay::CommandData_BufferInput(); + input->buffer_name = b->buffer_name; + input->text = input_get_contents(); + + // Buffer::history[Buffer::history.size()] is virtual, + // and is represented either by edit contents when it's currently + // being edited, or by Buffer::input in all other cases. + b->history.push_back(input->text); + b->history_at = b->history.size(); + input_set_contents({}); + + relay_send_now(input); + return true; +} + +struct InputStamp { + DWORD start = {}; + DWORD end = {}; + std::wstring input; +}; + +static InputStamp +input_stamp() +{ + DWORD start = {}, end = {}; + SendMessage(g.hwndInput, EM_GETSEL, (WPARAM) &start, (LPARAM) &end); + return {start, end, input_get_contents()}; +} + +static void +input_complete(const InputStamp &state, const std::wstring &error, + const Relay::ResponseData_BufferComplete *response) +{ + if (!response) { + show_error_message(error.c_str()); + return; + } + + std::string utf8; + if (!LibertyXDR::wstring_to_utf8(state.input.substr(0, state.start), utf8)) + return; + std::wstring preceding; + if (!LibertyXDR::utf8_to_wstring( + reinterpret_cast<const uint8_t *>(utf8.c_str()), response->start, + preceding)) + return; + + if (response->completions.size() > 0) { + auto insert = response->completions.at(0); + if (response->completions.size() == 1) + insert += L" "; + + SendMessage(g.hwndInput, EM_SETSEL, preceding.length(), state.end); + SendMessage(g.hwndInput, EM_REPLACESEL, TRUE, (LPARAM) insert.c_str()); + } + + // TODO(p): Avoid the PC speaker, which is also unreliable. + if (response->completions.size() != 1) + Beep(800, 100); + + // TODO(p): Show all completion options. +} + +static bool +input_complete() +{ + // TODO(p): Also add an increasing counter to the stamp. + auto state = input_stamp(); + if (state.start != state.end) + return false; + + std::string utf8; + if (!LibertyXDR::wstring_to_utf8(state.input.substr(0, state.start), utf8)) + return false; + + auto complete = new Relay::CommandData_BufferComplete(); + complete->buffer_name = g.buffer_current; + complete->text = state.input; + complete->position = utf8.length(); + relay_send_now(complete, [state](auto error, auto response) { + auto stamp = input_stamp(); + if (std::make_tuple(stamp.start, stamp.end, stamp.input) != + std::make_tuple(state.start, state.end, state.input)) + return; + input_complete(stamp, error, + dynamic_cast<const Relay::ResponseData_BufferComplete *>(response)); + }); + return true; +} + +static boolean +input_wants(const MSG *message) +{ + switch (message->message) { + case WM_KEYDOWN: + // Shift-Tab can go to the dialog manager. + return message->wParam == VK_RETURN || + (message->wParam == VK_TAB && !(GetKeyState(VK_SHIFT) & 0x8000)); + case WM_SYSCHAR: + switch (message->wParam) { + case 'p': return true; + case 'n': return true; + } + } + return false; +} + +static LRESULT CALLBACK +input_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData) +{ + switch (uMsg) { + case WM_GETDLGCODE: + { + // https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243 + LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam); + if (lParam && input_wants((MSG *) lParam)) + lResult |= DLGC_WANTMESSAGE; + return lResult; + } + case WM_SYSCHAR: + { + auto b = buffer_by_name(g.buffer_current); + if (!b) + break; + + // TODO(p): Emacs-style cursor movement shortcuts. + switch (wParam) { + case 'p': + { + if (b->history_at < 1) + break; + if (b->history_at == b->history.size()) + b->input = input_get_contents(); + input_set_contents(b->history.at(--b->history_at)); + return 0; + } + case 'n': + { + if (b->history_at >= b->history.size()) + break; + input_set_contents(++b->history_at == b->history.size() + ? b->input + : b->history.at(b->history_at)); + return 0; + } + } + break; + } + case WM_KEYDOWN: + { + HWND scrollable = IsWindowVisible(g.hwndBufferLog) + ? g.hwndBufferLog + : g.hwndBuffer; + + switch (wParam) { + case VK_PRIOR: + SendMessage(scrollable, EM_SCROLL, SB_PAGEUP, 0); + return 0; + case VK_NEXT: + SendMessage(scrollable, EM_SCROLL, SB_PAGEDOWN, 0); + return 0; + } + break; + } + case WM_CHAR: + { + // This could be implemented more precisely, but it will do. + relay_send_now(new Relay::CommandData_Active()); + + switch (wParam) { + case VK_RETURN: + if (!input_submit()) + break; + return 0; + case VK_TAB: + if (!input_complete()) + break; + return 0; + } + break; + } + case WM_NCDESTROY: + // https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + RemoveWindowSubclass(hWnd, input_proc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +// --- General UI -------------------------------------------------------------- + +static LRESULT CALLBACK +bufferlist_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData) +{ + switch (uMsg) { + case WM_MBUTTONUP: + { + POINT p = {LOWORD(lParam), HIWORD(lParam)}; + ClientToScreen(hWnd, &p); + int index = LBItemFromPt(hWnd, p, FALSE); + if (wParam || index < 0 || (size_t) index > g.buffers.size()) + break; + + auto input = new Relay::CommandData_BufferInput(); + input->buffer_name = g.buffer_current; + input->text = L"/buffer close " + g.buffers.at(index).buffer_name; + relay_send_now(input); + return 0; + } + case WM_NCDESTROY: + // https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + RemoveWindowSubclass(hWnd, bufferlist_proc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +static LRESULT CALLBACK +richedit_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData) +{ + switch (uMsg) { + case WM_GETDLGCODE: + { + // https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243 + LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam); + lResult &= ~DLGC_WANTTAB; + if (lParam && + ((MSG *) lParam)->message == WM_KEYDOWN && + ((MSG *) lParam)->wParam == VK_TAB) + lResult &= ~DLGC_WANTMESSAGE; + return lResult; + } + case WM_VSCROLL: + { + // Dragging the scrollbar doesn't result in EN_VSCROLL. + LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam); + refresh_status(); + return lResult; + } + case WM_NCDESTROY: + // https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + RemoveWindowSubclass(hWnd, richedit_proc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +static LRESULT CALLBACK +log_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, [[maybe_unused]] DWORD_PTR dwRefData) +{ + switch (uMsg) { + case WM_GETDLGCODE: + { + // https://devblogs.microsoft.com/oldnewthing/20070627-00/?p=26243 + // https://devblogs.microsoft.com/oldnewthing/20031114-00/?p=41823 + LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam); + lResult &= ~(DLGC_WANTTAB | DLGC_HASSETSEL); + if (lParam && + ((MSG *) lParam)->message == WM_KEYDOWN && + ((MSG *) lParam)->wParam == VK_TAB) + lResult &= ~DLGC_WANTMESSAGE; + return lResult; + } + case WM_NCDESTROY: + // https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + RemoveWindowSubclass(hWnd, log_proc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +static void +process_resize(UINT w, UINT h) +{ + // Font height, control height (accounts for padding and borders) + int fh = g.font_height, ch = fh + 10; + + int top = 0; + MoveWindow(g.hwndTopic, 5, 5, w - 10, fh, FALSE); + top += ch; + + int bottom = 0; + MoveWindow(g.hwndInput, 3, h - ch - 3, w - 6, ch, FALSE); + bottom += ch + 3; + MoveWindow(g.hwndPrompt, 5, h - bottom - fh - 5, w / 2 - 5, fh, FALSE); + MoveWindow(g.hwndStatus, w / 2, h - bottom - fh - 5, w / 2 - 5, fh, FALSE); + bottom += ch; + + bool to_bottom = buffer_at_bottom(); + MoveWindow(g.hwndBufferList, 3, top, 150, h - top - bottom, FALSE); + MoveWindow(g.hwndBuffer, 156, top, w - 159, h - top - bottom, FALSE); + MoveWindow(g.hwndBufferLog, 156, top, w - 159, h - top - bottom, FALSE); + if (to_bottom) + buffer_scroll_to_bottom(); + + InvalidateRect(g.hwndMain, NULL, TRUE); +} + +static void +process_bufferlist_drawitem(PDRAWITEMSTRUCT dis) +{ + // Just always redraw it entirely, disregarding dis->itemAction. + COLORREF foreground = GetSysColor(dis->itemState & ODS_SELECTED + ? COLOR_HIGHLIGHTTEXT : COLOR_WINDOWTEXT); + COLORREF background = GetSysColor(dis->itemState & ODS_SELECTED + ? COLOR_HIGHLIGHT : COLOR_WINDOW); + if (dis->itemState & ODS_DISABLED) + foreground = GetSysColor(COLOR_GRAYTEXT); + + HFONT oldFont = NULL; + std::wstring text; + if (dis->itemID != (UINT) -1 && dis->itemID < g.buffers.size()) { + const Buffer& b = g.buffers.at(dis->itemID); + text = b.buffer_name; + if (b.buffer_name != g.buffer_current && b.new_messages) { + text += L" (" + std::to_wstring(b.new_messages) + L")"; + oldFont = (HFONT) SelectObject(dis->hDC, g.hfontBold); + } + if (b.highlighted) + foreground = RGB(0xff, 0x5f, 0x00); + } + + COLORREF oldForeground = SetTextColor(dis->hDC, foreground); + COLORREF oldBackground = SetBkColor(dis->hDC, background); + + // Old Windows hardcoded two pixels, and so will we. + ExtTextOut(dis->hDC, dis->rcItem.left + 2, dis->rcItem.top, + ETO_CLIPPED | ETO_OPAQUE, &dis->rcItem, + text.c_str(), text.length(), NULL); + if (oldFont) + SelectObject(dis->hDC, oldFont); + + SetTextColor(dis->hDC, oldForeground); + SetBkColor(dis->hDC, oldBackground); + + if (dis->itemState & ODS_FOCUS) + DrawFocusRect(dis->hDC, &dis->rcItem); +} + +static void +process_bufferlist_notification(WORD code) +{ + if (code == LBN_SELCHANGE) { + auto i = (size_t) SendMessage(g.hwndBufferList, LB_GETCURSEL, 0, 0); + if (i < g.buffers.size()) + buffer_activate(g.buffers.at(i).buffer_name); + } +} + +static void +process_accelerator(WORD id) +{ + // Buffer indexes rotated to start after the current buffer. + std::vector<size_t> rotated(g.buffers.size()); + size_t start = 0; + for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it) + if (it->buffer_name == g.buffer_current) { + start = it - g.buffers.begin(); + break; + } + for (auto &index : rotated) + index = ++start % g.buffers.size(); + + auto b = buffer_by_name(g.buffer_current); + switch (id) { + case ID_PREVIOUS_BUFFER: + if (rotated.size() > 0) { + size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1; + buffer_activate(g.buffers[i].buffer_name); + } + return; + case ID_NEXT_BUFFER: + if (rotated.size() > 0) + buffer_activate(g.buffers[rotated.front()].buffer_name); + return; + case ID_SWITCH_BUFFER: + if (!g.buffer_last.empty()) + buffer_activate(g.buffer_last); + return; + case ID_GOTO_HIGHLIGHT: + for (auto i : rotated) + if (g.buffers[i].highlighted) { + buffer_activate(g.buffers[i].buffer_name); + break; + } + return; + case ID_GOTO_ACTIVITY: + for (auto i : rotated) + if (g.buffers[i].new_messages) { + buffer_activate(g.buffers[i].buffer_name); + break; + } + return; + case ID_TOGGLE_UNIMPORTANT: + if (b) + buffer_toggle_unimportant(b->buffer_name); + return; + case ID_DISPLAY_FULL_LOG: + if (b) + buffer_toggle_log(); + return; + } +} + +static LRESULT CALLBACK +window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + switch (uMsg) { + case WM_DESTROY: + PostQuitMessage(0); + return 0; + case WM_SIZE: + process_resize(LOWORD(lParam), HIWORD(lParam)); + return 0; + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) + g.hwndLastFocused = GetFocus(); + else if (g.hwndLastFocused) + SetFocus(g.hwndLastFocused); + return 0; + case WM_MEASUREITEM: + { + auto mis = (PMEASUREITEMSTRUCT) lParam; + mis->itemHeight = g.font_height; + return TRUE; + } + case WM_DRAWITEM: + { + auto dis = (PDRAWITEMSTRUCT) lParam; + if (dis->hwndItem == g.hwndBufferList) + process_bufferlist_drawitem(dis); + return TRUE; + } + case WM_SYSCOMMAND: + { + auto b = buffer_by_name(g.buffer_current); + if (b && wParam == SC_RESTORE) { + b->highlighted = false; + refresh_icon(); + } + // Here we absolutely must pass to DefWindowProc(). + break; + } + case WM_COMMAND: + if (!lParam) + process_accelerator(LOWORD(wParam)); + else if (lParam == (LPARAM) g.hwndBufferList) + process_bufferlist_notification(HIWORD(wParam)); + else if (lParam == (LPARAM) g.hwndBuffer && + HIWORD(wParam) == EN_VSCROLL) + refresh_status(); + return 0; + case WM_NOTIFY: + switch (((LPNMHDR) lParam)->code) { + case EN_LINK: + { + auto link = (ENLINK *) lParam; + if (link->msg == WM_LBUTTONUP) { + TEXTRANGE tr = {}; + tr.chrg = link->chrg; + tr.lpstrText = new wchar_t[tr.chrg.cpMax - tr.chrg.cpMin + 1](); + SendMessage( + link->nmhdr.hwndFrom, EM_GETTEXTRANGE, 0, (LPARAM) &tr); + ShellExecute( + NULL, L"open", tr.lpstrText, NULL, NULL, SW_SHOWNORMAL); + delete[] tr.lpstrText; + } + break; + } + } + } + return DefWindowProc(hWnd, uMsg, wParam, lParam); +} + +static void +get_font() +{ + // To enable the "Make text bigger" scaling functionality. + NONCLIENTMETRICS ncm = {}; + ncm.cbSize = sizeof ncm; + if (SystemParametersInfo(SPI_GETNONCLIENTMETRICS, sizeof ncm, &ncm, 0)) { + g.hfont = CreateFontIndirect(&ncm.lfMessageFont); + + LOGFONT bold = g.fontlog = ncm.lfMessageFont; + bold.lfWeight = FW_BOLD; + g.hfontBold = CreateFontIndirect(&bold); + } + + if (!g.hfont) + g.hfont = g.hfontBold = (HFONT) GetStockObject(DEFAULT_GUI_FONT); + + // There doesn't seem to be a better way than through a drawing context. + HDC hdc = GetDC(NULL); + HFONT oldFont = (HFONT) SelectObject(hdc, g.hfont); + TEXTMETRIC tm = {}; + GetTextMetrics(hdc, &tm); + SelectObject(hdc, oldFont); + ReleaseDC(NULL, hdc); + + g.font_height = tm.tmHeight; +} + +static BOOL CALLBACK +set_font(HWND child, LPARAM font) +{ + SendMessage(child, WM_SETFONT, font, (LPARAM) TRUE); + return TRUE; +} + +static bool +process_messages(HACCEL accelerators) +{ + MSG message = {}; + while (PeekMessage(&message, NULL, 0, 0, TRUE)) { + if (message.message == WM_QUIT) + return false; + if (TranslateAccelerator(g.hwndMain, accelerators, &message)) + continue; + + // https://devblogs.microsoft.com/oldnewthing/20031021-00/?p=42083 + if (IsDialogMessage(g.hwndMain, &message)) + continue; + + TranslateMessage(&message); + DispatchMessage(&message); + } + return true; +} + +int WINAPI +wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance, + PWSTR pCmdLine, int nCmdShow) +{ + setlocale(LC_ALL, ""); + + WSADATA wd = {}; + int err = {}; + if ((err = WSAStartup(MAKEWORD(2, 2), &wd))) { + show_error_message(format_error_message(err).c_str()); + return 1; + } + + INITCOMMONCONTROLSEX icc = {}; + icc.dwICC = 0; + icc.dwSize = sizeof icc; + (void) InitCommonControlsEx(&icc); + + // TODO(p): The control doesn't seem to support visual styles at all, + // try to figure out how to immitate it with WM_NCPAINT, + // GetThemeBackgroundContentRect(), DrawThemeBackground(), etc., + // and remember to handle the case of visual styles being disabled, + // perhaps by using DrawEdge() → InflateRect(-CXBORDER, -CYBORDER). + // This is by no means simple. + // + // Example implementation: https://web.archive.org/web/20210707175627 + // /http://www.codeguru.com/cpp/w-d/dislog/miscellaneous/article.php/c8729 + // /XP-Theme-Support-for-Rich-Edit-and-Custom-Controls.htm + if (!LoadLibrary(L"Msftedit.dll")) { + show_error_message(format_error_message(GetLastError()).c_str()); + return 1; + } + + // WINE calls WM_MEASUREITEM as soon as the listbox is created. + // TODO(p): Watch for WM_SETTINGCHANGE/SPI_SETNONCLIENTMETRICS, + // reset all fonts in all widgets, and the topic background colour. + get_font(); + + g.hicon = + LoadIcon(hInstance, MAKEINTRESOURCE(IDI_ICON)); + g.hiconHighlighted = + LoadIcon(hInstance, MAKEINTRESOURCE(IDI_HIGHLIGHTED)); + + WNDCLASSEX wc = {}; + wc.cbSize = sizeof wc; + wc.lpfnWndProc = window_proc; + wc.hInstance = hInstance; + wc.hIcon = g.hicon; + wc.hCursor = LoadCursor(NULL, IDC_ARROW); + wc.hbrBackground = GetSysColorBrush(COLOR_3DFACE); + wc.lpszClassName = L"xW"; + if (!RegisterClassEx(&wc)) + return 1; + + g.hwndMain = CreateWindowEx(WS_EX_CONTROLPARENT, L"xW", L"xW", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, 600, 400, NULL, NULL, hInstance, NULL); + + // We're lucky to not need much user user interface, + // because Win32 is in many aspects quite difficult to work with. + HMENU id = 0; + g.hwndTopic = CreateWindowEx(0, MSFTEDIT_CLASS, L"", + WS_VISIBLE | WS_CHILD | WS_TABSTOP | + ES_AUTOHSCROLL | ES_READONLY, + 0, 0, 100, 20, g.hwndMain, ++id, hInstance, NULL); + g.hwndBufferList = CreateWindowEx(WS_EX_CLIENTEDGE, WC_LISTBOX, L"", + WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_VSCROLL | + LBS_NOINTEGRALHEIGHT | LBS_NOTIFY | LBS_NODATA | LBS_OWNERDRAWFIXED, + 0, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL); + g.hwndBuffer = CreateWindowEx(WS_EX_CLIENTEDGE, MSFTEDIT_CLASS, L"", + WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_VSCROLL | + ES_MULTILINE | ES_READONLY | ES_SAVESEL, + 50, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL); + g.hwndBufferLog = CreateWindowEx(WS_EX_CLIENTEDGE, WC_EDIT, L"", + WS_CHILD | WS_TABSTOP | WS_VSCROLL | + ES_MULTILINE | ES_READONLY, + 50, 20, 50, 40, g.hwndMain, ++id, hInstance, NULL); + g.hwndPrompt = CreateWindowEx(0, WC_STATIC, L"Connecting...", + WS_VISIBLE | WS_CHILD, + 0, 60, 50, 20, g.hwndMain, ++id, hInstance, NULL); + g.hwndStatus = CreateWindowEx(0, WC_STATIC, L"", + WS_VISIBLE | WS_CHILD | ES_RIGHT, + 50, 60, 50, 20, g.hwndMain, ++id, hInstance, NULL); + g.hwndInput = CreateWindowEx(WS_EX_CLIENTEDGE, WC_EDIT, L"", + WS_VISIBLE | WS_CHILD | WS_TABSTOP | + ES_AUTOHSCROLL, + 0, 80, 100, 20, g.hwndMain, ++id, hInstance, NULL); + + SendMessage(g.hwndTopic, EM_SETBKGNDCOLOR, 0, GetSysColor(COLOR_3DFACE)); + // The 1 probably means AURL_ENABLEEA only in later versions. + SendMessage(g.hwndTopic, EM_AUTOURLDETECT, 1, 0); + SendMessage(g.hwndTopic, EM_SETEVENTMASK, 0, ENM_LINK); + SendMessage(g.hwndTopic, EM_SETUNDOLIMIT, 0, 0); + SetWindowSubclass(g.hwndTopic, richedit_proc, 0, 0); + SendMessage(g.hwndBuffer, EM_AUTOURLDETECT, 1, 0); + SendMessage(g.hwndBuffer, EM_SETEVENTMASK, 0, ENM_LINK | ENM_SCROLL); + SendMessage(g.hwndBuffer, EM_SETUNDOLIMIT, 0, 0); + SetWindowSubclass(g.hwndBufferList, bufferlist_proc, 0, 0); + SetWindowSubclass(g.hwndBuffer, richedit_proc, 0, 0); + SetWindowSubclass(g.hwndBufferLog, log_proc, 0, 0); + + RECT client_rect = {}; + if (GetClientRect(g.hwndMain, &client_rect)) { + process_resize(client_rect.right - client_rect.left, + client_rect.bottom - client_rect.top); + } + + EnumChildWindows(g.hwndMain, (WNDENUMPROC) set_font, (LPARAM) g.hfont); + SendMessage(g.hwndPrompt, WM_SETFONT, (WPARAM) g.hfontBold, (LPARAM) TRUE); + SetFocus(g.hwndInput); + SetWindowSubclass(g.hwndInput, input_proc, 0, 0); + ShowWindow(g.hwndMain, nCmdShow); + + HACCEL accelerators = + LoadAccelerators(hInstance, MAKEINTRESOURCE(IDA_ACCELERATORS)); + if (!accelerators) { + show_error_message(format_error_message(GetLastError()).c_str()); + return 1; + } + + int argc = 0; + LPWSTR *argv = CommandLineToArgvW(pCmdLine, &argc); + if (argc < 2) { + show_error_message( + L"You must pass the relay address and port on the command line."); + return 1; + } + + // We have a few suboptimal asynchronous options: + // a) WSAAsyncGetHostByName() requires us to distinguish hostnames + // from IP literals manually, + // b) GetAddrInfoEx() only supports asynchronous operation since Windows 8, + // c) run this from a thread. + addrinfoW hints = {}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + err = GetAddrInfo(argv[0], argv[1], &hints, &g.addresses); + LocalFree(argv); + if (err) { + show_error_message(format_error_message(err).c_str()); + return 1; + } + + std::wstring error; + g.addresses_iterator = g.addresses; + if (!relay_connect_step(error)) { + show_error_message(error.c_str()); + return 1; + } + + while (process_messages(accelerators)) { + DWORD result = MsgWaitForMultipleObjects( + g.event != NULL, &g.event, FALSE, INFINITE, QS_ALLINPUT); + if (result == WAIT_FAILED) { + show_error_message(format_error_message(GetLastError()).c_str()); + return 1; + } + if (g.event != NULL && result == WAIT_OBJECT_0 && + !relay_process_socket_events(error)) { + show_error_message(error.c_str()); + return 1; + } + } + FreeAddrInfo(g.addresses); + WSACleanup(); + return 0; +} diff --git a/xW/xW.manifest b/xW/xW.manifest new file mode 100644 index 0000000..68bb0f4 --- /dev/null +++ b/xW/xW.manifest @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> +<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> + <assemblyIdentity name="xW" version="1.0.0.0" type="win32" /> + <dependency> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Windows.Common-Controls" + version="6.0.0.0" type="win32" processorArchitecture="*" + publicKeyToken="6595b64144ccf1df" language="*" /> + </dependentAssembly> + </dependency> +</assembly> diff --git a/xW/xW.rc b/xW/xW.rc new file mode 100644 index 0000000..858b104 --- /dev/null +++ b/xW/xW.rc @@ -0,0 +1,23 @@ +#include <windows.h> +#include "xW-resources.h" + +// Beware of this madness https://gitlab.kitware.com/cmake/cmake/-/issues/23066 +CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "xW.manifest" + +IDI_ICON ICON "xW.ico" +IDI_HIGHLIGHTED ICON "xW-highlighted.ico" + +IDA_ACCELERATORS ACCELERATORS +BEGIN + "^p", ID_PREVIOUS_BUFFER + "^n", ID_NEXT_BUFFER + VK_F5, ID_PREVIOUS_BUFFER, VIRTKEY + VK_F6, ID_NEXT_BUFFER, VIRTKEY + VK_PRIOR, ID_PREVIOUS_BUFFER, CONTROL, VIRTKEY + VK_NEXT, ID_NEXT_BUFFER, CONTROL, VIRTKEY + VK_TAB, ID_SWITCH_BUFFER, CONTROL, VIRTKEY + "!", ID_GOTO_HIGHLIGHT, ALT + "a", ID_GOTO_ACTIVITY, ALT + "H", ID_TOGGLE_UNIMPORTANT, ALT + "h", ID_DISPLAY_FULL_LOG, ALT +END diff --git a/xW/xW.svg b/xW/xW.svg new file mode 100644 index 0000000..d68c63e --- /dev/null +++ b/xW/xW.svg @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="48" height="48" viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <clipPath id="outer"> + <rect x="-1" y="-0.15" width="5" height="3.30" /> + </clipPath> + <clipPath id="inner"> + <rect x="-1" y="0" width="5" height="3" /> + </clipPath> + </defs> + + <g transform="translate(6, 6) scale(12)" stroke-linecap="square"> + <g clip-path="url(#outer)"> + <path stroke="#ffffff" stroke-width="1.5" d="M 0.5,0 2.5,3" /> + <path stroke="#ffffff" stroke-width="1.5" d="M 0.5,3 2.5,0" /> + </g> + <g clip-path="url(#inner)"> + <path stroke="#000000" stroke-width="0.2" d="M 0,0 2,3 M 1,0 3,3" /> + <path stroke="#ff6600" stroke-width="0.3" d="M 0,3 2,0 M 1,3 3,0" /> + </g> + </g> +</svg> |