diff --git a/CMakeLists.txt b/CMakeLists.txt index 555ea3c..7f613a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,12 +15,14 @@ set(CMAKE_CXX_EXTENSIONS OFF) find_package(PkgConfig REQUIRED) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) pkg_check_modules(LIBVNCCLIENT REQUIRED IMPORTED_TARGET libvncclient) +include(CTest) add_executable(wayviewer src/input.cpp src/main.cpp src/options.cpp src/sdl_app.cpp + src/ui_prompt.cpp src/viewer_state.cpp src/vnc_client.cpp ) @@ -44,3 +46,17 @@ configure_file( install(TARGETS wayviewer RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/wayviewer.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) + +if(BUILD_TESTING) + add_executable(wayviewer_input_tests + tests/input_tests.cpp + src/input.cpp + ) + + target_link_libraries(wayviewer_input_tests PRIVATE + PkgConfig::SDL2 + PkgConfig::LIBVNCCLIENT + ) + + add_test(NAME input_mapping COMMAND wayviewer_input_tests) +endif() diff --git a/README.md b/README.md index eaf4406..505c443 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,14 @@ Use `./build/wayviewer --help` for all options. ## Launcher -Launching `wayviewer` without arguments from wofi opens graphical prompts for: +Launching `wayviewer` without arguments from wofi opens a small floating-friendly window with one column of fields: -- server address -- username -- password -- quality +- Host +- Username +- Password +- Quality + +Use `Tab` or the arrow keys to move between fields, `Enter` to connect, and `Escape`, `Ctrl+Q`, the window close button, or `Quit` to exit. Install the binary and desktop entry into your user prefix: @@ -78,7 +80,8 @@ Then open wofi in `drun` mode and choose `wayviewer`. ## Source Layout - `src/main.cpp`: process entry point -- `src/options.*`: command-line parsing and wofi or terminal prompts +- `src/options.*`: command-line parsing and terminal prompts +- `src/ui_prompt.*`: graphical connection form - `src/vnc_client.*`: VNC client setup and callbacks - `src/viewer_state.*`: framebuffer state and dirty rectangles - `src/input.*`: SDL input translation diff --git a/src/input.cpp b/src/input.cpp index 9248251..d2cfb40 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -7,12 +7,72 @@ namespace wayviewer { -std::uint32_t mapSdlKey(SDL_Keycode key) +namespace { + +char shiftedAscii(SDL_Keycode key) { + switch (key) { + case SDLK_1: return '!'; + case SDLK_2: return '@'; + case SDLK_3: return '#'; + case SDLK_4: return '$'; + case SDLK_5: return '%'; + case SDLK_6: return '^'; + case SDLK_7: return '&'; + case SDLK_8: return '*'; + case SDLK_9: return '('; + case SDLK_0: return ')'; + case SDLK_MINUS: return '_'; + case SDLK_EQUALS: return '+'; + case SDLK_LEFTBRACKET: return '{'; + case SDLK_RIGHTBRACKET: return '}'; + case SDLK_BACKSLASH: return '|'; + case SDLK_SEMICOLON: return ':'; + case SDLK_QUOTE: return '"'; + case SDLK_COMMA: return '<'; + case SDLK_PERIOD: return '>'; + case SDLK_SLASH: return '?'; + case SDLK_BACKQUOTE: return '~'; + default: return 0; + } +} + +std::uint32_t printableKeySym(const SDL_Keysym& keysym) +{ + const SDL_Keycode key = keysym.sym; + const bool shift = (keysym.mod & KMOD_SHIFT) != 0; + const bool caps = (keysym.mod & KMOD_CAPS) != 0; + + if (key >= SDLK_a && key <= SDLK_z) { + if (shift != caps) { + return static_cast('A' + (key - SDLK_a)); + } + return static_cast('a' + (key - SDLK_a)); + } + + if (shift) { + const char shifted = shiftedAscii(key); + if (shifted != 0) { + return static_cast(shifted); + } + } + if (key >= 0x20 && key <= 0x7e) { return static_cast(key); } + return 0; +} + +} + +std::uint32_t mapSdlKey(const SDL_Keysym& keysym) +{ + if (const std::uint32_t printable = printableKeySym(keysym)) { + return printable; + } + + const SDL_Keycode key = keysym.sym; switch (key) { case SDLK_BACKSPACE: return XK_BackSpace; case SDLK_TAB: return XK_Tab; diff --git a/src/input.hpp b/src/input.hpp index c2d3890..60046c8 100644 --- a/src/input.hpp +++ b/src/input.hpp @@ -7,7 +7,7 @@ namespace wayviewer { -std::uint32_t mapSdlKey(SDL_Keycode key); +std::uint32_t mapSdlKey(const SDL_Keysym& keysym); int buttonMaskFromMouseState(std::uint32_t buttons); std::pair localToRemote(int localX, int localY, int windowW, int windowH, int remoteW, int remoteH); diff --git a/src/options.cpp b/src/options.cpp index 8ddbbd8..c03ae92 100644 --- a/src/options.cpp +++ b/src/options.cpp @@ -1,18 +1,15 @@ #include "options.hpp" #include -#include #include -#include #include #include #include #include -#include -#include #include #include -#include + +#include "ui_prompt.hpp" namespace wayviewer { namespace { @@ -100,18 +97,6 @@ std::string readPasswordFile(const std::string& path) return password; } -void parseTarget(Options& options, const std::string& target) -{ - const auto colon = target.rfind(':'); - if (colon == std::string::npos || colon == 0 || colon == target.size() - 1) { - options.host = target; - return; - } - - options.host = target.substr(0, colon); - options.port = parsePort(target.substr(colon + 1)); -} - std::string promptLine(const std::string& label, const std::string& defaultValue = {}) { std::cout << label; @@ -148,158 +133,18 @@ std::string promptPassword(const std::string& label) return value; } -bool hasGraphicalSession() -{ - return std::getenv("WAYLAND_DISPLAY") || std::getenv("DISPLAY"); } -bool commandExists(const char* command) +void parseTarget(Options& options, const std::string& target) { - const char* path = std::getenv("PATH"); - if (!path) { - return false; + const auto colon = target.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon == target.size() - 1) { + options.host = target; + return; } - std::string paths = path; - std::size_t start = 0; - while (start <= paths.size()) { - const std::size_t end = paths.find(':', start); - const std::string directory = paths.substr(start, end == std::string::npos ? std::string::npos : end - start); - const std::string candidate = (directory.empty() ? "." : directory) + "/" + command; - if (access(candidate.c_str(), X_OK) == 0) { - return true; - } - if (end == std::string::npos) { - break; - } - start = end + 1; - } - return false; -} - -void writeAll(int fd, std::string_view data) -{ - const char* current = data.data(); - std::size_t remaining = data.size(); - while (remaining > 0) { - const ssize_t written = write(fd, current, remaining); - if (written < 0) { - if (errno == EINTR) { - continue; - } - return; - } - current += written; - remaining -= static_cast(written); - } -} - -std::string readAll(int fd) -{ - std::string output; - std::array buffer {}; - while (true) { - const ssize_t count = read(fd, buffer.data(), buffer.size()); - if (count < 0) { - if (errno == EINTR) { - continue; - } - break; - } - if (count == 0) { - break; - } - output.append(buffer.data(), static_cast(count)); - } - return output; -} - -std::string trimLineEnd(std::string value) -{ - while (!value.empty() && (value.back() == '\n' || value.back() == '\r')) { - value.pop_back(); - } - return value; -} - -std::string runWofi(const std::vector& args, const std::string& input) -{ - int stdinPipe[2] {}; - int stdoutPipe[2] {}; - if (pipe(stdinPipe) != 0 || pipe(stdoutPipe) != 0) { - throw std::runtime_error("failed to open prompt pipes"); - } - - const pid_t pid = fork(); - if (pid < 0) { - close(stdinPipe[0]); - close(stdinPipe[1]); - close(stdoutPipe[0]); - close(stdoutPipe[1]); - throw std::runtime_error("failed to launch wofi"); - } - - if (pid == 0) { - dup2(stdinPipe[0], STDIN_FILENO); - dup2(stdoutPipe[1], STDOUT_FILENO); - close(stdinPipe[0]); - close(stdinPipe[1]); - close(stdoutPipe[0]); - close(stdoutPipe[1]); - - std::vector argv; - argv.push_back(const_cast("wofi")); - for (const std::string& arg : args) { - argv.push_back(const_cast(arg.c_str())); - } - argv.push_back(nullptr); - execvp("wofi", argv.data()); - _exit(127); - } - - close(stdinPipe[0]); - close(stdoutPipe[1]); - writeAll(stdinPipe[1], input); - close(stdinPipe[1]); - - std::string output = readAll(stdoutPipe[0]); - close(stdoutPipe[0]); - - int status = 0; - while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { - } - - if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { - throw std::runtime_error("prompt cancelled"); - } - - return trimLineEnd(output); -} - -std::string wofiTextPrompt(const std::string& label) -{ - return runWofi({"--dmenu", "--exec-search", "--prompt", label, "--lines", "1", "--width", "520"}, "\n"); -} - -std::string wofiPasswordPrompt(const std::string& label) -{ - return runWofi({"--dmenu", "--password", "--exec-search", "--prompt", label, "--lines", "1", "--width", "520"}, "\n"); -} - -Quality wofiQualityPrompt(Quality current) -{ - const char* selected = current == Quality::Low ? "low\nmedium\nhigh\n" : current == Quality::High ? "high\nmedium\nlow\n" : "medium\nhigh\nlow\n"; - return parseQuality(runWofi({"--dmenu", "--no-custom-entry", "--prompt", "Quality", "--lines", "3", "--width", "320"}, selected)); -} - -bool shouldUseWofi() -{ - if (const char* ui = std::getenv("WAYVIEWER_UI")) { - return std::string(ui) == "wofi"; - } - return hasGraphicalSession() && commandExists("wofi") && !isatty(STDIN_FILENO); -} - + options.host = target.substr(0, colon); + options.port = parsePort(target.substr(colon + 1)); } Options parseArgs(int argc, char** argv) @@ -372,24 +217,8 @@ void promptForMissingOptions(Options& options) return; } - if (shouldUseWofi()) { - if (options.host.empty()) { - const std::string target = wofiTextPrompt("VNC host or host:port"); - if (target.empty()) { - throw std::runtime_error("missing VNC host"); - } - parseTarget(options, target); - } - - if (options.username.empty()) { - options.username = wofiTextPrompt("Username"); - } - - if (options.password.empty()) { - options.password = wofiPasswordPrompt("Password"); - } - - options.quality = wofiQualityPrompt(options.quality); + if (shouldUseOptionsWindow()) { + showOptionsWindow(options); return; } diff --git a/src/options.hpp b/src/options.hpp index 67b17ee..2484e2d 100644 --- a/src/options.hpp +++ b/src/options.hpp @@ -25,5 +25,6 @@ struct Options { Options parseArgs(int argc, char** argv); void promptForMissingOptions(Options& options); +void parseTarget(Options& options, const std::string& target); } diff --git a/src/sdl_app.cpp b/src/sdl_app.cpp index 2589407..75a2354 100644 --- a/src/sdl_app.cpp +++ b/src/sdl_app.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace wayviewer { namespace { @@ -189,6 +190,7 @@ int runViewer(int argc, char** argv) int buttonMask = 0; bool running = true; bool needsRender = true; + std::unordered_map pressedKeys; while (running && state.connected) { const int inputRemoteW = state.width; @@ -216,7 +218,21 @@ int runViewer(int argc, char** argv) case SDL_KEYDOWN: case SDL_KEYUP: if (!options.viewOnly) { - const auto key = mapSdlKey(event.key.keysym.sym); + std::uint32_t key = 0; + if (event.type == SDL_KEYDOWN) { + key = mapSdlKey(event.key.keysym); + if (!event.key.repeat && key != 0) { + pressedKeys[event.key.keysym.scancode] = key; + } + } else { + const auto found = pressedKeys.find(event.key.keysym.scancode); + if (found != pressedKeys.end()) { + key = found->second; + pressedKeys.erase(found); + } else { + key = mapSdlKey(event.key.keysym); + } + } if (key != 0) { SendKeyEvent(client.get(), key, event.type == SDL_KEYDOWN); } diff --git a/src/ui_prompt.cpp b/src/ui_prompt.cpp new file mode 100644 index 0000000..cec5d6d --- /dev/null +++ b/src/ui_prompt.cpp @@ -0,0 +1,528 @@ +#include "ui_prompt.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wayviewer { +namespace { + +struct Rect { + int x = 0; + int y = 0; + int w = 0; + int h = 0; +}; + +enum class Focus { + Host, + Username, + Password, + Quality, +}; + +struct FormState { + std::string host; + std::string username; + std::string password; + Quality quality = Quality::Medium; + Focus focus = Focus::Host; + std::string error; + bool submitted = false; + bool cancelled = false; +}; + +constexpr int scale = 3; +constexpr int glyphW = 5; +constexpr int glyphH = 7; +constexpr int advance = 6 * scale; + +const std::array& glyph(char c) +{ + static const std::array blank = {"00000", "00000", "00000", "00000", "00000", "00000", "00000"}; + static const std::array unknown = {"11111", "10001", "00001", "00010", "00100", "00000", "00100"}; + static const std::array, 96> table = {{ + {{"00000", "00000", "00000", "00000", "00000", "00000", "00000"}}, + {{"00100", "00100", "00100", "00100", "00100", "00000", "00100"}}, + {{"01010", "01010", "01010", "00000", "00000", "00000", "00000"}}, + {{"01010", "01010", "11111", "01010", "11111", "01010", "01010"}}, + {{"00100", "01111", "10100", "01110", "00101", "11110", "00100"}}, + {{"11000", "11001", "00010", "00100", "01000", "10011", "00011"}}, + {{"01100", "10010", "10100", "01000", "10101", "10010", "01101"}}, + {{"00100", "00100", "01000", "00000", "00000", "00000", "00000"}}, + {{"00010", "00100", "01000", "01000", "01000", "00100", "00010"}}, + {{"01000", "00100", "00010", "00010", "00010", "00100", "01000"}}, + {{"00000", "00100", "10101", "01110", "10101", "00100", "00000"}}, + {{"00000", "00100", "00100", "11111", "00100", "00100", "00000"}}, + {{"00000", "00000", "00000", "00000", "00100", "00100", "01000"}}, + {{"00000", "00000", "00000", "11111", "00000", "00000", "00000"}}, + {{"00000", "00000", "00000", "00000", "00000", "01100", "01100"}}, + {{"00001", "00010", "00100", "01000", "10000", "00000", "00000"}}, + {{"01110", "10001", "10011", "10101", "11001", "10001", "01110"}}, + {{"00100", "01100", "00100", "00100", "00100", "00100", "01110"}}, + {{"01110", "10001", "00001", "00010", "00100", "01000", "11111"}}, + {{"11110", "00001", "00001", "01110", "00001", "00001", "11110"}}, + {{"00010", "00110", "01010", "10010", "11111", "00010", "00010"}}, + {{"11111", "10000", "10000", "11110", "00001", "00001", "11110"}}, + {{"01110", "10000", "10000", "11110", "10001", "10001", "01110"}}, + {{"11111", "00001", "00010", "00100", "01000", "01000", "01000"}}, + {{"01110", "10001", "10001", "01110", "10001", "10001", "01110"}}, + {{"01110", "10001", "10001", "01111", "00001", "00001", "01110"}}, + {{"00000", "01100", "01100", "00000", "01100", "01100", "00000"}}, + {{"00000", "01100", "01100", "00000", "01100", "00100", "01000"}}, + {{"00010", "00100", "01000", "10000", "01000", "00100", "00010"}}, + {{"00000", "00000", "11111", "00000", "11111", "00000", "00000"}}, + {{"01000", "00100", "00010", "00001", "00010", "00100", "01000"}}, + {{"01110", "10001", "00001", "00010", "00100", "00000", "00100"}}, + {{"01110", "10001", "10111", "10101", "10111", "10000", "01110"}}, + {{"01110", "10001", "10001", "11111", "10001", "10001", "10001"}}, + {{"11110", "10001", "10001", "11110", "10001", "10001", "11110"}}, + {{"01110", "10001", "10000", "10000", "10000", "10001", "01110"}}, + {{"11110", "10001", "10001", "10001", "10001", "10001", "11110"}}, + {{"11111", "10000", "10000", "11110", "10000", "10000", "11111"}}, + {{"11111", "10000", "10000", "11110", "10000", "10000", "10000"}}, + {{"01110", "10001", "10000", "10111", "10001", "10001", "01110"}}, + {{"10001", "10001", "10001", "11111", "10001", "10001", "10001"}}, + {{"01110", "00100", "00100", "00100", "00100", "00100", "01110"}}, + {{"00001", "00001", "00001", "00001", "10001", "10001", "01110"}}, + {{"10001", "10010", "10100", "11000", "10100", "10010", "10001"}}, + {{"10000", "10000", "10000", "10000", "10000", "10000", "11111"}}, + {{"10001", "11011", "10101", "10101", "10001", "10001", "10001"}}, + {{"10001", "11001", "10101", "10011", "10001", "10001", "10001"}}, + {{"01110", "10001", "10001", "10001", "10001", "10001", "01110"}}, + {{"11110", "10001", "10001", "11110", "10000", "10000", "10000"}}, + {{"01110", "10001", "10001", "10001", "10101", "10010", "01101"}}, + {{"11110", "10001", "10001", "11110", "10100", "10010", "10001"}}, + {{"01111", "10000", "10000", "01110", "00001", "00001", "11110"}}, + {{"11111", "00100", "00100", "00100", "00100", "00100", "00100"}}, + {{"10001", "10001", "10001", "10001", "10001", "10001", "01110"}}, + {{"10001", "10001", "10001", "10001", "10001", "01010", "00100"}}, + {{"10001", "10001", "10001", "10101", "10101", "10101", "01010"}}, + {{"10001", "10001", "01010", "00100", "01010", "10001", "10001"}}, + {{"10001", "10001", "01010", "00100", "00100", "00100", "00100"}}, + {{"11111", "00001", "00010", "00100", "01000", "10000", "11111"}}, + {{"01110", "01000", "01000", "01000", "01000", "01000", "01110"}}, + {{"10000", "01000", "00100", "00010", "00001", "00000", "00000"}}, + {{"01110", "00010", "00010", "00010", "00010", "00010", "01110"}}, + {{"00100", "01010", "10001", "00000", "00000", "00000", "00000"}}, + {{"00000", "00000", "00000", "00000", "00000", "00000", "11111"}}, + {{"01000", "00100", "00010", "00000", "00000", "00000", "00000"}}, + {{"00000", "00000", "01110", "00001", "01111", "10001", "01111"}}, + {{"10000", "10000", "11110", "10001", "10001", "10001", "11110"}}, + {{"00000", "00000", "01110", "10000", "10000", "10000", "01110"}}, + {{"00001", "00001", "01111", "10001", "10001", "10001", "01111"}}, + {{"00000", "00000", "01110", "10001", "11111", "10000", "01110"}}, + {{"00110", "01001", "01000", "11100", "01000", "01000", "01000"}}, + {{"00000", "00000", "01111", "10001", "10001", "01111", "00001"}}, + {{"10000", "10000", "11110", "10001", "10001", "10001", "10001"}}, + {{"00100", "00000", "01100", "00100", "00100", "00100", "01110"}}, + {{"00010", "00000", "00110", "00010", "00010", "10010", "01100"}}, + {{"10000", "10000", "10010", "10100", "11000", "10100", "10010"}}, + {{"01100", "00100", "00100", "00100", "00100", "00100", "01110"}}, + {{"00000", "00000", "11010", "10101", "10101", "10101", "10101"}}, + {{"00000", "00000", "11110", "10001", "10001", "10001", "10001"}}, + {{"00000", "00000", "01110", "10001", "10001", "10001", "01110"}}, + {{"00000", "00000", "11110", "10001", "10001", "11110", "10000"}}, + {{"00000", "00000", "01111", "10001", "10001", "01111", "00001"}}, + {{"00000", "00000", "10110", "11001", "10000", "10000", "10000"}}, + {{"00000", "00000", "01111", "10000", "01110", "00001", "11110"}}, + {{"01000", "01000", "11100", "01000", "01000", "01001", "00110"}}, + {{"00000", "00000", "10001", "10001", "10001", "10011", "01101"}}, + {{"00000", "00000", "10001", "10001", "10001", "01010", "00100"}}, + {{"00000", "00000", "10001", "10001", "10101", "10101", "01010"}}, + {{"00000", "00000", "10001", "01010", "00100", "01010", "10001"}}, + {{"00000", "00000", "10001", "10001", "10001", "01111", "00001"}}, + {{"00000", "00000", "11111", "00010", "00100", "01000", "11111"}}, + {{"00010", "00100", "00100", "01000", "00100", "00100", "00010"}}, + {{"00100", "00100", "00100", "00000", "00100", "00100", "00100"}}, + {{"01000", "00100", "00100", "00010", "00100", "00100", "01000"}}, + {{"01000", "10101", "00010", "00000", "00000", "00000", "00000"}} + }}; + + if (c < 32 || c > 126) { + return unknown; + } + if (c == ' ') { + return blank; + } + return table[static_cast(c - 32)]; +} + +void color(SDL_Renderer* renderer, int r, int g, int b) +{ + SDL_SetRenderDrawColor(renderer, static_cast(r), static_cast(g), static_cast(b), 255); +} + +void fill(SDL_Renderer* renderer, Rect rect) +{ + SDL_Rect sdlRect{rect.x, rect.y, rect.w, rect.h}; + SDL_RenderFillRect(renderer, &sdlRect); +} + +void stroke(SDL_Renderer* renderer, Rect rect) +{ + SDL_Rect sdlRect{rect.x, rect.y, rect.w, rect.h}; + SDL_RenderDrawRect(renderer, &sdlRect); +} + +int textWidth(const std::string& text) +{ + return static_cast(text.size()) * advance; +} + +void drawText(SDL_Renderer* renderer, int x, int y, const std::string& text, int r, int g, int b) +{ + color(renderer, r, g, b); + int cursor = x; + for (char c : text) { + const auto& rows = glyph(c); + for (int row = 0; row < glyphH; ++row) { + for (int col = 0; col < glyphW; ++col) { + if (rows[static_cast(row)][col] == '1') { + fill(renderer, Rect{cursor + col * scale, y + row * scale, scale, scale}); + } + } + } + cursor += advance; + } +} + +bool contains(Rect rect, int x, int y) +{ + return x >= rect.x && y >= rect.y && x < rect.x + rect.w && y < rect.y + rect.h; +} + +std::string qualityName(Quality quality) +{ + switch (quality) { + case Quality::Low: return "Low"; + case Quality::Medium: return "Medium"; + case Quality::High: return "High"; + } + return "Medium"; +} + +Quality nextQuality(Quality quality) +{ + switch (quality) { + case Quality::Low: return Quality::Medium; + case Quality::Medium: return Quality::High; + case Quality::High: return Quality::Low; + } + return Quality::Medium; +} + +std::string visibleFieldText(const std::string& value, bool password) +{ + if (!password) { + return value; + } + return std::string(value.size(), '*'); +} + +std::string fitText(const std::string& value, int maxWidth) +{ + if (textWidth(value) <= maxWidth) { + return value; + } + + std::string fitted = value; + while (!fitted.empty() && textWidth("..." + fitted) > maxWidth) { + fitted.erase(fitted.begin()); + } + return "..." + fitted; +} + +void drawField(SDL_Renderer* renderer, Rect rect, const std::string& label, const std::string& value, bool focused, bool password) +{ + drawText(renderer, rect.x, rect.y - 28, label, 218, 222, 226); + color(renderer, focused ? 66 : 38, focused ? 122 : 42, focused ? 176 : 50); + fill(renderer, rect); + color(renderer, focused ? 124 : 82, focused ? 177 : 92, focused ? 255 : 108); + stroke(renderer, rect); + const std::string display = fitText(visibleFieldText(value, password), rect.w - 24); + drawText(renderer, rect.x + 12, rect.y + 13, display, 245, 247, 250); +} + +void drawButton(SDL_Renderer* renderer, Rect rect, const std::string& label, bool primary) +{ + color(renderer, primary ? 52 : 45, primary ? 119 : 48, primary ? 235 : 54); + fill(renderer, rect); + color(renderer, primary ? 117 : 83, primary ? 169 : 91, primary ? 255 : 105); + stroke(renderer, rect); + drawText(renderer, rect.x + (rect.w - textWidth(label)) / 2, rect.y + 14, label, 250, 250, 252); +} + +void render(SDL_Renderer* renderer, const FormState& form) +{ + color(renderer, 18, 20, 24); + SDL_RenderClear(renderer); + + drawText(renderer, 32, 26, "wayviewer", 245, 247, 250); + drawText(renderer, 32, 58, "Connect to VNC", 156, 163, 175); + + const Rect host{32, 120, 456, 48}; + const Rect username{32, 206, 456, 48}; + const Rect password{32, 292, 456, 48}; + const Rect quality{32, 378, 456, 48}; + const Rect connect{32, 460, 216, 52}; + const Rect quit{272, 460, 216, 52}; + + drawField(renderer, host, "Host", form.host, form.focus == Focus::Host, false); + drawField(renderer, username, "Username", form.username, form.focus == Focus::Username, false); + drawField(renderer, password, "Password", form.password, form.focus == Focus::Password, true); + drawField(renderer, quality, "Quality", qualityName(form.quality), form.focus == Focus::Quality, false); + drawButton(renderer, connect, "Connect", true); + drawButton(renderer, quit, "Quit", false); + + if (!form.error.empty()) { + drawText(renderer, 32, 530, form.error, 248, 113, 113); + } + + SDL_RenderPresent(renderer); +} + +void focusNext(FormState& form) +{ + switch (form.focus) { + case Focus::Host: form.focus = Focus::Username; break; + case Focus::Username: form.focus = Focus::Password; break; + case Focus::Password: form.focus = Focus::Quality; break; + case Focus::Quality: form.focus = Focus::Host; break; + } +} + +void focusPrevious(FormState& form) +{ + switch (form.focus) { + case Focus::Host: form.focus = Focus::Quality; break; + case Focus::Username: form.focus = Focus::Host; break; + case Focus::Password: form.focus = Focus::Username; break; + case Focus::Quality: form.focus = Focus::Password; break; + } +} + +std::string& focusedText(FormState& form) +{ + if (form.focus == Focus::Host) { + return form.host; + } + if (form.focus == Focus::Username) { + return form.username; + } + return form.password; +} + +void submit(FormState& form) +{ + if (form.host.empty()) { + form.error = "Host is required"; + form.focus = Focus::Host; + return; + } + form.submitted = true; +} + +void applyMouse(FormState& form, int x, int y) +{ + const Rect host{32, 120, 456, 48}; + const Rect username{32, 206, 456, 48}; + const Rect password{32, 292, 456, 48}; + const Rect quality{32, 378, 456, 48}; + const Rect connect{32, 460, 216, 52}; + const Rect quit{272, 460, 216, 52}; + + if (contains(host, x, y)) { + form.focus = Focus::Host; + } else if (contains(username, x, y)) { + form.focus = Focus::Username; + } else if (contains(password, x, y)) { + form.focus = Focus::Password; + } else if (contains(quality, x, y)) { + form.focus = Focus::Quality; + form.quality = nextQuality(form.quality); + } else if (contains(connect, x, y)) { + submit(form); + } else if (contains(quit, x, y)) { + form.cancelled = true; + } +} + +void applyTextInput(FormState& form, const char* text) +{ + if (form.focus == Focus::Quality) { + return; + } + + std::string& value = focusedText(form); + for (const char* current = text; *current != '\0'; ++current) { + const unsigned char c = static_cast(*current); + if (c >= 32 && c <= 126 && value.size() < 256) { + value.push_back(static_cast(c)); + } + } +} + +void applyKey(FormState& form, const SDL_KeyboardEvent& key) +{ + const bool ctrl = (key.keysym.mod & KMOD_CTRL) != 0; + switch (key.keysym.sym) { + case SDLK_ESCAPE: + form.cancelled = true; + break; + case SDLK_q: + if (ctrl) { + form.cancelled = true; + } + break; + case SDLK_TAB: + if (key.keysym.mod & KMOD_SHIFT) { + focusPrevious(form); + } else { + focusNext(form); + } + break; + case SDLK_RETURN: + case SDLK_KP_ENTER: + if (form.focus == Focus::Quality) { + form.quality = nextQuality(form.quality); + } else { + submit(form); + } + break; + case SDLK_SPACE: + if (form.focus == Focus::Quality) { + form.quality = nextQuality(form.quality); + } + break; + case SDLK_BACKSPACE: + if (form.focus != Focus::Quality) { + std::string& value = focusedText(form); + if (!value.empty()) { + value.pop_back(); + } + } + break; + case SDLK_UP: + focusPrevious(form); + break; + case SDLK_DOWN: + focusNext(form); + break; + case SDLK_LEFT: + case SDLK_RIGHT: + if (form.focus == Focus::Quality) { + form.quality = nextQuality(form.quality); + } + break; + default: + break; + } +} + +bool graphicalSession() +{ + return std::getenv("WAYLAND_DISPLAY") || std::getenv("DISPLAY"); +} + +} + +bool shouldUseOptionsWindow() +{ + if (const char* ui = std::getenv("WAYVIEWER_UI")) { + return std::string(ui) == "window"; + } + return graphicalSession() && !isatty(STDIN_FILENO); +} + +void showOptionsWindow(Options& options) +{ + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + throw std::runtime_error(std::string("SDL_Init failed: ") + SDL_GetError()); + } + + SDL_SetHint(SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, "0"); + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); + + SDL_Window* rawWindow = SDL_CreateWindow("wayviewer", + SDL_WINDOWPOS_CENTERED, + SDL_WINDOWPOS_CENTERED, + 520, + 580, + SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_UTILITY); + if (!rawWindow) { + SDL_QuitSubSystem(SDL_INIT_VIDEO); + throw std::runtime_error(std::string("SDL_CreateWindow failed: ") + SDL_GetError()); + } + + SDL_Renderer* rawRenderer = SDL_CreateRenderer(rawWindow, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + if (!rawRenderer) { + rawRenderer = SDL_CreateRenderer(rawWindow, -1, SDL_RENDERER_SOFTWARE); + } + if (!rawRenderer) { + SDL_DestroyWindow(rawWindow); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + throw std::runtime_error(std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); + } + + SDL_SetWindowAlwaysOnTop(rawWindow, SDL_TRUE); + SDL_RaiseWindow(rawWindow); + + FormState form; + form.host = options.host; + form.username = options.username; + form.password = options.password; + form.quality = options.quality; + + SDL_StartTextInput(); + + while (!form.submitted && !form.cancelled) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_QUIT: + form.cancelled = true; + break; + case SDL_MOUSEBUTTONDOWN: + applyMouse(form, event.button.x, event.button.y); + break; + case SDL_TEXTINPUT: + applyTextInput(form, event.text.text); + break; + case SDL_KEYDOWN: + applyKey(form, event.key); + break; + default: + break; + } + } + + render(rawRenderer, form); + SDL_Delay(16); + } + + SDL_StopTextInput(); + SDL_DestroyRenderer(rawRenderer); + SDL_DestroyWindow(rawWindow); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + + if (form.cancelled) { + throw std::runtime_error("cancelled"); + } + + options.host.clear(); + options.port = 5900; + parseTarget(options, form.host); + options.username = form.username; + options.password = form.password; + options.quality = form.quality; +} + +} diff --git a/src/ui_prompt.hpp b/src/ui_prompt.hpp new file mode 100644 index 0000000..7e9f3b2 --- /dev/null +++ b/src/ui_prompt.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "options.hpp" + +namespace wayviewer { + +void showOptionsWindow(Options& options); +bool shouldUseOptionsWindow(); + +} diff --git a/tests/input_tests.cpp b/tests/input_tests.cpp new file mode 100644 index 0000000..329db61 --- /dev/null +++ b/tests/input_tests.cpp @@ -0,0 +1,31 @@ +#include "../src/input.hpp" + +#include +#include + +#include + +namespace { + +SDL_Keysym keysym(SDL_Keycode key, Uint16 mod = KMOD_NONE) +{ + SDL_Keysym value {}; + value.sym = key; + value.mod = mod; + value.scancode = SDL_GetScancodeFromKey(key); + return value; +} + +} + +int main() +{ + assert(wayviewer::mapSdlKey(keysym(SDLK_a)) == 'a'); + assert(wayviewer::mapSdlKey(keysym(SDLK_a, KMOD_LSHIFT)) == 'A'); + assert(wayviewer::mapSdlKey(keysym(SDLK_a, KMOD_CAPS)) == 'A'); + assert(wayviewer::mapSdlKey(keysym(SDLK_a, KMOD_LSHIFT | KMOD_CAPS)) == 'a'); + assert(wayviewer::mapSdlKey(keysym(SDLK_1, KMOD_LSHIFT)) == '!'); + assert(wayviewer::mapSdlKey(keysym(SDLK_SLASH, KMOD_LSHIFT)) == '?'); + assert(wayviewer::mapSdlKey(keysym(SDLK_LSHIFT)) == XK_Shift_L); + return 0; +}