From d7b69379a45644729bbaaa3d3f3aa2faa818a480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Mon, 10 Aug 2026 21:04:45 +0200 Subject: [PATCH 1/3] The clipboard is ours to write, and on X11 a write is a promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: `clipboard_set_text` as a platform seam, never `SDL_SetClipboardText`. ADDED: an X11 selection owner on a thread of its own — its own `Display`, poked through a self-pipe, serving TARGETS, TIMESTAMP, UTF8_STRING, the two text/plain forms and STRING for the life of the process. - A selection is a live window answering `SelectionRequest`, not a place to put bytes: the owner has to still be there when the paste happens. - STRING is served the same UTF-8 bytes deliberately. It is nominally Latin-1, but everything that can read UTF-8 asks for UTF8_STRING first, and refusing STRING leaves the rest with nothing. ADDED: the Windows write, `OpenClipboard`/`CF_UNICODETEXT`, retried while another application holds the lock. ADDED: `kMaxClipboardWrite`, 64KB — one `XChangeProperty`, no INCR on this side. CHANGED: the write blocks until the server reports us as the owner, bounded at 250ms. - The caller hands the focus back to the game immediately afterwards and Wine re-reads the selection around that focus change, so returning early is a first paste of the previous clipboard. Measured at 0.1-0.7ms. --- docs/platform.md | 21 +++ src/platform/clipboard.hpp | 21 +++ src/platform/clipboard_win.cpp | 32 +++++ src/platform/clipboard_x11.cpp | 250 ++++++++++++++++++++++++++++++++- 4 files changed, 318 insertions(+), 6 deletions(-) diff --git a/docs/platform.md b/docs/platform.md index cb023a6..974b21e 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -78,6 +78,27 @@ The seams (windowing still comes free from SDL3; the clipboard did not — see b arrives as a plain hyphen (1277 bytes against 1291 for the same item). Polling therefore sees the two forms alternate, which is why one press of the hotkey used to show affixes and the next did not. `parse_info_line` accepts either separator; **do not "fix" that by trusting the encoding.** +- **`platform/clipboard.hpp` — `clipboard_set_text(text)`:** the write, for QuickPaste. Not + `SDL_SetClipboardText`, and the reason is not the read path's: **X11 has no clipboard to put + something into.** A selection is a live window answering `SelectionRequest`, so a write is a + promise to still be there when the paste happens — which here is Wine asking, after the popup + has closed. So the owner is a window on a thread of its own with its own `Display`, started on + the first write and never stopped; the main thread hands text over under a mutex and pokes a + self-pipe, because **that Display is touched only by that thread** (the hotkey listener's rule, + and the same abort behind it). It answers `TARGETS`, `TIMESTAMP`, `UTF8_STRING`, + `text/plain;charset=utf-8`, `text/plain` and `STRING`, takes ownership with a **real server + timestamp** (the zero-length property append, as the handover does — ICCCM wants an owner able + to answer `TIMESTAMP` truthfully), and drops the text on `SelectionClear` rather than serving + something it no longer owns. `STRING` is served the same UTF-8 bytes on purpose: it is + nominally Latin-1, but everything that can read UTF-8 asks for `UTF8_STRING` first and refusing + `STRING` leaves the rest with nothing. **The call blocks until ownership is asserted** (a + condition variable, bounded at 250ms): the caller hands the focus back to the game immediately + afterwards and Wine re-reads the selection around that focus change, so returning before the + server has us as the owner is a first paste of the previous clipboard — measured, reported, and + the reason this is not fire-and-forget. **No INCR on this side** — one `XChangeProperty`, hence + `kMaxClipboardWrite` (64KB) and an editor that will not store more. Windows is the ordinary + `OpenClipboard`/`CF_UNICODETEXT` write, retried while another application holds the lock. + → [quickpaste.md](quickpaste.md) - **`clipboard_wedge_note` in `App` concludes from the owner's behaviour, never from its identity** — and that is the second attempt, the first two having been wrong in ways worth recording so nobody rebuilds them. It says the one thing the app can state truthfully about diff --git a/src/platform/clipboard.hpp b/src/platform/clipboard.hpp index ac1a2aa..8a25452 100644 --- a/src/platform/clipboard.hpp +++ b/src/platform/clipboard.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -49,6 +50,26 @@ uint64_t clipboard_stamp(); /// serving from the previous copy. No-op on Windows, where the clipboard is already rendered. void clipboard_poke(); +/// The largest value the X11 half will take ownership of. There is no INCR on the write side — +/// a value is handed over in one property — so this is the ceiling a single `XChangeProperty` +/// can be relied on for on any server. A paste is a chat line or a map regex; nothing that +/// belongs on this path comes near it. +inline constexpr size_t kMaxClipboardWrite = 64 * 1024; + +/// Put UTF-8 `text` on the clipboard, replacing whatever was there. False when it could not be +/// taken — no display, or `text` past `kMaxClipboardWrite`. +/// +/// **Not `SDL_SetClipboardText`**, for the same reason nothing else here is SDL's: the X11 +/// clipboard is not a store but an owner, and this owner has to outlive whatever window asked +/// for the write. A selection is served from a live window answering `SelectionRequest`, so +/// ours lives on its own thread with its own `Display` for the life of the process — the popup +/// that made the write is long closed by the time the game asks for the bytes, and Wine only +/// asks when the user actually presses Ctrl+V. +/// +/// Windows is the ordinary `OpenClipboard`/`CF_UNICODETEXT` write, retried while another +/// application holds the lock. +bool clipboard_set_text(const std::string& text); + /// Diagnostics only: who owns the clipboard right now, as "0x class 'X' pid N". /// Server-side queries exclusively — it never asks the owner for anything, so it cannot /// perturb a handover in flight and is safe to call while waiting for one. diff --git a/src/platform/clipboard_win.cpp b/src/platform/clipboard_win.cpp index a74d5ab..af6e137 100644 --- a/src/platform/clipboard_win.cpp +++ b/src/platform/clipboard_win.cpp @@ -53,6 +53,38 @@ std::string clipboard_targets(int) { return out.empty() ? "(none)" : out; } +bool clipboard_set_text(const std::string& text) { + if (text.size() > kMaxClipboardWrite) return false; // the X11 ceiling, kept one rule + // The same retry the read path does, and for the same reason: the lock belongs to whoever + // opened it last, and the application we are pasting into may be holding it briefly. + for (int waited = 0; !OpenClipboard(nullptr); waited += 10) { + if (waited >= 200) { + debug::log("[paste] clipboard locked by %s, gave up", + window_desc(GetOpenClipboardWindow()).c_str()); + return false; + } + Sleep(10); + } + const int n = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0); + HGLOBAL mem = n > 0 ? GlobalAlloc(GMEM_MOVEABLE, static_cast(n) * sizeof(wchar_t)) + : nullptr; + bool ok = false; + if (mem) { + if (wchar_t* dst = static_cast(GlobalLock(mem))) { + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, dst, n); + GlobalUnlock(mem); + EmptyClipboard(); + // The clipboard owns the block once SetClipboardData succeeds, and only then — + // a failure leaves it ours to free. + ok = SetClipboardData(CF_UNICODETEXT, mem) != nullptr; + } + if (!ok) GlobalFree(mem); + } + CloseClipboard(); + debug::log("[paste] put %zu bytes on the clipboard (ok: %d)", text.size(), (int)ok); + return ok; +} + std::string clipboard_text(int timeout_ms) { // No async handshake here — the data is already in the clipboard. The only wait is for // the global lock, which the copying app can hold briefly; retry rather than fail. diff --git a/src/platform/clipboard_x11.cpp b/src/platform/clipboard_x11.cpp index faec3e7..c0f50e3 100644 --- a/src/platform/clipboard_x11.cpp +++ b/src/platform/clipboard_x11.cpp @@ -3,9 +3,14 @@ #include #include #include +#include #include +#include +#include +#include #include +#include #include #include @@ -148,8 +153,8 @@ std::string read_prop(Ctx& c, Atom* type_out) { } /// Atom name for tracing only; the round trip is not worth it on the hot path. -std::string atom_name(Ctx& c, Atom a) { - char* n = a ? XGetAtomName(c.d, a) : nullptr; +std::string atom_name(Display* d, Atom a) { + char* n = a ? XGetAtomName(d, a) : nullptr; std::string s = n ? n : "?"; if (n) XFree(n); return s; @@ -168,7 +173,7 @@ std::string convert(Ctx& c, Atom target, Clock::time_point deadline) { if (!wait_for(c.d, c.win, SelectionNotify, &ev, deadline)) { if (trace()) debug::trace("[copy] %s: no reply before the deadline", - atom_name(c, target).c_str()); + atom_name(c.d, target).c_str()); return {}; } if (ev.xselection.selection == c.clipboard && ev.xselection.target == target) break; @@ -176,7 +181,7 @@ std::string convert(Ctx& c, Atom target, Clock::time_point deadline) { if (ev.xselection.property == None) { // owner can't supply this format if (trace()) debug::trace("[copy] %s: owner refused (format not offered)", - atom_name(c, target).c_str()); + atom_name(c.d, target).c_str()); return {}; } @@ -185,7 +190,7 @@ std::string convert(Ctx& c, Atom target, Clock::time_point deadline) { if (type != c.incr) { XDeleteProperty(c.d, c.win, c.prop); if (trace()) - debug::trace("[copy] %s: %zu bytes", atom_name(c, target).c_str(), s.size()); + debug::trace("[copy] %s: %zu bytes", atom_name(c.d, target).c_str(), s.size()); return s; } @@ -234,15 +239,248 @@ std::string targets_list(Ctx& c, int timeout_ms) { data && fmt == 32) { Atom* list = reinterpret_cast(data); for (unsigned long i = 0; i < count; ++i) line += (line.empty() ? "" : " ") + - atom_name(c, list[i]); + atom_name(c.d, list[i]); } if (data) XFree(data); XDeleteProperty(c.d, c.win, c.prop); return line.empty() ? "(none)" : line; } +int ignore_xerror(Display*, XErrorEvent*) { return 0; } + +/// The write half: a window that owns the CLIPBOARD selection and answers for it. +/// +/// X11 has no clipboard to put something *in*. A selection is a live window that serves +/// `SelectionRequest` on demand, so a write is a promise to still be there when the paste +/// happens — which here is Wine asking, after the popup that made the write has closed and the +/// user has clicked into a chat box. Hence a thread of its own with its own `Display`, started +/// on the first write and never stopped: **the Display is touched only by that thread** (the +/// same rule the hotkey listener follows, and for the same abort), and the main thread hands +/// text over under the mutex and pokes a self-pipe. +/// +/// The text goes with the process, as an unowned selection does everywhere on X11 — a clipboard +/// manager that wants to outlive us will have taken a copy of its own. +class SelectionOwner { +public: + /// Blocks until the selection is actually ours, and **that is not a nicety**. The caller + /// hands the keyboard focus straight back to the game afterwards, and Wine re-reads the X + /// selection around a focus change: posting the text and returning meant the focus went back + /// while Wine still owned the selection, so the first paste was of the *previous* clipboard + /// and only the next one — after Wine had noticed us — came out right. Reported from the + /// game, and the whole reason this waits. + /// + /// The wait is a handful of milliseconds (two round trips on our own connection) against a + /// bound that only exists so a wedged server cannot hold the main loop. + bool set(const std::string& text) { + if (!start()) return false; + uint64_t want = 0; + { + std::lock_guard lk(mu_); + pending_ = text; + want = ++requested_; + } + poke(); + std::unique_lock lk(mu_); + const bool answered = done_.wait_for(lk, std::chrono::milliseconds(kTakeTimeoutMs), + [&] { return taken_ >= want; }); + return answered && owned_; + } + +private: + /// How long `set` waits for the thread to assert ownership. Generous: the work behind it is + /// two round trips, and anything near this bound is a server that has stopped answering. + static constexpr int kTakeTimeoutMs = 250; + + bool start() { + if (started_) return d_ != nullptr; + started_ = true; + d_ = XOpenDisplay(nullptr); + if (!d_) return false; + if (pipe(pipe_) != 0) { + XCloseDisplay(d_); + d_ = nullptr; + return false; + } + fcntl(pipe_[0], F_SETFL, O_NONBLOCK); + // A requestor that exits between asking for the selection and being answered leaves us + // writing a property on a window that is gone — a `BadWindow` whose *default* handler + // exits the process. The hotkey listener installs the same handler, and it is global + // rather than per-display, so this is belt and braces; it is here because losing the + // application to somebody else's Ctrl+V is not a failure to inherit by accident. + XSetErrorHandler(ignore_xerror); + XSetWindowAttributes attr{}; + w_ = XCreateWindow(d_, DefaultRootWindow(d_), -10, -10, 1, 1, 0, CopyFromParent, InputOnly, + CopyFromParent, 0, &attr); + // PropertyChange for the timestamp trick below; the selection events themselves are + // sent to the owner whether or not anything is selected for. + XSelectInput(d_, w_, PropertyChangeMask); + clipboard_ = XInternAtom(d_, "CLIPBOARD", False); + utf8_ = XInternAtom(d_, "UTF8_STRING", False); + plain_utf8_ = XInternAtom(d_, "text/plain;charset=utf-8", False); + plain_ = XInternAtom(d_, "text/plain", False); + targets_ = XInternAtom(d_, "TARGETS", False); + timestamp_ = XInternAtom(d_, "TIMESTAMP", False); + stamp_prop_ = XInternAtom(d_, "PPC_OWNER_TIME", False); + thread_ = std::thread([this] { run(); }); + return true; + } + + void poke() { + char c = 1; + ssize_t n = write(pipe_[1], &c, 1); + (void)n; + } + + /// A real server timestamp, from a zero-length property append on our own window. ICCCM + /// says an owner must be able to answer TIMESTAMP with the time it took the selection at, + /// and `CurrentTime` leaves it with nothing true to say. + Time server_time() { + XChangeProperty(d_, w_, stamp_prop_, XA_ATOM, 32, PropModeAppend, nullptr, 0); + XFlush(d_); + XEvent e; + if (wait_for(d_, w_, PropertyNotify, &e, + Clock::now() + std::chrono::milliseconds(kTakeTimeoutMs / 2))) + return e.xproperty.time; + return CurrentTime; + } + + void take_pending() { + uint64_t seq = 0; + { + std::lock_guard lk(mu_); + if (requested_ == taken_) return; + served_ = std::move(pending_); + seq = requested_; + } + const auto t0 = Clock::now(); + owned_since_ = server_time(); + XSetSelectionOwner(d_, clipboard_, w_, owned_since_); + XFlush(d_); + // Asked rather than assumed, and it is what `set` answers with: taking a selection can + // fail, and a paste of somebody else's clipboard is worth knowing about in the log. + const bool ok = XGetSelectionOwner(d_, clipboard_) == w_; + debug::log("[paste] put %zu bytes on the clipboard in %lldms (owner taken: %d)", + served_.size(), + (long long)std::chrono::duration_cast(Clock::now() - + t0) + .count(), + (int)ok); + { + std::lock_guard lk(mu_); + taken_ = seq; + owned_ = ok; + } + done_.notify_all(); + } + + void serve(const XSelectionRequestEvent& req) { + XSelectionEvent note{}; + note.type = SelectionNotify; + note.requestor = req.requestor; + note.selection = req.selection; + note.target = req.target; + note.time = req.time; + // An obsolete requestor sends None and means "put it where the target says". + const Atom prop = req.property == None ? req.target : req.property; + note.property = prop; + + if (req.target == targets_) { + const Atom list[] = {targets_, timestamp_, utf8_, plain_utf8_, plain_, XA_STRING}; + XChangeProperty(d_, req.requestor, prop, XA_ATOM, 32, PropModeReplace, + reinterpret_cast(list), + static_cast(std::size(list))); + } else if (req.target == timestamp_) { + const long t = static_cast(owned_since_); + XChangeProperty(d_, req.requestor, prop, XA_INTEGER, 32, PropModeReplace, + reinterpret_cast(&t), 1); + } else if (req.target == utf8_ || req.target == plain_utf8_ || req.target == plain_ || + req.target == XA_STRING) { + // STRING is nominally Latin-1 and this is UTF-8. Served anyway: every requestor + // that can read one asks for UTF8_STRING first, and refusing STRING outright + // leaves the ones that only know it with nothing at all. + XChangeProperty(d_, req.requestor, prop, req.target, 8, PropModeReplace, + reinterpret_cast(served_.data()), + static_cast(served_.size())); + } else { + note.property = None; // we cannot supply that format + } + XSendEvent(d_, req.requestor, False, 0, reinterpret_cast(¬e)); + XFlush(d_); + if (trace()) + debug::trace("[paste] served %s to 0x%lx: %s", atom_name(d_, req.target).c_str(), + req.requestor, note.property == None ? "refused" : "ok"); + } + + void run() { + const int xfd = ConnectionNumber(d_); + for (;;) { + while (XPending(d_)) { + XEvent ev; + XNextEvent(d_, &ev); + if (ev.type == SelectionRequest) { + serve(ev.xselectionrequest); + } else if (ev.type == SelectionClear && ev.xselectionclear.selection == clipboard_) { + // Somebody else copied. Drop the text rather than keep serving it: we are + // not the owner any more and the next request is not ours to answer. + served_.clear(); + debug::log("[paste] clipboard taken over by another window"); + } + } + fd_set r; + FD_ZERO(&r); + FD_SET(xfd, &r); + FD_SET(pipe_[0], &r); + const int n = select(std::max(xfd, pipe_[0]) + 1, &r, nullptr, nullptr, nullptr); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + if (FD_ISSET(pipe_[0], &r)) { + char buf[16]; + while (read(pipe_[0], buf, sizeof buf) > 0) {} // drain + take_pending(); + } + } + } + + Display* d_ = nullptr; + Window w_ = None; + Atom clipboard_ = None, utf8_ = None, plain_utf8_ = None, plain_ = None, targets_ = None, + timestamp_ = None, stamp_prop_ = None; + Time owned_since_ = CurrentTime; + std::string served_; ///< thread-only: what requests are answered with + int pipe_[2] = {-1, -1}; + std::thread thread_; + bool started_ = false; + + std::mutex mu_; + std::condition_variable done_; + std::string pending_; ///< handed to the thread; `requested_` says whether it is new + uint64_t requested_ = 0; ///< writes asked for, and `taken_` the ones that reached the server + uint64_t taken_ = 0; + bool owned_ = false; ///< the last write actually got the selection +}; + +/// Leaked on purpose, and never joined: the selection has to stay answerable for as long as the +/// process can be pasted from, and a static whose destructor joins an Xlib thread at exit is a +/// deadlock waiting for a release build to find. +SelectionOwner& owner() { + static SelectionOwner* o = new SelectionOwner(); + return *o; +} + } // namespace +bool clipboard_set_text(const std::string& text) { + if (text.size() > kMaxClipboardWrite) { + debug::log("[paste] refused %zu bytes: past the %zu-byte ceiling a single property can" + " be relied on for", + text.size(), kMaxClipboardWrite); + return false; + } + return owner().set(text); +} + void clipboard_poke() { Ctx& c = ctx(); if (!c.d) return; From 49330e4019f6d194d81842825500b32cc9aaf8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Mon, 10 Aug 2026 21:05:05 +0200 Subject: [PATCH 2/3] A hotkey opens the pastes you keep, and the number key is a position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: QuickPaste — Alt+V opens a list of saved snippets at the cursor; picking one puts its text on the clipboard, closes the popup and gives the game back the foreground. Nothing presses Ctrl+V. ADDED: the entries themselves, heading and multi-line body, in `config.json` under `pastes` and in PRIVACY.md, since they are text the user typed. ADDED: a QuickPaste tab in Settings — a read-only list to arrange, with adding and editing in a modal dialog on a draft, and drag-to-reorder on a grip handle. ADDED: picking by number key, read as `SDL_Scancode`. - A keycode test would leave the feature unusable by number outside a US layout: the same physical keys print `ěščřžýáíé` on a Czech one. ADDED: nine active slots, which is how many number keys there are. Storage is unbounded and `enabled` is what competes for a number; the ceiling holds on config load too, and the tenth checkbox is disabled rather than refused. ADDED: four Font Awesome glyphs — grip-lines, pen, trash-can, square-plus. CHANGED: `kOnlyGlyphs` is derived from `kGlyphCodepoints` rather than hand-written. - The old range covered U+F00C..U+F0E2 because that was where the first two glyphs happened to sit, so all four new ones baked as nothing. CHANGED: nothing of ours stays on screen when the game is present and a third application is in front — panels included, not only the idle marker. Panels stay up when the game is *gone*, or they would be unreachable. CHANGED: the keyboard focus is re-claimed when the game comes back to the front and on a click into a panel, not only when the panel opens. - Our window is override-redirect, so the window manager will never focus it: a panel that lost the focus to an alt-tab was left on screen taking no input. CHANGED: `Config::load` reads every field inside one `try`. - `config.json` is hand-editable and nlohmann throws as readily on a field of the wrong type as on a truncated file, so an object where a string belongs was an uncaught exception before the first window. --- .claude/skills/run-overlay/SKILL.md | 10 +- CLAUDE.md | 8 +- CMakeLists.txt | 3 + PRIVACY.md | 10 +- README.md | 13 +- ROADMAP.md | 23 ++- docs/architecture.md | 46 +++-- docs/quickpaste.md | 146 +++++++++++++++ docs/roadmap.md | 22 +-- scripts/fetch-glyphs.sh | 5 +- src/app.cpp | 240 ++++++++++++++++++++++--- src/app.hpp | 36 +++- src/config.cpp | 48 +++-- src/config.hpp | 12 ++ src/fonts.cpp | 37 +++- src/glyph_data.inc | 156 ++++++++++------ src/platform/input.hpp | 2 +- src/quickpaste.cpp | 82 +++++++++ src/quickpaste.hpp | 58 ++++++ src/screens/quickpaste_screen.cpp | 153 ++++++++++++++++ src/screens/quickpaste_screen.hpp | 20 +++ src/screens/settings_screen.cpp | 270 ++++++++++++++++++++++++++++ src/screens/settings_screen.hpp | 6 + src/ui/glyphs.hpp | 6 +- src/ui/strings.cpp | 24 +++ src/ui/strings.hpp | 23 +++ tests/quickpaste_test.cpp | 108 +++++++++++ 27 files changed, 1422 insertions(+), 145 deletions(-) create mode 100644 docs/quickpaste.md create mode 100644 src/quickpaste.cpp create mode 100644 src/quickpaste.hpp create mode 100644 src/screens/quickpaste_screen.cpp create mode 100644 src/screens/quickpaste_screen.hpp create mode 100644 tests/quickpaste_test.cpp diff --git a/.claude/skills/run-overlay/SKILL.md b/.claude/skills/run-overlay/SKILL.md index 832673f..9ea8968 100644 --- a/.claude/skills/run-overlay/SKILL.md +++ b/.claude/skills/run-overlay/SKILL.md @@ -14,6 +14,7 @@ launch it, photograph it, and look. `PPC_DEV_ITEM` is what makes that possible w | --- | --- | | `PPC_DEV_OVERLAY=1` | Opens Settings and disables dismiss-on-blur. Required for every dev run. | | `PPC_DEV_ITEM=` | Opens the price-check panel on a captured clipboard instead. | +| `PPC_DEV_PASTE=1` | Opens the QuickPaste popup at wherever the pointer is, which is the only way to see it without the game. | | `PPC_DEV_IDLE=1` | Keeps the idle status marker up (it otherwise shows only while the game is in front). | | `PPC_MANAGED=1` | Lets the window manager manage the window — needed if you want it stackable/movable on a real session. | | `PPC_DEBUG_COPY=1` | Traces the copy timeline to stderr. | @@ -23,9 +24,12 @@ launch it, photograph it, and look. `PPC_DEV_ITEM` is what makes that possible w ```sh cmake --build build -j -pkill -f PathOfPriceCheck # one instance per user — a second launch refuses, loudly -(PPC_DEV_OVERLAY=1 PPC_DEV_ITEM=tests/data/items/.txt \ - ./build/PathOfPriceCheck > "$SCRATCH/run.log" 2>&1 &) +pkill -x -f ./build/PathOfPriceCheck # ALWAYS, first — one instance per user, and a second + # launch refuses. -x -f matches that exact command line + # and so cannot match the shell running this script. +setsid nohup env PPC_DEV_OVERLAY=1 PPC_DEV_ITEM=tests/data/items/.txt \ + ./build/PathOfPriceCheck > "$SCRATCH/run.log" 2>&1 < /dev/null & # setsid: a plain `&` + # dies with the shell sleep 10 # data bundle, poe.ninja overview, exchange digest, CDN icons spectacle -b -n -f -o "$SCRATCH/shot.png" magick "$SCRATCH/shot.png" -crop 480x620+1220+0 +repage -resize 220% "$SCRATCH/panel.png" diff --git a/CLAUDE.md b/CLAUDE.md index 5b19752..ba597e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ Pipeline: **hotkey → auto-copy → clipboard → parse → identify → price The overlay, Settings, the league list, the static game-data layer, the item layer (parse → resolve → price-relevant numbers → search plan, plus the game-styled tooltip), the trade search, -poe.ninja reference pricing, the in-game currency exchange feed and the binary updater (with the -Windows installer it depends on) are all **built and tested**. +poe.ninja reference pricing, the in-game currency exchange feed, QuickPaste and the binary updater +(with the Windows installer it depends on) are all **built and tested**. What is not built is [docs/roadmap.md](docs/roadmap.md) — including the fact that a language other than English cannot yet be selected, because the data build emits only English. @@ -40,6 +40,7 @@ read whole; each is one layer. | [docs/updater.md](docs/updater.md) | `src/update/` and `packaging/` — how a copy arrives and how it replaces itself: the install flavours, the swap, `latest.json`, and the Windows installer. | | [docs/item-layer.md](docs/item-layer.md) | `src/item/` — parse, resolve, derive, range matching, and the plan rules every strategy shares. Where most pricing judgement lives. | | [docs/strategy-unique.md](docs/strategy-unique.md), [strategy-map.md](docs/strategy-map.md), [strategy-gem.md](docs/strategy-gem.md), [strategy-logbook.md](docs/strategy-logbook.md) | One per search strategy that has more to say than the shared rules: uniques (including unidentified), maps (with charts and Valdo maps), gems, expedition logbooks (the one item that is up to three items at once). | +| [docs/quickpaste.md](docs/quickpaste.md) | The paste list — the popup at the cursor, the nine number-key slots, and the clipboard *write*, which is a seam of its own. | | [docs/trade-layer.md](docs/trade-layer.md) | `src/trade/` — query building, the two-step client, the rate limiter, and how results and the filter list are drawn. | | [docs/ninja.md](docs/ninja.md) | `src/ninja/` — the poe.ninja reference price. | | [docs/exchange.md](docs/exchange.md) | `src/exchange/` — GGG's hourly in-game currency exchange digests. | @@ -90,6 +91,9 @@ violate one of these on the strength of not having read it. - **Never issue a GGG request outside `trade::request`.** The shared rate limiter is a hard requirement, not a courtesy. poe.ninja and the currency-exchange CDN are *different hosts with different rules* and deliberately do not go through it. → trade-layer, external-apis +- **The clipboard is ours to read *and* to write.** `clipboard_set_text` owns the X selection from + a thread of its own, because a write on X11 is a promise to answer for the text later. → + quickpaste, platform - **Do not go back to `SDL_GetClipboardText()`**, do not clear the clipboard before a copy, and do not build a purely passive clipboard watcher. All three were tried and measured; each fails in a way that reads as a hang. → platform, architecture diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b5374b..6a6767c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,6 +119,7 @@ target_compile_definitions(imgui PUBLIC SDL_MAIN_HANDLED) add_library(ppc_core STATIC src/paths.cpp src/config.cpp + src/quickpaste.cpp src/leagues.cpp src/util/sha256.cpp src/util/base64.cpp @@ -177,6 +178,7 @@ set(APP_SOURCES src/ui/theme.cpp src/ui/range_slider.cpp src/screens/settings_screen.cpp + src/screens/quickpaste_screen.cpp src/screens/pricecheck_screen.cpp src/screens/item_view.cpp) if(WIN32) @@ -280,3 +282,4 @@ ppc_add_test(ninja_test) ppc_add_test(exchange_test) ppc_add_test(ratelimit_test) ppc_add_test(track_test) +ppc_add_test(quickpaste_test) diff --git a/PRIVACY.md b/PRIVACY.md index b0762d7..5a7d566 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -74,14 +74,18 @@ The whole tool works by reading the clipboard, so this is worth being precise ab - If you press the hotkey while something other than an item is on your clipboard, that text is what gets parsed. It fails to parse, nothing opens, and it is discarded. But it *was* read — so the ordinary caution applies: this is a global hotkey and the clipboard is a global thing. -- Nothing is ever written to your clipboard except when you click the diagnostic check id in the - panel footer, which copies that four-character id and nothing else. +- **Two things write to your clipboard, both because you asked**: picking an entry from QuickPaste, + which puts that entry's own text there and nothing else, and clicking the diagnostic check id in + the panel footer, which copies that four-character id. On Linux the text is then served from a + window this application owns for as long as it runs — which is how the X11 clipboard works for + every program — so closing the tool takes it with it unless your desktop's clipboard manager has + kept a copy. ## What is stored on your machine | path | what | |---|---| -| `/config.json` | your settings: league, hotkeys, panel geometry, listing status, result count, filter ranges, client and interface language, panel opacity, whether to update automatically | +| `/config.json` | your settings: league, hotkeys, panel geometry, listing status, result count, filter ranges, client and interface language, panel opacity, whether to update automatically — **and your QuickPaste entries, in full**, since they are text you typed for this tool to hold | | `/cookies.txt` | the cookie jar above | | `/data//` | the downloaded game-data bundle, plus a `current` pointer | | `/update/` | a downloaded release of the application, waiting for the restart that applies it. One file, consumed as it is applied; absent whenever no update is pending | diff --git a/README.md b/README.md index 74f517b..9945dfb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ the game in the game's own typeface. volume-weighted average, the band around it, and the volume on both sides. Items that trade there get no Search button, and one that had no trade in the last hour says so rather than showing you nothing. +- **QuickPaste.** Alt+V opens your saved snippets at the cursor — a map regex, a vendor search, the + whisper you send twenty times an evening. Pick one by clicking it or by pressing its number, and + it is on your clipboard. **It does not press Ctrl+V for you**: you paste it yourself, in the + field you meant, at your own time. Nine at a time, because that is how many number keys there + are; keep as many as you like and switch them in and out in Settings. - **Listings you can read.** Account, listing age and price, with the seller's own item drawn beside the list when you hover a row — through the same renderer as the item in your hand, so the comparison is like-for-like. @@ -117,11 +122,15 @@ for Arch, Debian/Ubuntu/Mint/Pop!\_OS and Fedora, plus the Windows toolchain. | | | |---|---| | **Ctrl+D** | price-check the item under the cursor | +| **Alt+V** | open QuickPaste at the cursor; **1**-**9** picks one | | **Shift+Space** | Settings | | **Escape**, click away, or the hotkey again | dismiss the panel | -Both hotkeys are rebindable in Settings, and both are ignored unless Path of Exile is the window in -front — they are grabbed system-wide, so they must not go off in your browser. +All three hotkeys are rebindable in Settings, and all three are ignored unless Path of Exile is the +window in front — they are grabbed system-wide, so they must not go off in your browser. + +QuickPaste's number keys go by the key's **position**, not by what your layout prints on it, so the +top row works whatever keyboard you have. The panel docks beside the frame the item came from: right of the stash, or left of the inventory, depending on which half of the screen your cursor was in. If it lands wrong, the stash and diff --git a/ROADMAP.md b/ROADMAP.md index 50b2801..097d4c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,18 +70,25 @@ could only tick it or leave it. **Does not** — carry an edit onto the next item. Bounds belong to the item in hand. -## 0.5 — The paste list +## 0.5 — QuickPaste — **shipped** -*Working name.* A hotkey opens a small window at the cursor listing saved snippets — a map regex, -a vendor search, a whisper you send twenty times an evening. +A hotkey opens a small window at the cursor listing saved snippets — a map regex, a vendor +search, a whisper you send twenty times an evening. -**Will:** +**Does:** -- Hold entries of a heading and a body, multi-line, edited in Settings. +- Hold entries of a heading and a body, multi-line, written and arranged in Settings' own + **QuickPaste** tab: add, edit, delete, and drag into the order you want them offered in. - Put the one you pick on the clipboard and give the game back the foreground. -- Pick by number key, so the mouse never has to travel. - -**Will not** — press Ctrl+V for you. You paste, in your own field, at your own time. +- **Pick by number key**, so the mouse never has to travel — and by the key's *position*, so it + works on a keyboard layout that does not print digits on that row. +- Offer nine at a time, which is how many number keys there are. Keep as many as you like: the + ones beyond nine are simply switched off until you switch something else off. Nothing to read, + nothing refused — the tenth tick is just not available. +- Open at your cursor, growing down, up, or from somewhere between the two, whichever fits the + screen. + +**Does not** — press Ctrl+V for you. You paste, in your own field, at your own time. **Might** — grow that keystroke as an option later if it turns out to be wanted. diff --git a/docs/architecture.md b/docs/architecture.md index 29107d3..40142df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,9 @@ it drives have docs of their own and are read separately: [data-layer.md](data-l [platform.md](platform.md). Pipeline: **hotkey → auto-copy → clipboard → parse → identify → price → render**. `App` (`src/app.cpp`) -owns the SDL event loop and a `Screen` state machine `{ Hidden, PriceCheck, Settings }`. Price-check +owns the SDL event loop and a `Screen` state machine `{ Hidden, PriceCheck, Settings, QuickPaste }` +— the last of which is the paste list and is [quickpaste.md](quickpaste.md), the only screen that +does not involve the copy path at all. Price-check hotkey → `simulate_copy()` → wait for the clipboard to be written → parse → show if it's an item. **Four steps, and they are meant to stay four.** An earlier version grew a pre-copy snapshot, a byte comparison against it, a latching write detector, `SDL_EVENT_CLIPBOARD_UPDATE` as a third @@ -129,15 +131,29 @@ the copy path used to call `focus_game_window()` on a window it had just confirm and `XSetInputFocus` on the toplevel can land somewhere Wine didn't put it. Focus is handed back to the game on close **only** if `overlay_.has_focus()` — i.e. only focus we took ourselves. -**Claiming the keyboard is a smaller thing than claiming the foreground**, and two places do it: -Settings, for its text fields, and the filter list's range editor, for its two boxes. Both call -`overlay_take_keyboard_focus`, which is `XSetInputFocus` on our own override-redirect window — it +**Claiming the keyboard is a smaller thing than claiming the foreground**, and three places do it: +Settings, for its text fields; the filter list's range editor, for its two boxes; and the paste +popup, for its number keys. All three go through `App::take_keyboard`, which is +`overlay_take_keyboard_focus` — `XSetInputFocus` on our own override-redirect window — it moves `input=` and leaves `active=` on the game, which is why it is useless for prising the clipboard out of Wine (below) and exactly right here. Without it a text field on a price check -looks live and receives nothing, because the WM will not focus the window it is drawn on. Neither -hands the focus back on closing the widget: for a price check the game regaining focus *is* the -dismiss, so returning it would close the panel out from under the edit. `set_screen` hands it back -when the screen closes, and only if `overlay_.has_focus()`. +looks live and receives nothing, because the WM will not focus the window it is drawn on. None of +them hands the focus back on closing the widget: for a price check the game regaining focus *is* +the dismiss, so returning it would close the panel out from under the edit. `set_screen` hands it +back when the screen closes, through `give_keyboard_back`. + +**Claiming it once is not enough, and `overlay_.has_focus()` is not the record of having claimed +it.** Two reported bugs came out of that pair. A screen that lives on the keyboard has to +*re-*claim it whenever the game comes back to the front, because the window manager will not hand +the focus to a window it does not manage — without that, alt-tabbing to a browser and back left +Settings on screen, apparently live, receiving nothing (`App::reclaim_keyboard`, run from the +placement poll and, up to 400ms sooner, from a click into the panel). And handing it back cannot +be gated on SDL's `has_focus()` alone, which lags the `XSetInputFocus` that caused it: a paste +popup dismissed briskly reached the hand-back before SDL had registered the focus we had taken +ourselves, so the game never got a focus change — and that focus change is what makes Wine re-read +the clipboard, which is why the first paste served the *previous* one. `took_keyboard_` is our own +record of the call, and `give_keyboard_back` still checks the foreground first, so it can never +become a focus steal from a third application. **A drag that leaves the window is reconciled against the physical mouse** (`Overlay::sync_held_mouse`, run between the backend's `NewFrame` and ImGui's). The overlay is never wider than it needs to be @@ -178,7 +194,8 @@ releases even when it finds no game window — an unmatched pair leaks the helpe `deactivate_game_window` then refuses. `App::place_overlay()` gives each screen its own geometry: Settings is a 640×720 dialog centered over -the game, price-check is a **full-height panel docked beside the item's own frame** — right of the +the game, the paste popup is sized to its own list and placed at the cursor sampled when its hotkey +fired (see [quickpaste.md](quickpaste.md)), price-check is a **full-height panel docked beside the item's own frame** — right of the stash if the cursor was in the left half of the game window at hotkey time, left of the inventory if in the right half (`App::cursor_side()`, sampled before the copy; the user has moved on by the time the clipboard lands). Panels straddling the middle — vendor, quest rewards — have no correct answer, @@ -222,7 +239,7 @@ globe's lower half, so that the third line — the one an available update adds glass instead of on the frame. `place_overlay` sizes the window to the text for that screen, so the idle overlay is a 200×48 rectangle rather than a dialog-sized one nothing is drawn into. -**Settings is three tabs** — General, Price check, Application — between a fixed header (the title +**Settings is four tabs** — General, Price check, QuickPaste, Application — between a fixed header (the title and the close disc) and a fixed footer (Save). `kTabs` in `settings_screen.cpp` pairs each name with the function that draws it; `App::settings_tab()` holds which one is open, because the screen is a free function rebuilt every frame. The strip is buttons, not `ImGui::BeginTabBar`: the game marks @@ -311,13 +328,18 @@ holds no SDL/X11/curl and every layer can log into it. `clipboard_poke`). Suspect it first whenever turning the log on changes the behaviour being logged. A **system-tray icon** (SDL3 `SDL_Tray`, cross-platform) provides Exit. `Overlay` wraps -the SDL3+GL+ImGui window; `Config` persists to JSON. **`SDL_HINT_VIDEO_ALLOW_SCREENSAVER` is set +the SDL3+GL+ImGui window; `Config` persists to JSON. **`Config::load` reads every field inside +one `try`**, not just the parse: `config.json` is hand-editable and nlohmann throws as readily on +a field of the wrong type as on a truncated file, so an object where a string belongs would +otherwise be an uncaught exception before the first window — a config the user can only fix by +deleting it. What was read before the throw stands and everything after it keeps its default, +which is the same posture as the clamping the numeric fields already do. **`SDL_HINT_VIDEO_ALLOW_SCREENSAVER` is set back on**: SDL disables the screensaver at video init on the assumption that it is running a game, and on Linux that is an `org.freedesktop.ScreenSaver` inhibit — reason "Playing a game" — held for the life of the process, so an application that sits in the tray all day stopped the machine from sleeping. The game does its own inhibiting; we are a desktop app. `PPC_DEV_OVERLAY=1` opens Settings and disables dismiss-on-blur for local dev; add `PPC_DEV_ITEM=` to open the price-check panel on a captured -clipboard instead, or `PPC_DEV_IDLE=1` to keep the idle status marker up (it otherwise only ever +clipboard instead, `PPC_DEV_PASTE=1` to open the paste popup at the pointer, or `PPC_DEV_IDLE=1` to keep the idle status marker up (it otherwise only ever appears while the game is the window in front). `PPC_DEV_UPDATE_URL=` points the update check at a `latest.json` of your own, which is the only way to see its three notice surfaces before a release publishes one — see [updater.md](updater.md). diff --git a/docs/quickpaste.md b/docs/quickpaste.md new file mode 100644 index 0000000..0c6a3bd --- /dev/null +++ b/docs/quickpaste.md @@ -0,0 +1,146 @@ +# QuickPaste + + + +A hotkey (**Alt+V** by default) opens a list of saved snippets at the cursor. Pick one — by click +or by the number key beside it — and its text goes on the clipboard, the popup closes, and the +game gets the focus back. The user pastes it themselves. + +Three files and one seam: the model is `src/quickpaste.{hpp,cpp}` (`ppc_core`), the popup is +`src/screens/quickpaste_screen.cpp`, the list is edited in Settings' **QuickPaste** tab +(`quickpaste_tab` in `settings_screen.cpp`), and the write goes through +`clipboard_set_text` in [platform.md](platform.md)'s clipboard seam. + +## What it will not do + +**Nothing presses Ctrl+V**, and this is a decision rather than a gap. The application's one +sanctioned reason to touch the game's focus is prising the clipboard out of Wine +([architecture.md](architecture.md)); typing into whatever window happens to be in front is a +different promise, and a mistimed one lands in a chat box the user was not looking at. What the +tool guarantees is that the text is *on the clipboard* when the popup closes. The public plan +([../ROADMAP.md](../ROADMAP.md)) says the same thing in the user's words, and says the keystroke +may come back later as an option. + +## The nine slots + +`kMaxActivePastes` is 9, and it is a limit on the **keyboard, not on storage**. A tenth entry +would have no key to press, and a list longer than a glance has already spent the time it saves +at a vendor window. So the list is unbounded and each entry carries `enabled`; `active_pastes()` +returns the enabled ones in list order, capped at nine, and slot *n* of that answer is the key +that picks it. + +Everywhere the ceiling is reached, the answer is **a control that is not available rather than an +error to read**: the tenth checkbox is disabled, not refused; a new paste is created enabled when +there is room and disabled when there is not, because one that silently displaced an existing +slot would be worse than one with no number yet. `limit_enabled()` applies the same rule when the +config file is *read*, since that file is hand-editable and a run drawing keys nobody can press +is not a state worth having. + +## Picking by number is scancodes + +`paste_slot_for()` in `app.cpp` reads `SDL_Scancode`, not the keycode. **The digits are printed +on the number row only on a US layout** — on a Czech one the same physical keys produce +`ěščřžýáíé` — so a keycode test would leave the feature unusable by number for most of Europe. A +scancode is the key's *position*, which is what "the second one along" means and what the digit +drawn in the square stands for. The keypad answers the same slots. + +That is also why the popup **claims the keyboard focus** (`overlay_take_keyboard_focus`, as +Settings and the range editor do): our window is override-redirect, so without it every keystroke +goes to the game. It takes the X server's input focus and not the window manager's activation — +`active=` stays on the game — which is why it is not the thing +[architecture.md](architecture.md) forbids. + +## Where the window goes + +`App::place_overlay` sizes it from `quickpaste_size(entries)` — **declared, not measured**, for a +harder reason than Settings' fixed size: placement happens before the frame that could measure +it, so a height taken from the last frame would place the popup for the previous list every time. +The constants live in `quickpaste_screen.cpp` beside the code that draws to them. + +Position is the cursor sampled **at hotkey time** (`paste_x_`/`paste_y_`), for the same reason +`side_` is sampled there: by the time anything is placed, the hand has moved. It opens to the +right of the cursor and downwards from it, then is clamped into the game window — which is what +turns "downwards" into "upwards" near the bottom edge, and into somewhere between the two in the +middle. There is deliberately no rule that picks a direction: a rule and a clamp would disagree +in exactly the cases that matter. + +It dismisses like a price check — Escape, the hotkey again, a click outside it +(`poll_click_away`, whose panel rectangle is simply the whole window here) or the game taking +focus back. + +## The clipboard write + +`clipboard_set_text` is a platform seam of ours, never `SDL_SetClipboardText`. On X11 there is no +clipboard to put something *in*: a selection is a live window answering `SelectionRequest`, so a +write is a promise to still be there when the paste happens — after the popup has closed and the +user has clicked into a chat box. Hence a thread with its own `Display` that owns `CLIPBOARD` for +the life of the process; the main thread hands text over under a mutex and pokes a self-pipe, +because **the Display is touched only by that thread** (the rule the hotkey listener follows, and +the same Xlib abort behind it). + +**The write blocks until the selection is actually ours**, and that is the fix for the bug this +shipped with: `pick_paste` hands the keyboard focus straight back to the game the moment it +returns, and **Wine re-reads the X selection around a focus change**. Posting the text to the +owner thread and returning meant the focus went back while Wine still owned the selection — so +the first paste was of the *previous* clipboard and only the next one, after some later event had +made Wine look again, came out right. Reported from the game as "it only works on the second +try". The wait is two round trips on our own connection (measured at 0.1-0.7ms) against a 250ms +bound that exists only so a wedged server cannot hold the main loop. + +The same argument is why `give_keyboard_back` does not simply test `overlay_.has_focus()`: SDL's +view of our own focus lags the `XSetInputFocus` that caused it, so a popup dismissed briskly +could skip handing the focus back at all — and on this path that focus change is the thing that +makes Wine look. `took_keyboard_` is our own record of the call; the foreground check beside it +is what stops it becoming a focus steal when the user has alt-tabbed away. + +Two more consequences worth knowing: + +- **There is no INCR on the write side.** A value goes over in one `XChangeProperty`, so + `kMaxClipboardWrite` (64KB) is the ceiling, and the editor disables **Done** past it rather than + storing something that cannot be served. A paste is a chat line or a map regex; nothing that + belongs here comes near it. +- **A price check in flight is dropped first.** Its stamp would move on our own write and the + copy would be read as an item that is not one. `handle_action` calls `abandon_copy` before + opening the popup. + +## Ordering, editing, deleting + +The Settings list is **read-only apart from arranging it**: enable, drag to reorder, edit, +delete. A field that is typed into is a field that has to be finished before anything else can be +clicked, and the body is multi-line — so writing happens in a modal dialog on a **draft**, which +Done copies back and Cancel drops. Nothing persists until Settings' own Save, which is also what +makes an accidental delete recoverable: close Settings without saving. + +Reorder is a drag on the grip glyph, applied *after* the loop that drew the list (`PasteAction`) +— a delete changes the vector being walked, and a move needs the height of a row that has not +been drawn yet. `move_paste` answers false for a move that cannot happen. + +**The drag is measured in pixels of travel, not in rows crossed**, and this is the whole of what +makes it hold still. A row is picked up only once the pointer has covered the full height of the +neighbour it is heading for, and that height is then taken off a tally (`PasteDrag::paid`), so +the row stays under the hand and the move that would put it back needs the same distance again. +Asking instead which row the pointer is *over* — the shape of ImGui's own demo — works only +while every row is the same height. These are two lines and the second one wraps, so a move drops +the pointer back over the row it just came from, that reads as a move the other way, and the list +flickers between the two for as long as the button is held. That is what shipped, and it is what +this replaces. Travel past either end is *forgotten* rather than banked: banked, the hand would +have to give the distance back before the row moved again. + +The drag is also **ours to track, not ImGui's**: a row's id is its index, so the frame a move +lands, ImGui is holding the handle of the row that slid into the vacated place, and the drag +would go on shoving whatever kept arriving there. `PasteDrag::index` follows the paste, and the +held colouring is painted on that row rather than on the id ImGui thinks is down — a pressed +handle left behind on a row standing still is the drag appearing to have gone somewhere it has +not. + +The row actions are Font Awesome glyphs — grip-lines, pen, trash-can, square-plus — added to +`ui/glyphs.hpp` and `scripts/fetch-glyphs.sh` together, as that header insists. Adding them is +what turned `kOnlyGlyphs` in `fonts.cpp` from a hand-written range into one derived from +`kGlyphCodepoints`: the old range covered U+F00C..U+F0E2 because that was where the first two +glyphs happened to sit, and every one of these four would have baked as nothing. + +## Storage + +The list lives in `config.json` under `pastes`, one object per entry +(`heading`, `body`, `enabled`), in the order the popup lists them — and in +[../PRIVACY.md](../PRIVACY.md), because it is text the user typed and it is written to disk. diff --git a/docs/roadmap.md b/docs/roadmap.md index bd50017..f6cd5cd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,17 +37,17 @@ what a strategy leaves out is a **collapsed section at its foot** rather than no its links**, which is a price the tool missed entirely — see [trade-layer.md](trade-layer.md) and [item-layer.md](item-layer.md). -### 0.5, the paste list - -- Setting the clipboard goes through our own platform seam, never SDL's, and the X11 owner is a - window that has to outlive the popup. Wine renders on request, so it will ask. - → [platform.md](platform.md) -- No injected Ctrl+V: **focus is a gate, never something to take**, and Wine's clipboard is the - one sanctioned exception. → [architecture.md](architecture.md) -- Cursor placement is a new mode; everything placed today anchors to `stash_edge` / - `inventory_edge`. Clamp to the screen, dismiss on Escape, click-away and pick. -- Number-key selection is not a nicety: the point of the feature is speed at a vendor window, and - a popup the mouse has to travel to has spent what it saved. +### 0.5, QuickPaste — **built** + +Every constraint below held; the layer is [quickpaste.md](quickpaste.md) now, not a plan. The +clipboard owner is a thread with its own `Display` (`clipboard_set_text`), nothing injects +Ctrl+V, the popup is placed against a cursor sampled at hotkey time and clamped into the game +window, and the number keys are read as **scancodes** — which was the one thing the note below +did not anticipate, and the thing that decides whether the feature works outside a US layout. + +One rule the plan did not have and the code now does: nine slots is a limit on the *keyboard*, +so storage is unbounded, `enabled` is what competes for a number, and the ceiling is enforced on +load as well as in the UI because `config.json` is hand-editable. ### 0.6, map check diff --git a/scripts/fetch-glyphs.sh b/scripts/fetch-glyphs.sh index 4707e13..cad01dd 100755 --- a/scripts/fetch-glyphs.sh +++ b/scripts/fetch-glyphs.sh @@ -10,8 +10,9 @@ set -euo pipefail ver="6.7.2" url="https://github.com/FortAwesome/Font-Awesome/releases/download/$ver/fontawesome-free-$ver-web.zip" -# f00c check (confirm), f0e2 arrow-rotate-left (reset). -codepoints="U+F00C,U+F0E2" +# f00c check (confirm), f0e2 arrow-rotate-left (reset), f0fe square-plus (add), +# f304 pen (edit), f2ed trash-can (delete), f7a4 grip-lines (drag to reorder). +codepoints="U+F00C,U+F0E2,U+F0FE,U+F304,U+F2ED,U+F7A4" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" dest="$root/assets/fonts" diff --git a/src/app.cpp b/src/app.cpp index 09f14a9..cf03f92 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -20,7 +20,9 @@ #include "platform/overlay_native.hpp" #include "platform/platform.hpp" #include "platform/single_instance.hpp" +#include "quickpaste.hpp" #include "screens/pricecheck_screen.hpp" +#include "screens/quickpaste_screen.hpp" #include "screens/settings_screen.hpp" #include "trade/query.hpp" #include "ui/strings.hpp" @@ -100,6 +102,20 @@ std::string clipboard_wedge_note(const std::string& targets) { " The format list above usually names who has it."; } +/// Which paste-list slot a key press picks, or -1 for a key that picks none. +/// +/// **Scancodes and not keycodes.** The digits are printed on the number row only on a US +/// layout; on a Czech one the same physical keys produce `ěščřžýáíé`, and the popup would be +/// unusable by number for everybody whose layout is not en-US. A scancode is the key's position, +/// which is what "the second key along" means and what the digit in the square stands for. The +/// keypad answers the same slots, since somebody whose hand is already there should not have to +/// move it either. +int paste_slot_for(SDL_Scancode sc) { + if (sc >= SDL_SCANCODE_1 && sc <= SDL_SCANCODE_9) return sc - SDL_SCANCODE_1; + if (sc >= SDL_SCANCODE_KP_1 && sc <= SDL_SCANCODE_KP_9) return sc - SDL_SCANCODE_KP_1; + return -1; +} + void SDLCALL tray_exit_cb(void* userdata, SDL_TrayEntry*) { static_cast(userdata)->quit(); } @@ -318,6 +334,14 @@ int App::run(bool relaunched_after_update) { // The idle status marker, which otherwise only ever appears while the game is the // window in front. Laid out against the display, since there is no game to measure. place_overlay(); + } else if (std::getenv("PPC_DEV_PASTE")) { + // The paste popup, at wherever the pointer happens to be — the only way to see it + // without the game, since it is placed against a cursor the hotkey sampled. + float mx = 0, my = 0; + SDL_GetGlobalMouseState(&mx, &my); + paste_x_ = static_cast(mx); + paste_y_ = static_cast(my); + set_screen(Screen::QuickPaste); } else if (const char* path = std::getenv("PPC_DEV_ITEM")) { std::ifstream in(path, std::ios::binary); if (in) { @@ -369,6 +393,8 @@ int App::run(bool relaunched_after_update) { draw_settings_screen(*this); else if (screen_ == Screen::PriceCheck) draw_pricecheck_screen(*this); + else if (screen_ == Screen::QuickPaste) + draw_quickpaste_screen(*this); else draw_status_marker(*this); overlay_.end_frame(); @@ -453,8 +479,11 @@ void App::handle_event(const SDL_Event& e) { if (km & SDL_KMOD_SHIFT) m = m | Mod::Shift; if (km & SDL_KMOD_ALT) m = m | Mod::Alt; if (km & SDL_KMOD_GUI) m = m | Mod::Super; - (capture_which_ == Action::PriceCheck ? config_.price_check : config_.settings) = - Hotkey{m, name}; + switch (capture_which_) { + case Action::PriceCheck: config_.price_check = Hotkey{m, name}; break; + case Action::ToggleSettings: config_.settings = Hotkey{m, name}; break; + case Action::QuickPaste: config_.quick_paste = Hotkey{m, name}; break; + } end_capture(); } } else if (e.type == SDL_EVENT_KEY_DOWN && e.key.key == SDLK_ESCAPE) { @@ -463,16 +492,25 @@ void App::handle_event(const SDL_Event& e) { // the user was aiming at. ImGui closes its popup on the same press, so both agree. if (filter_edit_.open()) close_filter_edit(); else set_screen(Screen::Hidden); + } else if (e.type == SDL_EVENT_KEY_DOWN && screen_ == Screen::QuickPaste) { + // The whole reason the popup claims the keyboard. A slot nothing is in is not a miss to + // report — the popup stays up and the mouse still works. + const int slot = paste_slot_for(e.key.scancode); + const std::vector active = active_pastes(config_.pastes); + if (slot >= 0 && static_cast(slot) < active.size()) + pick_paste(active[static_cast(slot)]); } else if (e.type == SDL_EVENT_WINDOW_FOCUS_GAINED) { had_focus_ = true; debug::log("[app] overlay focus gained"); } else if (e.type == SDL_EVENT_WINDOW_FOCUS_LOST) { debug::log("[app] overlay focus lost (screen=%d, had_focus=%d)", (int)screen_, (int)had_focus_); - // Price-check auto-dismisses when you click back into the game; Settings stays - // open until closed manually (its hotkey or the X button). had_focus_ avoids - // closing before the window has actually taken focus. - if (!dev_mode_ && screen_ == Screen::PriceCheck && had_focus_) set_screen(Screen::Hidden); + // Price-check and the paste popup auto-dismiss when you click back into the game; + // Settings stays open until closed manually (its hotkey or the X button). had_focus_ + // avoids closing before the window has actually taken focus. + if (!dev_mode_ && (screen_ == Screen::PriceCheck || screen_ == Screen::QuickPaste) && + had_focus_) + set_screen(Screen::Hidden); } overlay_.process_event(e); need_redraw_ = true; // an event may have changed the UI @@ -746,7 +784,7 @@ void App::rebuild_plan() { /// from under the edit. `set_screen(Hidden)` gives it back when the check itself ends. void App::edit_filter(FilterEdit::Kind kind, size_t index, float top, float bottom) { filter_edit_ = FilterEdit{kind, index, bottom, top, /*opening=*/true}; - if (!overlay_.has_focus()) overlay_take_keyboard_focus(overlay_.window()); + if (!overlay_.has_focus()) take_keyboard(); need_redraw_ = true; } @@ -804,7 +842,9 @@ void App::open_reference_page() { } void App::poll_click_away() { - if (screen_ != Screen::PriceCheck) { + // The paste popup dismisses the same way and by the same measurement — it is all panel and + // no gutter, so the rectangle below is simply its whole window. + if (screen_ == Screen::Hidden) { mouse_was_down_ = false; return; } @@ -830,6 +870,15 @@ void App::poll_click_away() { const bool on_card = card_h_ > 0 && gx >= wx + layout_.tip_x && gx < wx + layout_.tip_x + layout_.tip_w && gy >= wy && gy < wy + card_h_; + // Settings does not dismiss on a click away — it closes on its own X, its hotkey or + // Escape. What a click *into* it means is that the user is coming back to it, possibly + // from another application that took the keyboard with it, and this is the earliest + // moment we can tell: the poll in `update_overlay_placement` is up to 400ms behind, and + // in the meantime the dialog would swallow their first sentence. + if (screen_ == Screen::Settings) { + if (on_panel) reclaim_keyboard(); + return; + } if (!on_panel && !on_card) set_screen(Screen::Hidden); } @@ -866,8 +915,12 @@ void App::handle_action(Action a) { // still has to close it. const bool game_focused = foreground_title_contains(config_.poe_window_title); if (a == Action::PriceCheck) log_state("hotkey"); - if (!game_focused && !dev_mode_ && - !(a == Action::ToggleSettings && screen_ == Screen::Settings)) { + // The exception is a hotkey closing the screen it opened. Both of those screens hold the + // keyboard focus themselves, so the game *cannot* be foreground while one is up, and the + // hotkey that opened it has to be able to take it away again. + const bool closes_own_screen = (a == Action::ToggleSettings && screen_ == Screen::Settings) || + (a == Action::QuickPaste && screen_ == Screen::QuickPaste); + if (!game_focused && !dev_mode_ && !closes_own_screen) { debug::trace("[copy] hotkey ignored: game not focused"); return; } @@ -914,11 +967,50 @@ void App::handle_action(Action a) { debug::log("[copy] injected in %llums, stamp=%llu owner=%s", (unsigned long long)(copy_started_ms_ - t0), (unsigned long long)copy_stamp_, clipboard_owner_info().c_str()); + } else if (a == Action::QuickPaste) { + if (screen_ == Screen::QuickPaste) { + set_screen(Screen::Hidden); + return; + } + // A price check still waiting on the clipboard would read our own write as the item it + // asked about — the stamp moves, the text is a paste, and it is dropped as "not an + // item" several hundred milliseconds after the user has moved on to something else. + if (copy_pending_) { + debug::log("[paste] dropping the copy in flight: the paste list writes the" + " clipboard itself"); + abandon_copy(); + } + // Where the hand is now, not where it will be when the window is placed. + float mx = 0, my = 0; + SDL_GetGlobalMouseState(&mx, &my); + paste_x_ = static_cast(mx); + paste_y_ = static_cast(my); + set_screen(Screen::QuickPaste); } else { set_screen(screen_ == Screen::Settings ? Screen::Hidden : Screen::Settings); } } +/// Put a paste on the clipboard and close, which is the whole of what the popup does. +/// +/// **Nothing presses Ctrl+V.** The paste happens where the user means it to, in their own field +/// and at their own time — an injected keystroke into a game window is a different promise from +/// the one this application makes about the copy path, and a mistimed one types into the chat +/// box of whatever had focus. +void App::pick_paste(size_t index) { + if (index >= config_.pastes.size()) return; + const Paste& p = config_.pastes[index]; + const bool ok = clipboard_set_text(p.body); + debug::log("[paste] picked '%s' (%zu bytes)%s", p.heading.c_str(), p.body.size(), + ok ? "" : " \xe2\x80\x94 the clipboard would not take it"); + set_screen(Screen::Hidden); // which hands the focus back to the game +} + +void App::open_paste_settings() { + settings_tab_ = kQuickPasteTab; + set_screen(Screen::Settings); +} + void App::update_overlay_placement() { uint64_t now = SDL_GetTicks(); if (now - last_detect_ms_ < 400) return; // poll a few times a second, not every frame @@ -930,18 +1022,25 @@ void App::update_overlay_placement() { (int)g.focused, g.w, g.h, g.x, g.y); game_state_logged_ = gs; } - // Go dormant when the game is gone *or* merely not in front — the idle marker has no - // business floating over other applications. Keep polling either way. An open panel is - // exempt: Settings holds the focus itself, so the game is never foreground while it's up, - // and price-check dismisses on its own terms. + // Somebody else is in front: a browser, a terminal, anything that is not the game and not + // us. **Nothing of ours floats over it — Settings included.** That exemption used to be + // justified by Settings holding the keyboard focus, so the game could never be foreground + // while it was up; but it only holds while Settings still *has* the focus, and alt-tabbing + // to look something up takes it away. What was left was a dialog painted over the browser + // that could not be typed into, because the window manager will not focus an + // override-redirect window and nothing asked it to again. // // **Our own window counts as the game being in front**, because from the user's side it // is: closing a panel that had taken the focus (a click on it, or the clipboard handover // nudge) leaves the focus on our now-empty overlay for as long as the compositor takes to - // hand it back, and the marker used to blink out for exactly that gap. It is not a hole in - // the rule above — focusing anything else takes the focus off us too, and the marker goes. - if (!g.present || (!g.focused && !overlay_.has_focus() && screen_ == Screen::Hidden)) { - if (overlay_.visible() && screen_ == Screen::Hidden) overlay_.set_visible(false); + // hand it back, and the marker used to blink out for exactly that gap. + const bool elsewhere = g.present && !g.focused && !overlay_.has_focus(); + if (!g.present || elsewhere) { + // With the game *gone* only the idle marker goes with it: a panel left open would have + // no way back, since every hotkey that could reopen it is gated on the game being in + // front. With the game merely behind something else, everything hides and comes back. + if (overlay_.visible() && (elsewhere || screen_ == Screen::Hidden)) + overlay_.set_visible(false); if (!g.present) { // forget geometry so it re-places when the game comes back game_present_ = false; game_w_ = game_h_ = 0; @@ -950,7 +1049,13 @@ void App::update_overlay_placement() { } bool moved = g.x != game_x_ || g.y != game_y_ || g.w != game_w_ || g.h != game_h_; - if (game_present_ && overlay_.visible() && !moved) return; // already placed, nothing changed + if (game_present_ && overlay_.visible() && !moved) { + // Back from another application without the window having moved: still owed the + // keyboard, since it was lost to whatever was in front and no window manager will hand + // it to a window it does not manage. + reclaim_keyboard(); + return; // already placed, nothing changed + } game_present_ = true; game_x_ = g.x; game_y_ = g.y; @@ -962,10 +1067,32 @@ void App::update_overlay_placement() { overlay_.set_visible(true); overlay_set_click_through(overlay_.window(), screen_ == Screen::Hidden); } - if (screen_ != Screen::Hidden) SDL_RaiseWindow(overlay_.window()); + if (screen_ != Screen::Hidden) { + SDL_RaiseWindow(overlay_.window()); + reclaim_keyboard(); + } need_redraw_ = true; // first placement / a move: repaint once } +/// Take the keyboard back for a screen that cannot work without it. +/// +/// Claiming it once, when the screen opens, is not enough: alt-tab to a browser and the focus +/// goes with it, and **nothing ever gives it back** — the window manager will not focus an +/// override-redirect window, so returning to the game leaves Settings on screen, apparently +/// live, swallowing every keystroke. Reported from a session where looking a regex up in a +/// browser cost the whole dialog. So it is re-claimed whenever the game is in front again, +/// which is the same condition that puts the window back on screen. +/// +/// Only the two screens that are *about* the keyboard, and only when we do not already hold it: +/// a price check takes it on demand (`edit_filter`) and must not take it otherwise, or the +/// focus-loss that dismisses it could never happen. +void App::reclaim_keyboard() { + if (overlay_.has_focus()) return; + if (screen_ != Screen::Settings && screen_ != Screen::QuickPaste) return; + debug::log("[app] reclaiming the keyboard for screen %d", (int)screen_); + take_keyboard(); +} + Side App::cursor_side() const { if (game_w_ <= 0) return Side::Inventory; float mx = 0, my = 0; @@ -993,6 +1120,29 @@ void App::place_overlay() { return; } + if (screen_ == Screen::QuickPaste) { + int w = 0, h = 0; + quickpaste_size(active_pastes(config_.pastes).size(), &w, &h); + // Right of the cursor and starting at it, which is where a menu opens — then clamped + // into the game window, which is what turns "downwards" into "upwards" near the bottom + // edge and into somewhere between the two in the middle. No decision of its own: a rule + // that picks a direction and a clamp that has to override it would disagree in the + // cases that matter. + constexpr int kCursorGap = 14; + int x = paste_x_ + kCursorGap; + if (x + w > gx + gw) x = paste_x_ - kCursorGap - w; // no room on the right: open left + // max(min()), not clamp: a game window narrower or shorter than the popup puts the low + // bound above the high one, which clamp is not defined for. + x = std::max(gx, std::min(x, gx + gw - w)); + const int y = std::max(gy, std::min(paste_y_ - kCursorGap, gy + gh - h)); + SDL_SetWindowSize(overlay_.window(), w, h); + SDL_SetWindowPosition(overlay_.window(), x, y); + layout_ = PanelLayout{0, float(w), 0, 0}; + debug::log("[paste] placed %dx%d+%d+%d for a cursor at %d,%d", w, h, x, y, paste_x_, + paste_y_); + return; + } + if (screen_ == Screen::Hidden) { // Over the lower half of the mana globe, which hangs off the bottom-right corner and // scales with the game's height — so both offsets are fractions of that height, exactly @@ -1061,9 +1211,11 @@ void App::log_state(const char* when) { void App::log_session_start() { if (!debug::enabled()) return; debug::log("[state] config %s", Config::path().c_str()); - debug::log("[state] hotkeys: price check %s, settings %s; league '%s'; window title '%s'", + debug::log("[state] hotkeys: price check %s, settings %s, paste list %s (%zu of %zu" + " enabled); league '%s'; window title '%s'", to_string(config_.price_check).c_str(), to_string(config_.settings).c_str(), - config_.league.c_str(), config_.poe_window_title.c_str()); + to_string(config_.quick_paste).c_str(), enabled_pastes(config_.pastes), + config_.pastes.size(), config_.league.c_str(), config_.poe_window_title.c_str()); debug::log("[state] video driver %s", SDL_GetCurrentVideoDriver()); log_state("startup"); } @@ -1105,17 +1257,49 @@ void App::set_screen(Screen s) { // Settings needs keyboard focus immediately (text fields); a price check takes it only when // something on it has to be typed into (edit_filter) or the copy stalls // (nudge_clipboard_handover). Closing hands focus back to the game. - if (s == Screen::Settings) { - overlay_take_keyboard_focus(overlay_.window()); + if (s == Screen::QuickPaste) { + // The number keys are the feature; without the keyboard the popup is a menu you have to + // aim at. This is the server's input focus and not the window manager's activation — + // the same thing the range editor takes, and the same reason it is not a violation of + // the rule about the game's foreground. + take_keyboard(); + } else if (s == Screen::Settings) { + take_keyboard(); // TTL-gated, so a warm cache makes this a no-op. A user who never opens Settings // never makes a network request at all. leagues_.refresh(false); - } else if (s == Screen::Hidden && overlay_.has_focus()) { - focus_game_window(config_.poe_window_title); // only hand back focus we actually took + } else if (s == Screen::Hidden) { + give_keyboard_back(); } need_redraw_ = true; } +/// Claim the X input focus for our own window, and **remember that we did**. +void App::take_keyboard() { + took_keyboard_ = true; + overlay_take_keyboard_focus(overlay_.window()); +} + +/// Hand the keyboard back to the game — but only focus we took, and only if the game is still +/// the window the *user* is in. +/// +/// Two conditions rather than one, and the second is not redundant. `overlay_.has_focus()` is +/// SDL's view, which lags the `XSetInputFocus` we just made by however long the round trip takes +/// — so a popup dismissed briskly (pick a paste the moment it opens) could reach here before SDL +/// had registered the focus we ourselves claimed, and the game would be left without it. That is +/// not cosmetic on this path: it is the focus change that makes Wine re-read the X selection, so +/// skipping it is a paste of the previous clipboard. +/// +/// `took_keyboard_` is our own record of an action we performed, and the foreground check is +/// what keeps it honest — if the user has alt-tabbed to a browser meanwhile, the focus is theirs +/// to place and pulling it onto the game would be exactly the theft the focus rule forbids. +void App::give_keyboard_back() { + const bool ours = overlay_.has_focus() || + (took_keyboard_ && foreground_title_contains(config_.poe_window_title)); + took_keyboard_ = false; + if (ours) focus_game_window(config_.poe_window_title); +} + void App::begin_capture(Action which) { capturing_ = true; capture_which_ = which; @@ -1137,7 +1321,9 @@ void App::apply_and_save_config() { } void App::rebind_hotkeys() { - hotkeys_->rebind({{config_.price_check, Action::PriceCheck}, {config_.settings, Action::ToggleSettings}}); + hotkeys_->rebind({{config_.price_check, Action::PriceCheck}, + {config_.settings, Action::ToggleSettings}, + {config_.quick_paste, Action::QuickPaste}}); } } // namespace ppc diff --git a/src/app.hpp b/src/app.hpp index 4f47f79..d03e2ba 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -26,7 +26,7 @@ union SDL_Event; namespace ppc { -enum class Screen { Hidden, PriceCheck, Settings }; +enum class Screen { Hidden, PriceCheck, Settings, QuickPaste }; /// How long a price check waits for the game to publish its copy before dropping it. Past this /// the user has moved on, and a panel that opens late is a panel about the wrong item. @@ -85,6 +85,18 @@ struct FilterEdit { bool open() const { return kind != Kind::None; } }; +/// The paste being written in Settings, and the draft it is written into. +/// +/// A draft rather than the entry itself: the dialog can be cancelled, and an edit typed +/// straight into `Config::pastes` would already have happened by then. Held on `App` for the +/// same reason `settings_tab_` is — the screen is a free function rebuilt every frame. +struct PasteEdit { + bool open = false; + bool adding = false; ///< appended on Done, rather than written back over `index` + size_t index = 0; + Paste draft; +}; + /// A search result's own item, parsed from the clipboard text the API ships with every /// listing. Parsed lazily — twenty of these up front is work for rows nobody hovers — and /// resolved against the same pinned bundle snapshot as the item in hand. @@ -199,6 +211,17 @@ class App { /// only ever come back empty, on every hour nobody happened to trade one in. bool trades_on_exchange() const; + // The paste list. `pick_paste` is the whole of what the popup does: the text goes on the + // clipboard and the popup closes, handing the game back the focus it took for the number + // keys. **Nothing presses Ctrl+V** — see the rule in docs/quickpaste.md. + void pick_paste(size_t index); + /// Open Settings on the tab where pastes are managed, which is the popup's only other + /// affordance and the answer to an empty list. + void open_paste_settings(); + /// Which paste Settings has open in its editor. Mutable: the dialog reads and writes it in + /// the same frame. + PasteEdit& paste_edit() { return paste_edit_; } + /// Copy-path diagnostic log (util/debug_log). Toggling it takes effect immediately — /// waiting for Save would mean the run that reproduced the bug went unrecorded — but it /// still needs a Save to persist. @@ -227,6 +250,9 @@ class App { void price_reference(); ///< ask poe.ninja about the item as planned void poll_click_away(); ///< dismiss price-check on a click outside it void update_overlay_placement(); ///< track the game window; move the overlay over it + void reclaim_keyboard(); ///< re-take the focus a screen that types needs + void take_keyboard(); ///< claim the input focus, and record that we did + void give_keyboard_back(); ///< return focus we took, if the game still wants it void place_overlay(); ///< size + position the overlay for the current screen Side cursor_side() const; ///< which half of the game window the mouse is in void set_screen(Screen s); @@ -271,6 +297,10 @@ class App { float card_h_ = 0; ///< height the item card drew at, in the gutter (see set_card_height) int settings_tab_ = 0; ///< which Settings tab is open FilterEdit filter_edit_; ///< which filter row has its range editor open + PasteEdit paste_edit_; ///< the paste Settings has open in its editor + /// Where the cursor was when the paste hotkey fired. Sampled there rather than read at + /// placement time for the same reason `side_` is: by then the hand has moved. + int paste_x_ = 0, paste_y_ = 0; bool hidden_filters_shown_ = false; ///< the filters the strategy left out are expanded std::string clipboard_; bool running_ = true; @@ -287,6 +317,10 @@ class App { bool copy_deactivated_ = false;///< the game is currently off the WM's active window, by us bool mouse_was_down_ = false; ///< prior global mouse button state, for click-away edges + /// We called `overlay_take_keyboard_focus` and have not handed it back. Our own record of + /// it, because SDL's `has_focus()` lags the call by a round trip and the moment it matters + /// most — a paste popup dismissed the instant it opened — is inside that gap. + bool took_keyboard_ = false; bool dev_mode_ = false; ///< PPC_DEV_OVERLAY: keep overlay up regardless of focus bool had_focus_ = false; ///< overlay has gained focus since it was shown diff --git a/src/config.cpp b/src/config.cpp index af71fa1..3b647e6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -29,16 +29,9 @@ NameCheck check_account_name(std::string_view s) { std::string Config::path() { return (config_dir() / "config.json").string(); } -Config Config::load() { - Config c; - std::ifstream in(path()); - if (!in) return c; - json j; - try { - in >> j; - } catch (...) { - return c; // keep defaults on malformed config - } +namespace { + +void read_into(Config& c, const json& j) { c.league = j.value("league", c.league); c.account_name = j.value("account_name", c.account_name); c.client_language = j.value("client_language", c.client_language); @@ -48,6 +41,18 @@ Config Config::load() { const auto& h = j["hotkeys"]; if (h.contains("price_check")) c.price_check = parse_hotkey(h["price_check"].get()); if (h.contains("settings")) c.settings = parse_hotkey(h["settings"].get()); + if (h.contains("quick_paste")) + c.quick_paste = parse_hotkey(h["quick_paste"].get()); + } + if (j.contains("pastes") && j["pastes"].is_array()) { + for (const auto& p : j["pastes"]) { + if (!p.is_object()) continue; + c.pastes.push_back(Paste{p.value("heading", std::string()), + p.value("body", std::string()), p.value("enabled", true)}); + } + // Clamped rather than taken, like the range percentages above: a hand-edited file + // claiming twelve active pastes would otherwise draw slots with no key to press. + limit_enabled(c.pastes); } c.auto_search = j.value("auto_search", c.auto_search); if (const std::string s = j.value("listing_status", c.listing_status); trade::valid_status(s)) @@ -72,6 +77,24 @@ Config Config::load() { c.reduce_transparency = j.value("reduce_transparency", c.reduce_transparency); c.auto_update = j.value("auto_update", c.auto_update); c.debug_log = j.value("debug_log", c.debug_log); +} + +} // namespace + +Config Config::load() { + Config c; + std::ifstream in(path()); + if (!in) return c; + try { + json j; + in >> j; + read_into(c, j); + } catch (...) { + // Malformed, or a field holding a type this does not expect — an object where a string + // belongs throws as surely as a truncated file does. This file is hand-editable, so + // neither may be the reason the program will not start. What was read before the throw + // stands; everything after it keeps its default. + } return c; } @@ -85,6 +108,11 @@ bool Config::save() const { j["poe_window_title"] = poe_window_title; j["hotkeys"]["price_check"] = to_string(price_check); j["hotkeys"]["settings"] = to_string(settings); + j["hotkeys"]["quick_paste"] = to_string(quick_paste); + // Written even when empty, so the file says the feature exists and where its entries go. + j["pastes"] = json::array(); + for (const Paste& p : pastes) + j["pastes"].push_back({{"heading", p.heading}, {"body", p.body}, {"enabled", p.enabled}}); j["auto_search"] = auto_search; j["listing_status"] = listing_status; j["result_count"] = result_count; diff --git a/src/config.hpp b/src/config.hpp index 221f416..097643e 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -3,8 +3,11 @@ #include #include +#include + #include "item/range_match.hpp" #include "platform/input.hpp" +#include "quickpaste.hpp" #include "trade/trade.hpp" namespace ppc { @@ -40,6 +43,15 @@ struct Config { Hotkey price_check{Mod::Ctrl, "D"}; Hotkey settings{Mod::Shift, "Space"}; + /// Opens the paste list at the cursor. Alt+V rather than anything on its own or under one + /// modifier: this fires while the game has the keyboard, so it has to be a combination + /// nobody arrives at by accident mid-fight. + Hotkey quick_paste{Mod::Alt, "V"}; + + /// The saved snippets that hotkey offers, in the order the popup lists them. No more than + /// `kMaxActivePastes` of them may be enabled at once — enforced on load as well as in + /// Settings, since this file is hand-editable. + std::vector pastes; /// Run the trade search as soon as the panel opens, rather than on the Search button. /// Off by default and deliberately so: a hotkey the user pressed to *read* an item diff --git a/src/fonts.cpp b/src/fonts.cpp index ba8fe93..aeb94a7 100644 --- a/src/fonts.cpp +++ b/src/fonts.cpp @@ -1,5 +1,7 @@ #include "fonts.hpp" +#include +#include #include #include #include @@ -47,11 +49,34 @@ constexpr ImWchar kBorrowedGlyphs[]{0x2264, 0x2265, 0}; /// properly. constexpr ImWchar kOnlyBorrowed[]{1, 0x2263, 0x2266, IM_UNICODE_CODEPOINT_MAX, 0}; -/// The same complement for the glyph subset, which lives in the Private Use Area between -/// U+F00C and U+F0E2. The subset carries no outline outside those two, but it does carry a -/// `.notdef` — and a merged source answering for *that* would serve every codepoint nothing -/// else has, replacing the boxes `fonts.unicode` exists to draw honestly. -constexpr ImWchar kOnlyGlyphs[]{1, 0xF00B, 0xF0E3, IM_UNICODE_CODEPOINT_MAX, 0}; +/// The same complement for the glyph subset — every codepoint **except** the handful +/// `ui/glyphs.hpp` names. The subset carries no outline outside those, but it does carry a +/// `.notdef`, and a merged source answering for *that* would serve every codepoint nothing else +/// has, replacing the boxes `fonts.unicode` exists to draw honestly. +/// +/// Derived from `kGlyphCodepoints` rather than written out beside it. The two used to be one +/// range because there were two glyphs and they sat next to each other; a third that landed +/// outside it would have baked as nothing, which is the exact failure `has_glyphs` exists to +/// catch and a worse one to have to catch twice. +constexpr auto kOnlyGlyphs = [] { + constexpr size_t n = std::size(ui::kGlyphCodepoints); + std::array cps{}; + std::copy(std::begin(ui::kGlyphCodepoints), std::end(ui::kGlyphCodepoints), cps.begin()); + std::sort(cps.begin(), cps.end()); // the gaps below only exist between neighbours + std::array out{}; + size_t at = 0; + unsigned int from = 1; + for (const unsigned int cp : cps) { + if (from < cp) { // no gap at all where two glyphs are adjacent + out[at++] = static_cast(from); + out[at++] = static_cast(cp - 1); + } + from = cp + 1; + } + out[at++] = static_cast(from); + out[at++] = IM_UNICODE_CODEPOINT_MAX; + return out; // the tail is zeroed, and ImGui stops at the first 0 +}(); /// Font Awesome draws on a square em and Fontin on a face with descenders, so a glyph baked at /// the text size stands a touch tall and sits a touch high against the words beside it. Both @@ -253,7 +278,7 @@ Fonts load_fonts(float size_px) { ImFontConfig gcfg; gcfg.FontDataOwnedByAtlas = false; // a static array in the binary gcfg.MergeMode = true; - gcfg.GlyphExcludeRanges = kOnlyGlyphs; + gcfg.GlyphExcludeRanges = kOnlyGlyphs.data(); gcfg.GlyphOffset = ImVec2(0.0f, size_px * kGlyphNudgeY); io.Fonts->AddFontFromMemoryTTF(const_cast(ppc_glyphs_ttf), static_cast(sizeof ppc_glyphs_ttf), diff --git a/src/glyph_data.inc b/src/glyph_data.inc index 92c31a0..53946c6 100644 --- a/src/glyph_data.inc +++ b/src/glyph_data.inc @@ -3,18 +3,18 @@ // Which codepoints: scripts/fetch-glyphs.sh. What they are called: src/ui/glyphs.hpp. // See assets/fonts/README.md for the license this is bundled under. -static const unsigned char ppc_glyphs_ttf[1360] = { +static const unsigned char ppc_glyphs_ttf[2056] = { 0x00,0x01,0x00,0x00,0x00,0x0a,0x00,0x80,0x00,0x03,0x00,0x20,0x4f,0x53,0x2f,0x32, - 0x51,0x55,0x53,0x36,0x00,0x00,0x02,0xfc,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, - 0xf1,0xca,0xef,0x93,0x00,0x00,0x03,0x5c,0x00,0x00,0x00,0x3c,0x67,0x6c,0x79,0x66, - 0x23,0xd0,0x69,0x04,0x00,0x00,0x00,0xac,0x00,0x00,0x01,0xbe,0x68,0x65,0x61,0x64, - 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x02,0x94,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, - 0x04,0x4d,0x02,0x2e,0x00,0x00,0x02,0xd8,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, - 0x05,0x40,0x00,0x10,0x00,0x00,0x02,0xcc,0x00,0x00,0x00,0x0c,0x6c,0x6f,0x63,0x61, - 0x00,0x7e,0x01,0x30,0x00,0x00,0x02,0x8c,0x00,0x00,0x00,0x08,0x6d,0x61,0x78,0x70, - 0x00,0x19,0x07,0x84,0x00,0x00,0x02,0x6c,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, - 0x1d,0x87,0x38,0x73,0x00,0x00,0x03,0x98,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, - 0xff,0xde,0x00,0x19,0x00,0x00,0x05,0x30,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, + 0x51,0x55,0x59,0xf8,0x00,0x00,0x05,0x94,0x00,0x00,0x00,0x60,0x63,0x6d,0x61,0x70, + 0xe2,0xfe,0xcd,0x39,0x00,0x00,0x05,0xf4,0x00,0x00,0x00,0x5c,0x67,0x6c,0x79,0x66, + 0xd2,0x04,0xc5,0xf6,0x00,0x00,0x00,0xac,0x00,0x00,0x04,0x3e,0x68,0x65,0x61,0x64, + 0x2b,0xa9,0x1a,0xa3,0x00,0x00,0x05,0x1c,0x00,0x00,0x00,0x36,0x68,0x68,0x65,0x61, + 0x04,0x4d,0x02,0x32,0x00,0x00,0x05,0x70,0x00,0x00,0x00,0x24,0x68,0x6d,0x74,0x78, + 0x0c,0x81,0x00,0x0d,0x00,0x00,0x05,0x54,0x00,0x00,0x00,0x1c,0x6c,0x6f,0x63,0x61, + 0x03,0x92,0x04,0xf9,0x00,0x00,0x05,0x0c,0x00,0x00,0x00,0x10,0x6d,0x61,0x78,0x70, + 0x00,0x1d,0x07,0x84,0x00,0x00,0x04,0xec,0x00,0x00,0x00,0x20,0x6e,0x61,0x6d,0x65, + 0x1d,0x87,0x38,0x73,0x00,0x00,0x06,0x50,0x00,0x00,0x01,0x98,0x70,0x6f,0x73,0x74, + 0xff,0xde,0x00,0x19,0x00,0x00,0x07,0xe8,0x00,0x00,0x00,0x20,0x00,0x05,0x00,0x00, 0xff,0xc0,0x01,0x80,0x01,0xc0,0x00,0x06,0x00,0x0d,0x00,0x14,0x00,0x1b,0x00,0x35, 0x00,0x00,0x37,0x37,0x07,0x37,0x27,0x31,0x11,0x17,0x33,0x23,0x33,0x27,0x31,0x07, 0x37,0x17,0x27,0x17,0x11,0x31,0x07,0x37,0x23,0x33,0x23,0x17,0x31,0x37,0x25,0x36, @@ -42,51 +42,95 @@ static const unsigned char ppc_glyphs_ttf[1360] = { 0x31,0x40,0x40,0x31,0x11,0x01,0x20,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, 0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0x33,0x11,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x2c, 0x39,0x39,0x39,0x39,0x2c,0x2c,0x0f,0x0e,0x0e,0x0f,0x2c,0x09,0x0d,0x0d,0x0a,0x09, - 0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x00,0x00,0x01,0x00,0x00, - 0x00,0x03,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, + 0x09,0x2f,0x2f,0x31,0x40,0x40,0x31,0x2f,0x2f,0x11,0x00,0x02,0x00,0x00,0xff,0xe0, + 0x01,0xc0,0x01,0xa0,0x00,0x19,0x00,0x3b,0x00,0x00,0x13,0x06,0x07,0x31,0x31,0x06, + 0x07,0x11,0x31,0x16,0x17,0x16,0x17,0x21,0x31,0x36,0x37,0x36,0x37,0x11,0x31,0x26, + 0x27,0x26,0x27,0x21,0x13,0x35,0x15,0x35,0x23,0x31,0x26,0x27,0x36,0x37,0x33,0x31, + 0x35,0x31,0x36,0x37,0x16,0x17,0x15,0x31,0x33,0x31,0x16,0x17,0x06,0x07,0x23,0x31, + 0x15,0x31,0x06,0x07,0x26,0x27,0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01, + 0x40,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x88,0x40,0x16,0x02,0x02, + 0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02, + 0x01,0xa0,0x01,0x12,0x12,0x1b,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b, + 0x01,0x40,0x1b,0x12,0x12,0x01,0xfe,0xc8,0x40,0x40,0x40,0x02,0x16,0x16,0x02,0x40, + 0x16,0x02,0x02,0x16,0x40,0x02,0x16,0x16,0x02,0x40,0x16,0x02,0x02,0x16,0x00,0x05, + 0x00,0x00,0xff,0xc0,0x01,0xc0,0x01,0xc0,0x00,0x1f,0x00,0x30,0x00,0x3d,0x00,0x4a, + 0x00,0x57,0x00,0x00,0x13,0x36,0x37,0x33,0x31,0x16,0x17,0x17,0x31,0x33,0x31,0x32, + 0x17,0x16,0x15,0x14,0x07,0x06,0x23,0x21,0x31,0x22,0x27,0x26,0x35,0x34,0x37,0x36, + 0x33,0x33,0x31,0x37,0x07,0x21,0x21,0x21,0x11,0x31,0x06,0x07,0x06,0x07,0x21,0x31, + 0x26,0x27,0x26,0x27,0x11,0x17,0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31, + 0x26,0x27,0x33,0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x33, + 0x06,0x07,0x15,0x31,0x16,0x17,0x36,0x37,0x35,0x31,0x26,0x27,0x87,0x09,0x14,0x78, + 0x14,0x09,0x07,0x60,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0x0e,0x09,0x09,0x09, + 0x09,0x0e,0x60,0x07,0x67,0x01,0x80,0xfe,0x80,0x01,0x80,0x01,0x12,0x12,0x1b,0xff, + 0x00,0x1b,0x12,0x12,0x01,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x60,0x0f, + 0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f,0x60,0x0f,0x01,0x01,0x0f,0x0f,0x01,0x01,0x0f, + 0x01,0xae,0x11,0x01,0x01,0x11,0x0e,0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e, + 0x0e,0x09,0x09,0x0e,0x6e,0xfe,0xc0,0x1b,0x12,0x12,0x01,0x01,0x12,0x12,0x1b,0x01, + 0x40,0x40,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f, + 0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01,0x01,0x0f,0xe0,0x0f,0x01, + 0x00,0x02,0xff,0xfd,0xff,0xbd,0x01,0xff,0x01,0xbf,0x00,0x11,0x00,0x24,0x00,0x00, + 0x01,0x07,0x37,0x07,0x17,0x31,0x37,0x31,0x36,0x35,0x34,0x27,0x27,0x31,0x26,0x23, + 0x22,0x07,0x07,0x07,0x37,0x07,0x06,0x07,0x07,0x31,0x06,0x17,0x16,0x37,0x37,0x31, + 0x36,0x37,0x37,0x31,0x27,0x01,0x6b,0x31,0x31,0x31,0x82,0x31,0x12,0x12,0x28,0x13, + 0x1a,0x19,0x14,0x47,0xe9,0xe9,0xe9,0x10,0x07,0x23,0x04,0x0a,0x0a,0x0e,0x78,0x15, + 0x10,0xea,0x82,0x01,0xad,0x31,0x31,0x31,0x82,0x31,0x13,0x1a,0x19,0x14,0x28,0x12, + 0x12,0x47,0xea,0xea,0xea,0x0f,0x16,0x78,0x0e,0x0a,0x0a,0x04,0x23,0x07,0x0f,0xea, + 0x82,0x00,0x00,0x02,0x00,0x00,0x00,0x60,0x01,0xc0,0x01,0x20,0x00,0x15,0x00,0x2b, + 0x00,0x00,0x37,0x22,0x07,0x31,0x31,0x06,0x15,0x14,0x17,0x16,0x33,0x21,0x31,0x32, + 0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21,0x35,0x22,0x07,0x31,0x31,0x06,0x15,0x14, + 0x17,0x16,0x33,0x21,0x31,0x32,0x37,0x36,0x35,0x34,0x27,0x26,0x23,0x21,0x20,0x0e, + 0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0x0e, + 0x09,0x09,0x09,0x09,0x0e,0x01,0x80,0x0e,0x09,0x09,0x09,0x09,0x0e,0xfe,0x80,0xa0, + 0x09,0x09,0x0e,0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x80,0x09,0x09,0x0e, + 0x0e,0x09,0x09,0x09,0x09,0x0e,0x0e,0x09,0x09,0x00,0x00,0x00,0x00,0x01,0x00,0x00, + 0x00,0x07,0x07,0x83,0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x51, - 0x00,0x7e,0x00,0xdf,0x00,0x01,0x00,0x00,0x03,0x07,0x05,0x00,0x31,0x52,0x77,0xb3, - 0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, - 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb, - 0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00, - 0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x10,0x00,0x01,0x00,0x00,0x01,0xcb,0xff,0xb5, - 0x00,0x00,0x02,0x80,0xff,0xf4,0xff,0xf5,0x02,0x8b,0x00,0x01,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0x00,0x04,0x02,0x03, - 0x03,0x84,0x00,0x05,0x00,0x00,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0x47,0x01,0x4c, - 0x01,0x66,0x00,0x00,0x00,0xf5,0x00,0x19,0x00,0x84,0x00,0x00,0x02,0x00,0x09,0x03, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x41,0x57,0x53,0x4d,0x00,0x80,0xf0,0x0c,0xf0,0xe2, - 0x01,0xcb,0xff,0xb5,0x00,0x00,0x01,0xcb,0x00,0x4b,0x00,0x00,0x00,0x01,0x00,0x00, - 0x00,0x00,0x01,0x41,0x01,0xaf,0x00,0x00,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x02, - 0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x14,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14, - 0x00,0x04,0x00,0x28,0x00,0x00,0x00,0x06,0x00,0x04,0x00,0x01,0x00,0x02,0xf0,0x0c, - 0xf0,0xe2,0xff,0xff,0x00,0x00,0xf0,0x0c,0xf0,0xe2,0xff,0xff,0x0f,0xf5,0x0f,0x20, - 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x07,0x00,0x5a,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x00,0x00,0x34,0x00,0x00,0x00,0x03,0x00,0x01,0x04,0x09, - 0x00,0x01,0x00,0x32,0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x02,0x00,0x0a, - 0x00,0x66,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x03,0x00,0x3e,0x00,0x70,0x00,0x03, - 0x00,0x01,0x04,0x09,0x00,0x04,0x00,0x32,0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09, - 0x00,0x05,0x00,0x64,0x00,0xae,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x06,0x00,0x2c, - 0x01,0x12,0x00,0x43,0x00,0x6f,0x00,0x70,0x00,0x79,0x00,0x72,0x00,0x69,0x00,0x67, - 0x00,0x68,0x00,0x74,0x00,0x20,0x00,0x28,0x00,0x63,0x00,0x29,0x00,0x20,0x00,0x46, + 0x00,0x7e,0x00,0xdf,0x01,0x31,0x01,0xaa,0x01,0xe3,0x02,0x1f,0x00,0x01,0x00,0x00, + 0x03,0x07,0x05,0x00,0xdd,0xcf,0xd3,0x6b,0x5f,0x0f,0x3c,0xf5,0x00,0x0b,0x02,0x00, + 0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93,0x00,0x00,0x00,0x00,0xe3,0x82,0x6a,0x93, + 0xff,0xf4,0xff,0xb5,0x02,0x8b,0x01,0xcb,0x00,0x00,0x00,0x08,0x00,0x02,0x00,0x00, + 0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0x00,0x10, + 0x01,0xc0,0x00,0x00,0x01,0xc0,0x00,0x00,0x02,0x00,0xff,0xfd,0x01,0xc0,0x00,0x00, + 0x00,0x01,0x00,0x00,0x01,0xcb,0xff,0xb5,0x00,0x00,0x02,0x80,0xff,0xf4,0xff,0xf5, + 0x02,0x8b,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x07,0x00,0x04,0x02,0x03,0x03,0x84,0x00,0x05,0x00,0x00,0x01,0x4c, + 0x01,0x66,0x00,0x00,0x00,0x47,0x01,0x4c,0x01,0x66,0x00,0x00,0x00,0xf5,0x00,0x19, + 0x00,0x84,0x00,0x00,0x02,0x00,0x09,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x41,0x57, + 0x53,0x4d,0x00,0x80,0xf0,0x0c,0xf7,0xa4,0x01,0xcb,0xff,0xb5,0x00,0x00,0x01,0xcb, + 0x00,0x4b,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x01,0x41,0x01,0xaf,0x00,0x00, + 0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x14, + 0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14,0x00,0x04,0x00,0x48,0x00,0x00,0x00,0x0e, + 0x00,0x08,0x00,0x02,0x00,0x06,0xf0,0x0c,0xf0,0xe2,0xf0,0xfe,0xf2,0xed,0xf3,0x04, + 0xf7,0xa4,0xff,0xff,0x00,0x00,0xf0,0x0c,0xf0,0xe2,0xf0,0xfe,0xf2,0xed,0xf3,0x04, + 0xf7,0xa4,0xff,0xff,0x0f,0xf5,0x0f,0x20,0x0f,0x05,0x0d,0x17,0x0d,0x01,0x08,0x62, + 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x07,0x00,0x5a,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x00,0x00,0x34, + 0x00,0x00,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x01,0x00,0x32,0x00,0x34,0x00,0x03, + 0x00,0x01,0x04,0x09,0x00,0x02,0x00,0x0a,0x00,0x66,0x00,0x03,0x00,0x01,0x04,0x09, + 0x00,0x03,0x00,0x3e,0x00,0x70,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x04,0x00,0x32, + 0x00,0x34,0x00,0x03,0x00,0x01,0x04,0x09,0x00,0x05,0x00,0x64,0x00,0xae,0x00,0x03, + 0x00,0x01,0x04,0x09,0x00,0x06,0x00,0x2c,0x01,0x12,0x00,0x43,0x00,0x6f,0x00,0x70, + 0x00,0x79,0x00,0x72,0x00,0x69,0x00,0x67,0x00,0x68,0x00,0x74,0x00,0x20,0x00,0x28, + 0x00,0x63,0x00,0x29,0x00,0x20,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20, + 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x46, 0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73, - 0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20, - 0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20, - 0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53, - 0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69, - 0x00,0x64,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77, - 0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20, - 0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c, - 0x00,0x69,0x00,0x64,0x00,0x2d,0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32, - 0x00,0x56,0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x20, - 0x00,0x37,0x00,0x37,0x00,0x35,0x00,0x2e,0x00,0x30,0x00,0x31,0x00,0x39,0x00,0x35, - 0x00,0x33,0x00,0x31,0x00,0x32,0x00,0x35,0x00,0x20,0x00,0x28,0x00,0x46,0x00,0x6f, - 0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f, - 0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x76,0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69, - 0x00,0x6f,0x00,0x6e,0x00,0x3a,0x00,0x20,0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e, - 0x00,0x32,0x00,0x29,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x41,0x00,0x77, - 0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x36,0x00,0x46,0x00,0x72, - 0x00,0x65,0x00,0x65,0x00,0x2d,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64, - 0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0xdb,0x00,0x19,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72, + 0x00,0x65,0x00,0x65,0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64, + 0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x46,0x00,0x6f,0x00,0x6e, + 0x00,0x74,0x00,0x20,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, + 0x00,0x65,0x00,0x20,0x00,0x36,0x00,0x20,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65, + 0x00,0x20,0x00,0x53,0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x2d,0x00,0x36, + 0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x56,0x00,0x65,0x00,0x72,0x00,0x73, + 0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x20,0x00,0x37,0x00,0x37,0x00,0x35,0x00,0x2e, + 0x00,0x30,0x00,0x31,0x00,0x39,0x00,0x35,0x00,0x33,0x00,0x31,0x00,0x32,0x00,0x35, + 0x00,0x20,0x00,0x28,0x00,0x46,0x00,0x6f,0x00,0x6e,0x00,0x74,0x00,0x20,0x00,0x41, + 0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d,0x00,0x65,0x00,0x20,0x00,0x76, + 0x00,0x65,0x00,0x72,0x00,0x73,0x00,0x69,0x00,0x6f,0x00,0x6e,0x00,0x3a,0x00,0x20, + 0x00,0x36,0x00,0x2e,0x00,0x37,0x00,0x2e,0x00,0x32,0x00,0x29,0x00,0x46,0x00,0x6f, + 0x00,0x6e,0x00,0x74,0x00,0x41,0x00,0x77,0x00,0x65,0x00,0x73,0x00,0x6f,0x00,0x6d, + 0x00,0x65,0x00,0x36,0x00,0x46,0x00,0x72,0x00,0x65,0x00,0x65,0x00,0x2d,0x00,0x53, + 0x00,0x6f,0x00,0x6c,0x00,0x69,0x00,0x64,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00, + 0xff,0xdb,0x00,0x19,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, }; diff --git a/src/platform/input.hpp b/src/platform/input.hpp index c0d1f25..96c904f 100644 --- a/src/platform/input.hpp +++ b/src/platform/input.hpp @@ -26,7 +26,7 @@ struct Hotkey { bool valid() const { return !key.empty(); } }; -enum class Action { PriceCheck, ToggleSettings }; +enum class Action { PriceCheck, ToggleSettings, QuickPaste }; std::string to_string(const Hotkey& h); ///< e.g. "Ctrl+D" Hotkey parse_hotkey(const std::string& s); ///< inverse of to_string diff --git a/src/quickpaste.cpp b/src/quickpaste.cpp new file mode 100644 index 0000000..29b82bd --- /dev/null +++ b/src/quickpaste.cpp @@ -0,0 +1,82 @@ +#include "quickpaste.hpp" + +#include +#include + +namespace ppc { +namespace { + +bool space(unsigned char c) { return std::isspace(c) != 0; } + +/// Byte offset of the `n`th character, or the whole string when it has fewer. Continuation +/// bytes (10xxxxxx) are not characters, which is the entire rule. +size_t utf8_offset(std::string_view s, size_t n) { + size_t chars = 0, i = 0; + for (; i < s.size(); ++i) { + if ((static_cast(s[i]) & 0xC0) == 0x80) continue; + if (chars == n) return i; + ++chars; + } + return i; +} + +} // namespace + +size_t enabled_pastes(const std::vector& list) { + return static_cast(std::count_if(list.begin(), list.end(), + [](const Paste& p) { return p.enabled; })); +} + +std::vector active_pastes(const std::vector& list) { + std::vector out; + for (size_t i = 0; i < list.size() && out.size() < kMaxActivePastes; ++i) + if (list[i].enabled) out.push_back(i); + return out; +} + +size_t limit_enabled(std::vector& list) { + size_t seen = 0, turned_off = 0; + for (Paste& p : list) { + if (!p.enabled) continue; + if (seen < kMaxActivePastes) { + ++seen; + continue; + } + p.enabled = false; + ++turned_off; + } + return turned_off; +} + +bool move_paste(std::vector& list, size_t from, size_t to) { + if (from == to || from >= list.size() || to >= list.size()) return false; + Paste moved = std::move(list[from]); + list.erase(list.begin() + static_cast(from)); + list.insert(list.begin() + static_cast(to), std::move(moved)); + return true; +} + +std::string paste_preview(std::string_view body, size_t max_chars) { + std::string out; + bool gap = false; + for (const char ch : body) { + if (space(static_cast(ch))) { + gap = !out.empty(); + continue; + } + if (gap) out += ' '; + gap = false; + out += ch; + // One character past the budget, so a body that only just fits is not given an + // ellipsis it does not need. + if (out.size() > max_chars * 4) break; // 4 bytes is the longest UTF-8 character + } + const size_t cut = utf8_offset(out, max_chars); + if (cut < out.size()) { + out.resize(cut); + out += "\xe2\x80\xa6"; + } + return out; +} + +} // namespace ppc diff --git a/src/quickpaste.hpp b/src/quickpaste.hpp new file mode 100644 index 0000000..dfea93a --- /dev/null +++ b/src/quickpaste.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include + +/// The paste list: saved snippets a hotkey puts on the clipboard. +/// +/// This half is the model — the list, which of it the popup can offer, and how a body reads on +/// one line. It is `ppc_core`, so none of it knows about ImGui or the clipboard: the popup is +/// `screens/quickpaste_screen`, the write is `platform/clipboard`'s `clipboard_set_text`. +namespace ppc { + +/// One saved snippet: a name to recognise it by, and the text that goes on the clipboard. +/// The body is multi-line and the heading is not — the popup draws the heading whole and the +/// body as the single line `paste_preview` makes of it. +struct Paste { + std::string heading; + std::string body; + /// Whether it takes one of the nine slots the popup offers. Storage is unlimited; the + /// number keys are not (see `kMaxActivePastes`). + bool enabled = true; +}; + +/// How many pastes the popup can hold at once. **A limit on the keyboard, not on storage**: +/// picking by number is the whole point of the feature, a tenth entry would have no key to +/// press, and a list longer than a glance has already spent what it saves. +inline constexpr size_t kMaxActivePastes = 9; + +/// How many entries the popup would draw right now. +size_t enabled_pastes(const std::vector& list); + +/// Indices into `list` of the pastes the popup offers, in list order. Never more than +/// `kMaxActivePastes` — the popup's slot *n* is `active_pastes(...)[n]`, which is also the +/// number key that picks it. +std::vector active_pastes(const std::vector& list); + +/// Turn off everything enabled past the ninth. For loading a config file, which is +/// hand-editable: a file claiming twelve active pastes is not a reason to draw a popup with +/// keys nobody can press. Returns how many it turned off. +size_t limit_enabled(std::vector& list); + +/// Move the entry at `from` to sit at `to`, shifting the rest along. Out-of-range or equal +/// indices are a no-op and answer false, which is what makes it safe to call from a drag that +/// has run off the end of the list. +bool move_paste(std::vector& list, size_t from, size_t to); + +/// The body as one line: every run of whitespace — newlines included — collapsed to a single +/// space, the ends trimmed, and the result cut to `max_chars` characters with an ellipsis. The +/// cut is on a UTF-8 boundary, so it can never split a character in half. +/// +/// `max_chars` is a bound on the work the popup does, not the width it draws at: the row is +/// clipped to the pixels it actually has (`ellipsize` in the screen). A body of a hundred +/// kilobytes must not turn into a hundred kilobytes of text measurement. +std::string paste_preview(std::string_view body, size_t max_chars = 160); + +} // namespace ppc diff --git a/src/screens/quickpaste_screen.cpp b/src/screens/quickpaste_screen.cpp new file mode 100644 index 0000000..8415f87 --- /dev/null +++ b/src/screens/quickpaste_screen.cpp @@ -0,0 +1,153 @@ +#include "screens/quickpaste_screen.hpp" + +#include +#include + +#include + +#include "app.hpp" +#include "quickpaste.hpp" +#include "ui/glyphs.hpp" +#include "ui/strings.hpp" +#include "ui/theme.hpp" + +namespace ppc { +namespace { + +/// The popup's shape. Every one of these is read twice — by `quickpaste_size`, which places the +/// window before there is a frame to measure in, and by the drawing below. They are the same +/// numbers or the list does not fill its own window. +constexpr float kWindowW = 380.0f; +constexpr float kRowH = 46.0f; ///< tall enough for the heading and the line under it +constexpr float kSquareGap = 10.0f; +constexpr float kHeadingSize = 17.0f; +constexpr float kPreviewSize = 14.0f; +constexpr float kNumberSize = 20.0f; +constexpr float kFooterH = 38.0f; ///< the separator and the Add button under it +constexpr float kEmptyH = 26.0f; ///< the one line drawn when nothing is enabled + +/// `s` cut to `max_w` pixels with an ellipsis, in whatever font is current. Binary search over +/// character boundaries rather than a walk: this runs for every row of every frame the popup is +/// up, and `CalcTextSize` is itself a walk. +std::string ellipsize(const std::string& s, float max_w) { + if (s.empty() || ImGui::CalcTextSize(s.c_str()).x <= max_w) return s; + static constexpr const char* kEllipsis = "\xe2\x80\xa6"; + const float ell = ImGui::CalcTextSize(kEllipsis).x; + std::vector at{0}; // byte offset of each character + for (size_t i = 1; i < s.size(); ++i) + if ((static_cast(s[i]) & 0xC0) != 0x80) at.push_back(i); + size_t lo = 0, hi = at.size(); // characters that fit + while (lo < hi) { + const size_t mid = (lo + hi + 1) / 2; + const size_t bytes = mid == at.size() ? s.size() : at[mid]; + if (ImGui::CalcTextSize(s.c_str(), s.c_str() + bytes).x + ell <= max_w) lo = mid; + else hi = mid - 1; + } + return s.substr(0, lo == at.size() ? s.size() : at[lo]) + kEllipsis; +} + +void draw_text_at(const ImVec2& pos, const char* s) { + ImGui::SetCursorScreenPos(pos); + ImGui::TextUnformatted(s); +} + +/// One paste: the number key on the left, its heading and the first line of its text on the +/// right. The whole strip is the click target — a `Selectable` with everything drawn on top of +/// it, the same shape the unique picker uses — because the thing being aimed at is the entry, +/// not the words in it. +void draw_row(App& app, size_t slot, size_t index) { + const Paste& p = app.config().pastes[index]; + ImGui::PushID(static_cast(index)); + const ImVec2 at = ImGui::GetCursorPos(); + const bool picked = ImGui::Selectable("##pick", false, ImGuiSelectableFlags_AllowOverlap, + ImVec2(0, kRowH)); + const ImVec2 p0 = ImGui::GetItemRectMin(), p1 = ImGui::GetItemRectMax(); + + // The square carries the key you press instead of aiming. Its own background, darker than + // the row, so it reads as a key cap rather than as the first word of the heading. + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 sq1(p0.x + kRowH, p1.y); + dl->AddRectFilled(p0, sq1, ImGui::GetColorU32(ui::col::kTabIdle), 2.0f); + dl->AddRect(p0, sq1, ImGui::GetColorU32(ui::col::kBorder), 2.0f); + + ImGui::PushFont(app.fonts().small_caps, kNumberSize); + const std::string key = std::to_string(slot + 1); + const ImVec2 ks = ImGui::CalcTextSize(key.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kAccent); + draw_text_at(ImVec2(p0.x + (kRowH - ks.x) * 0.5f, p0.y + (kRowH - ks.y) * 0.5f), key.c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + const float text_x = p0.x + kRowH + kSquareGap; + const float text_w = p1.x - text_x; + ImGui::PushFont(app.fonts().small_caps, kHeadingSize); + const float head_h = ImGui::GetTextLineHeight(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kTitle); + const std::string heading = + p.heading.empty() ? std::string(ui::text(ui::Msg::PasteUntitled)) : p.heading; + draw_text_at(ImVec2(text_x, p0.y + 6.0f), ellipsize(heading, text_w).c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + ImGui::PushFont(app.fonts().regular, kPreviewSize); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kTextDim); + const std::string body = paste_preview(p.body); + draw_text_at(ImVec2(text_x, p0.y + 8.0f + head_h), + ellipsize(body.empty() ? std::string(ui::text(ui::Msg::PasteEmptyBody)) : body, + text_w) + .c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + // The cursor was moved back over the row to draw on it, so the row has to close itself or + // the next one starts inside this one. + ImGui::SetCursorPos(at); + ImGui::Dummy(ImVec2(0, kRowH)); + ImGui::PopID(); + if (picked) app.pick_paste(index); +} + +} // namespace + +void quickpaste_size(size_t entries, int* w, int* h) { + const ImGuiStyle& style = ImGui::GetStyle(); + const float rows = entries ? static_cast(entries) * kRowH + + static_cast(entries - 1) * style.ItemSpacing.y + : kEmptyH; + *w = static_cast(kWindowW); + *h = static_cast(style.WindowPadding.y * 2.0f + rows + kFooterH); +} + +void draw_quickpaste_screen(App& app) { + const ui::Theme theme(app.config().reduce_transparency); + ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("QuickPaste", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings); + + // A quieter hover than the theme's Selectable: the row is the size of a button and a + // full-strength highlight on something the mouse only passes over reads as a selection. + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::col::kFrameHovered); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::col::kFrameActive); + const std::vector active = active_pastes(app.config().pastes); + for (size_t slot = 0; slot < active.size(); ++slot) draw_row(app, slot, active[slot]); + if (active.empty()) { + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", ui::text(ui::Msg::QuickPasteNone)); + } + ImGui::PopStyleColor(2); + + // The way in to where these are managed, since a popup with nothing in it has to say what + // to do about that — and Settings is where it is said. + ImGui::Separator(); + const std::string add = app.fonts().has_glyphs + ? std::string(ui::kGlyphAdd) + " " + ui::text(ui::Msg::QuickPasteAdd) + : std::string(ui::text(ui::Msg::QuickPasteAdd)); + if (ImGui::Button(add.c_str())) app.open_paste_settings(); + + ImGui::End(); +} + +} // namespace ppc diff --git a/src/screens/quickpaste_screen.hpp b/src/screens/quickpaste_screen.hpp new file mode 100644 index 0000000..81e4f02 --- /dev/null +++ b/src/screens/quickpaste_screen.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace ppc { +class App; + +/// The paste list, drawn at the cursor: one row per enabled paste, picked by click or by the +/// number key beside it. +void draw_quickpaste_screen(App& app); + +/// How big that window has to be for `entries` pastes, in pixels. +/// +/// **Declared, not measured** — the same rule Settings' fixed size follows, and here it is not a +/// preference: `App::place_overlay` sizes the window before the frame that would measure it, so +/// a height taken from the last frame would place the popup for the previous item every time. +/// Every constant behind it is in the implementation, beside the code that draws to them. +void quickpaste_size(size_t entries, int* w, int* h); + +} // namespace ppc diff --git a/src/screens/settings_screen.cpp b/src/screens/settings_screen.cpp index 2f87f75..943f90b 100644 --- a/src/screens/settings_screen.cpp +++ b/src/screens/settings_screen.cpp @@ -2,14 +2,19 @@ #include #include +#include #include #include +#include #include #include #include #include "app.hpp" +#include "platform/clipboard.hpp" +#include "quickpaste.hpp" +#include "ui/glyphs.hpp" #include "ui/strings.hpp" #include "ui/theme.hpp" #include "util/debug_log.hpp" @@ -411,6 +416,7 @@ void general_tab(App& app, Config& c) { section(app, ui::text(ui::Msg::SectionHotkeys)); hotkey_row(app, ui::text(ui::Msg::HotkeyPriceCheck), Action::PriceCheck, c.price_check); hotkey_row(app, ui::text(ui::Msg::HotkeySettings), Action::ToggleSettings, c.settings); + hotkey_row(app, ui::text(ui::Msg::HotkeyQuickPaste), Action::QuickPaste, c.quick_paste); section(app, ui::text(ui::Msg::SectionAppearance)); // Nothing to apply: the dialog's own theme reads this every frame, and App hands it to the @@ -486,6 +492,265 @@ void price_check_tab(App& app, Config& c) { "%.3f"); } +/// A square icon button, or the word behind it when the glyph subset and `ui/glyphs.hpp` have +/// drifted apart. `tip` is what it does, since an icon cannot say so itself. +bool icon_button(App& app, const char* glyph, const char* word, const char* tip, float w) { + const bool pressed = ImGui::Button(app.fonts().has_glyphs ? glyph : word, ImVec2(w, 0)); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; +} + +constexpr float kPasteIconW = 30.0f; +constexpr float kPasteGripW = 24.0f; +constexpr float kPasteSlotW = 18.0f; + +/// What the popup will do with the list, said in the list: the number key this entry answers +/// to, or an empty column where there is no key to press. A column of its own either way, or +/// the headings of the enabled and the disabled entries would not line up. +void paste_slot_number(const Config& c, size_t index) { + const float x = ImGui::GetCursorPosX(); + const std::vector active = active_pastes(c.pastes); + for (size_t slot = 0; slot < active.size(); ++slot) { + if (active[slot] != index) continue; + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::col::kAccent); + ImGui::Text("%zu", slot + 1); + ImGui::PopStyleColor(); + ImGui::SameLine(0.0f, 0.0f); // back onto the row; the width is set below + break; + } + // Set rather than advanced: `SameLine(offset)` measures from the window's left edge, not + // from the cursor, which puts the whole row on top of itself. + ImGui::SetCursorPosX(x + kPasteSlotW + ImGui::GetStyle().ItemSpacing.x); +} + +/// What a row asked for, to be done once the loop drawing the list has finished: a delete changes +/// the vector being walked, and a reorder needs the height of a row that has not been drawn yet. +struct PasteAction { + size_t removed = 0; + bool removing = false; + size_t grabbed = 0; + bool grabbing = false; // a handle was pressed this frame + std::vector heights; // each row's, in list order, as drawn this frame +}; + +/// A reorder in progress. Tracked by us and not by ImGui's held-item id, because a row's id is +/// its position in the list: the moment a move lands, ImGui is holding the handle of the row that +/// slid into the old place, and the drag would carry on shoving whatever kept arriving there. +/// What has to survive a move is the paste's identity, so that is what is kept. +struct PasteDrag { + bool active = false; + size_t index = 0; // where the dragged paste is *now* + float paid = 0.0f; // pixels of the pointer's travel already spent on moves +}; +PasteDrag paste_drag; + +/// Turn the pointer's travel into moves. A row is picked up only once the pointer has covered the +/// whole height of the neighbour it is heading for, and that height is then taken off the tally — +/// which is both the hysteresis and the reason the row stays under the hand. +/// +/// Asking instead which row the pointer is *over* reverses itself whenever two rows differ in +/// height, and these do: a move drops the pointer back over the row it just came from, that reads +/// as a move the other way, and the list flickers between the two until the button comes up. +void resolve_paste_drag(Config& c, const PasteAction& act) { + if (act.grabbing) paste_drag = PasteDrag{true, act.grabbed, 0.0f}; + if (!paste_drag.active) return; + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left) || paste_drag.index >= act.heights.size()) { + paste_drag = PasteDrag{}; + return; + } + // Raw: the default threshold would hold the delta at zero for the first few pixels and then + // hand over all of them at once, which is a jump the tally cannot account for. + const float travel = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left, 0.0f).y - paste_drag.paid; + const bool down = travel > 0.0f; + if (down ? paste_drag.index + 1 >= act.heights.size() : paste_drag.index == 0) { + // Travel off the end of the list is forgotten rather than banked: banked, the hand would + // owe that distance back before the row it is still holding would move again. + paste_drag.paid += travel; + return; + } + const size_t to = down ? paste_drag.index + 1 : paste_drag.index - 1; + // One move per frame: the heights were measured in the order the list had when it was drawn, + // and a second move would be reading them for an order that no longer exists. A flick that + // outruns this is not lost — it stays on the tally and is paid off over the next frames. + const float step = act.heights[to]; + if (std::abs(travel) < step) return; + if (!move_paste(c.pastes, paste_drag.index, to)) return; + paste_drag.index = to; + paste_drag.paid += down ? step : -step; +} + +/// One entry: what the popup would show of it, plus what can be done to it here. The heading and +/// the text are **read-only** — this list is for arranging, and a field that is typed into is a +/// field that has to be finished before anything else can be clicked. Writing is the dialog. +void paste_row(App& app, Config& c, size_t i, PasteAction& act) { + Paste& p = c.pastes[i]; + ImGui::PushID(static_cast(i)); + const float top = ImGui::GetCursorScreenPos().y; + + // The handle. Pressing it starts a reorder; the drag itself is resolved after the loop, which + // needs the heights of rows this one has not reached yet. ImGui has no drag-and-drop for a + // list this small, and the two-button version costs a row of chrome per entry. + // + // While a drag runs, the held look is painted on the row being moved rather than on the id + // ImGui is holding — those part company on the first move, and the pressed handle left behind + // on a row standing still is the drag appearing to have gone somewhere it has not. + const bool held = paste_drag.active && paste_drag.index == i; + const ImVec4 grip = ImGui::GetStyleColorVec4(held ? ImGuiCol_ButtonActive : ImGuiCol_Button); + ImGui::PushStyleColor(ImGuiCol_Button, grip); + if (paste_drag.active) { + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, grip); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, grip); + } + icon_button(app, ui::kGlyphGrip, "=", ui::text(ui::Msg::PasteReorder), kPasteGripW); + ImGui::PopStyleColor(paste_drag.active ? 3 : 1); + if (ImGui::IsItemActivated()) { + act.grabbed = i; + act.grabbing = true; + } + ImGui::SameLine(); + + // Off is the only way to make room, so the box that would take the tenth slot is the one + // that is disabled — no error to read, and the row above says how many are left. + const bool full = enabled_pastes(c.pastes) >= kMaxActivePastes; + ImGui::BeginDisabled(!p.enabled && full); + ImGui::Checkbox("##on", &p.enabled); + ImGui::EndDisabled(); + ImGui::SameLine(); + + paste_slot_number(c, i); + + const float heading_x = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + if (p.heading.empty()) ImGui::TextDisabled("%s", ui::text(ui::Msg::PasteUntitled)); + else ImGui::TextUnformatted(p.heading.c_str()); + + right_align(kPasteIconW * 2.0f + ImGui::GetStyle().ItemSpacing.x); + if (icon_button(app, ui::kGlyphEdit, "...", ui::text(ui::Msg::PasteEdit), kPasteIconW)) { + app.paste_edit() = PasteEdit{true, false, i, p}; + } + ImGui::SameLine(); + if (icon_button(app, ui::kGlyphDelete, "X", ui::text(ui::Msg::PasteDelete), kPasteIconW)) { + act.removed = i; + act.removing = true; + } + + // The text, one line and dim, under the heading and in its column. The same line the popup + // draws, so what is arranged here is what will be read there. + ImGui::SetCursorPosX(heading_x); + const std::string preview = paste_preview(p.body, 200); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", preview.empty() ? ui::text(ui::Msg::PasteEmptyBody) + : preview.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + act.heights.push_back(ImGui::GetCursorScreenPos().y - top); + ImGui::PopID(); +} + +/// Writing a paste: the one place a heading or a body is typed. A dialog rather than fields in +/// the list, because the body is multi-line and a list whose rows are text boxes is a list you +/// cannot scan. +/// +/// It edits a **draft**, which Done copies back — Cancel has to be able to leave nothing behind, +/// and Settings as a whole is still not saved until its own Save. +void paste_dialog(App& app, Config& c) { + PasteEdit& pe = app.paste_edit(); + if (pe.open && !ImGui::IsPopupOpen("##paste_edit")) ImGui::OpenPopup("##paste_edit"); + // The full width of the dialog it opens over, centred on it. The text being written is a + // chat line or a search string, so width is what it wants and height is what it does not: + // three lines by default, and the box scrolls for the rare paste that runs longer. + const ImVec2 display = ImGui::GetIO().DisplaySize; + ImGui::SetNextWindowSize(ImVec2(display.x, 0.0f)); + ImGui::SetNextWindowPos(ImVec2(display.x * 0.5f, display.y * 0.5f), ImGuiCond_Always, + ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("##paste_edit", nullptr, ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove)) + return; + + section(app, ui::text(pe.adding ? ui::Msg::PasteNew : ui::Msg::PasteEdit)); + ImGui::InputTextWithHint(row(ui::text(ui::Msg::PasteHeading)), + ui::text(ui::Msg::PasteHeadingHint), &pe.draft.heading); + row_label(ui::text(ui::Msg::PasteBody)); + ImGui::InputTextMultiline("##body", &pe.draft.body, + ImVec2(-FLT_MIN, ImGui::GetTextLineHeightWithSpacing() * 3.0f + + ImGui::GetStyle().FramePadding.y * 2.0f)); + row_gutter(); + if (pe.draft.body.size() > kMaxClipboardWrite) { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(kWarn, ui::text(ui::Msg::PasteTooLong), kMaxClipboardWrite); + ImGui::PopTextWrapPos(); + } else { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::PasteBodyHint)); + ImGui::PopTextWrapPos(); + } + + ImGui::Separator(); + // Nothing to paste is not an error worth wording: the button that would store it is simply + // not available, which is the same answer the ninth-slot checkbox gives. + const bool storable = !pe.draft.body.empty() && pe.draft.body.size() <= kMaxClipboardWrite; + ImGui::BeginDisabled(!storable); + ImGui::PushStyleColor(ImGuiCol_Button, ui::col::kButtonHovered); + const std::string done = app.fonts().has_glyphs + ? std::string(ui::kGlyphConfirm) + " " + + ui::text(ui::Msg::PasteDone) + : std::string(ui::text(ui::Msg::PasteDone)); + if (ImGui::Button(done.c_str(), ImVec2(120, 0))) { + if (pe.adding) { + // Enabled if there is a slot for it, and off when the nine are taken — a new paste + // that silently displaced one of them would be worse than one with no number yet. + pe.draft.enabled = enabled_pastes(c.pastes) < kMaxActivePastes; + c.pastes.push_back(pe.draft); + } else if (pe.index < c.pastes.size()) { + c.pastes[pe.index].heading = pe.draft.heading; + c.pastes[pe.index].body = pe.draft.body; + } + pe = PasteEdit{}; + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(ui::text(ui::Msg::PasteCancel), ImVec2(120, 0))) { + pe = PasteEdit{}; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void quickpaste_tab(App& app, Config& c) { + section(app, ui::text(ui::Msg::SectionPastes)); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", ui::text(ui::Msg::PasteListHelp)); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + + PasteAction act; + for (size_t i = 0; i < c.pastes.size(); ++i) paste_row(app, c, i, act); + if (c.pastes.empty()) ImGui::TextDisabled("%s", ui::text(ui::Msg::PasteNone)); + // After the loop, never inside it: both of these change the list the loop is walking. + resolve_paste_drag(c, act); + if (act.removing && act.removed < c.pastes.size()) + c.pastes.erase(c.pastes.begin() + static_cast(act.removed)); + + ImGui::Separator(); + const std::string add = app.fonts().has_glyphs + ? std::string(ui::kGlyphAdd) + " " + ui::text(ui::Msg::PasteNew) + : std::string(ui::text(ui::Msg::PasteNew)); + if (ImGui::Button(add.c_str())) app.paste_edit() = PasteEdit{true, true, 0, Paste{}}; + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + const size_t on = enabled_pastes(c.pastes); + if (on >= kMaxActivePastes) + ImGui::TextDisabled(ui::text(ui::Msg::PasteSlotsFull), kMaxActivePastes); + else + ImGui::TextDisabled(ui::text(ui::Msg::PasteSlotsLeft), on, kMaxActivePastes); + + paste_dialog(app, c); +} + void application_tab(App& app, Config& c) { section(app, ui::text(ui::Msg::SectionGameData)); data_row(app); @@ -542,8 +807,13 @@ struct Tab { constexpr Tab kTabs[]{ {ui::Msg::TabGeneral, &general_tab}, {ui::Msg::TabPriceCheck, &price_check_tab}, + {ui::Msg::TabQuickPaste, &quickpaste_tab}, {ui::Msg::TabApplication, &application_tab}, }; +// The paste popup's "add one" opens Settings on this tab by number, and a tab inserted above +// it would quietly send that button somewhere else. +static_assert(kTabs[kQuickPasteTab].draw == &quickpaste_tab, + "kQuickPasteTab must name the tab the paste list is on"); /// The fixed tab strip. Buttons rather than `ImGui::BeginTabBar`, because the game marks the /// open tab by lighting its *name* and ImGui has no colour for a selected tab's label. diff --git a/src/screens/settings_screen.hpp b/src/screens/settings_screen.hpp index 621a430..8770871 100644 --- a/src/screens/settings_screen.hpp +++ b/src/screens/settings_screen.hpp @@ -3,4 +3,10 @@ namespace ppc { class App; void draw_settings_screen(App& app); + +/// Which tab the paste list is on, for the popup's way in to it. A constant rather than a +/// search for the name: the tab strip is a table of function pointers, and the one thing that +/// could go wrong here — this number naming a different tab — is caught by a `static_assert` +/// beside that table. +inline constexpr int kQuickPasteTab = 2; } // namespace ppc diff --git a/src/ui/glyphs.hpp b/src/ui/glyphs.hpp index 0678ab8..e700c00 100644 --- a/src/ui/glyphs.hpp +++ b/src/ui/glyphs.hpp @@ -14,9 +14,13 @@ namespace ppc::ui { inline constexpr const char* kGlyphConfirm = "\xef\x80\x8c"; ///< U+F00C, check inline constexpr const char* kGlyphReset = "\xef\x83\xa2"; ///< U+F0E2, arrow-rotate-left +inline constexpr const char* kGlyphAdd = "\xef\x83\xbe"; ///< U+F0FE, square-plus +inline constexpr const char* kGlyphEdit = "\xef\x8c\x84"; ///< U+F304, pen +inline constexpr const char* kGlyphDelete = "\xef\x8b\xad"; ///< U+F2ED, trash-can +inline constexpr const char* kGlyphGrip = "\xef\x9e\xa4"; ///< U+F7A4, grip-lines /// The codepoints behind the above, for the one place that has to ask the atlas whether they /// actually baked rather than trusting that they did. -inline constexpr unsigned int kGlyphCodepoints[]{0xF00C, 0xF0E2}; +inline constexpr unsigned int kGlyphCodepoints[]{0xF00C, 0xF0E2, 0xF0FE, 0xF304, 0xF2ED, 0xF7A4}; } // namespace ppc::ui diff --git a/src/ui/strings.cpp b/src/ui/strings.cpp index a421d2b..ebddbba 100644 --- a/src/ui/strings.cpp +++ b/src/ui/strings.cpp @@ -20,6 +20,7 @@ constexpr const char* kEnglish[]{ "General", "Price check", + "QuickPaste", "Application", "League and account", @@ -28,6 +29,7 @@ constexpr const char* kEnglish[]{ "Trade search", "Filter ranges", "Hotkeys", + "Pastes", "Price-check panel", "Game data", "Updates", @@ -69,6 +71,7 @@ constexpr const char* kEnglish[]{ "Price check", "Settings", + "QuickPaste", "press keys\xe2\x80\xa6", "Reduce transparency", @@ -79,6 +82,27 @@ constexpr const char* kEnglish[]{ "Stash edge", "Inventory edge", + "The QuickPaste hotkey opens this list at your cursor. Picking one puts its text on your " + "clipboard \xe2\x80\x94 you paste it yourself, where you meant to.", + "Nothing here yet.", + "%zu of %zu slots used \xe2\x80\x94 the popup picks by number key.", + "All %zu slots are taken. Turn one off to give another a number.", + "(no heading)", + "(nothing to paste)", + "New paste", + "Edit paste", + "Delete", + "Drag to reorder", + "Heading", + "What it is", + "Text", + "What goes on the clipboard. Newlines are kept.", + "Done", + "Cancel", + "Too long to put on the clipboard \xe2\x80\x94 the ceiling is %zu bytes.", + "No pastes enabled.", + "Add a paste", + "Bundle", "Downloading %.1f / %.1f MB", "Downloading\xe2\x80\xa6", diff --git a/src/ui/strings.hpp b/src/ui/strings.hpp index a78eaf4..16052c3 100644 --- a/src/ui/strings.hpp +++ b/src/ui/strings.hpp @@ -29,6 +29,7 @@ enum class Msg : uint16_t { TabGeneral, TabPriceCheck, + TabQuickPaste, TabApplication, SectionAccount, @@ -37,6 +38,7 @@ enum class Msg : uint16_t { SectionTradeSearch, SectionFilterRanges, SectionHotkeys, + SectionPastes, SectionPricePanel, SectionGameData, SectionUpdates, @@ -75,6 +77,7 @@ enum class Msg : uint16_t { HotkeyPriceCheck, HotkeySettings, + HotkeyQuickPaste, PressKeys, ReduceTransparency, @@ -85,6 +88,26 @@ enum class Msg : uint16_t { StashEdge, InventoryEdge, + PasteListHelp, + PasteNone, ///< Settings, with nothing in the list yet + PasteSlotsLeft, ///< "%zu", "%zu" — active pastes and the ceiling + PasteSlotsFull, ///< "%zu" — the ceiling + PasteUntitled, + PasteEmptyBody, + PasteNew, + PasteEdit, + PasteDelete, + PasteReorder, + PasteHeading, + PasteHeadingHint, + PasteBody, + PasteBodyHint, + PasteDone, + PasteCancel, + PasteTooLong, ///< "%zu" — the byte ceiling + QuickPasteNone, ///< the popup, with nothing enabled to offer + QuickPasteAdd, + Bundle, Downloading, ///< "%.1f", "%.1f" — megabytes done and total DownloadingPlain, diff --git a/tests/quickpaste_test.cpp b/tests/quickpaste_test.cpp new file mode 100644 index 0000000..7411f78 --- /dev/null +++ b/tests/quickpaste_test.cpp @@ -0,0 +1,108 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include + +#include "config.hpp" +#include "quickpaste.hpp" + +using namespace ppc; + +// The paste list's model. It is here rather than beside the popup because the popup links ImGui +// and this does not — and because the two things worth pinning are both rules the UI only +// *enforces*: which entries get a number key, and what a multi-line body reads as on one line. + +namespace { + +std::vector list_of(std::initializer_list enabled) { + std::vector v; + for (const bool on : enabled) v.push_back(Paste{"h", "b", on}); + return v; +} + +} // namespace + +TEST_CASE("the popup offers the enabled entries, in list order") { + const std::vector v = list_of({false, true, false, true}); + const std::vector active = active_pastes(v); + REQUIRE(active.size() == 2); + CHECK(active[0] == 1); + CHECK(active[1] == 3); + CHECK(enabled_pastes(v) == 2); +} + +TEST_CASE("a tenth enabled paste gets no slot, and the ninth still does") { + std::vector v = list_of({true, true, true, true, true, true, true, true, true, true}); + CHECK(active_pastes(v).size() == kMaxActivePastes); + // Every slot the popup draws is a key somebody can press, so the last one is the ninth + // entry and not the tenth. + CHECK(active_pastes(v).back() == kMaxActivePastes - 1); + + // A hand-edited config is where this arrives from, and loading it turns the extras off + // rather than drawing keys nobody can press. + CHECK(limit_enabled(v) == 1); + CHECK(enabled_pastes(v) == kMaxActivePastes); + CHECK_FALSE(v.back().enabled); + CHECK(limit_enabled(v) == 0); // already within the ceiling: nothing to do +} + +TEST_CASE("moving an entry shifts the rest along") { + std::vector v; + for (const char* h : {"a", "b", "c", "d"}) v.push_back(Paste{h, "x", true}); + + CHECK(move_paste(v, 3, 0)); + CHECK(v[0].heading == "d"); + CHECK(v[1].heading == "a"); + CHECK(v[3].heading == "c"); + + // A drag that has run off the end of the list asks for a move that cannot happen, which is + // why this answers rather than clamping: the caller redraws the list it already has. + CHECK_FALSE(move_paste(v, 0, 0)); + CHECK_FALSE(move_paste(v, 0, 4)); + CHECK_FALSE(move_paste(v, 9, 1)); +} + +TEST_CASE("a body reads as one line") { + CHECK(paste_preview(" first\n\tsecond third \n\n") == "first second third"); + CHECK(paste_preview("").empty()); + CHECK(paste_preview("\n \t ").empty()); +} + +#ifndef _WIN32 +TEST_CASE("the list survives a save and a load, ceiling included") { + const std::filesystem::path dir = std::filesystem::temp_directory_path() / "ppc-paste-test"; + std::filesystem::remove_all(dir); + setenv("XDG_CONFIG_HOME", dir.c_str(), 1); + + Config c; + for (int i = 0; i < 11; ++i) + c.pastes.push_back(Paste{"heading " + std::to_string(i), "line one\nline two", true}); + c.quick_paste = Hotkey{Mod::Alt, "V"}; + REQUIRE(c.save()); + + const Config back = Config::load(); + REQUIRE(back.pastes.size() == 11); // every one is kept — the ceiling is on the *enabled* + CHECK(back.pastes[0].heading == "heading 0"); + CHECK(back.pastes[0].body == "line one\nline two"); // newlines are the whole point of a body + CHECK(to_string(back.quick_paste) == "Alt+V"); + // A file claiming eleven active pastes was written by hand; loading it is where that is + // answered, not the popup. + CHECK(enabled_pastes(back.pastes) == kMaxActivePastes); + CHECK_FALSE(back.pastes[9].enabled); + CHECK_FALSE(back.pastes[10].enabled); + + std::filesystem::remove_all(dir); +} +#endif + +TEST_CASE("a body past the budget ends in an ellipsis, never mid-character") { + CHECK(paste_preview("abcdef", 3) == "abc\xe2\x80\xa6"); + CHECK(paste_preview("abc", 3) == "abc"); // exactly the budget needs no ellipsis + // Three-byte characters: the cut counts characters, and a half-written UTF-8 sequence is + // what draws as a box in the popup. + CHECK(paste_preview("\xe2\x86\x92\xe2\x86\x92\xe2\x86\x92", 2) == + "\xe2\x86\x92\xe2\x86\x92\xe2\x80\xa6"); + // The whole point of the budget: a body of any size costs a bounded amount of work. + CHECK(paste_preview(std::string(100000, 'x'), 10) == "xxxxxxxxxx\xe2\x80\xa6"); +} From 3b99674e918bc39af559339ab98d75fc06f9e14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Mon, 10 Aug 2026 21:05:11 +0200 Subject: [PATCH 3/3] chore: version 0.5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index bd73f47..2eb3c4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4 +0.5