diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dcefc24..08cf4d8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -120,6 +120,18 @@ For text this project did not write — trade account and character names, which Cyrillic, Hangul or CJK, none of which Fontin covers — a fallback face is loaded from **whatever the operating system already ships**. Nothing is bundled for it and nothing is redistributed. +## Font Awesome Free + +The buttons a word does not fit on are drawn in **[Font Awesome Free](https://fontawesome.com)** +Solid, by Fonticons, Inc. Only the codepoints actually used are bundled — `scripts/fetch-glyphs.sh` +subsets the release down to those and no more, and +[assets/fonts/README.md](assets/fonts/README.md) says which and why. The website's navigation +icons are the same set, inlined into `site/style.css` rather than fetched from anywhere. + +Icons are **CC BY 4.0** and the fonts are **SIL OFL 1.1**; both permit redistribution with +attribution, which this section is. The full text is fetched to +`assets/fonts/FontAwesome-LICENSE.txt`. + ## Icon `assets/popc_icon.png` and the `.ico` generated from it are part of this repository and covered by diff --git a/docs/architecture.md b/docs/architecture.md index c1e7ffd..b7e1184 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -274,6 +274,15 @@ and the Save row sit outside it and stay put when they do — a form whose Save is the failure that shape exists to prevent. Adding a section needs no new number; adding one that overflows 720 needs this one raised. +**The two paths the dialog names go through `path_line()`** — the config file beside Save, and the +debug log under Diagnostics. Both are drawn through `paths::display_path`, which folds the home +directory back into `~` or `%APPDATA%`, and both open their **folder** on a click +(`paths::file_url` → `SDL_OpenURL`). Three reasons, and they all point the same way: the line has +one line of room and an absolute path does not reliably fit it; this dialog is the one people +screenshot, and an absolute path names the person who took it; and a `.json` or a `.log` is a file +a desktop may have no handler for, while its folder is a thing every file manager opens. The whole +path is still in the hover, which is where the shortening gives back what it took. + **The look is `src/ui/theme.cpp`** — the game's palette and control shapes, sampled off its Options dialog: near-black frames under hairline brown borders, headings and the title in the small-caps face, the left column tinted where the value beside it is not, and orange for check marks, slider @@ -344,7 +353,9 @@ appears while the game is the window in front). `PPC_DEV_UPDATE_URL=` point 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). `PPC_REPORT_URL=` points the bug reporter at a relay of your own, which is how its two outcomes are seen without posting into the -real channel — see [reporting.md](reporting.md). +real channel — see [reporting.md](reporting.md). `PPC_DEV_ANON=1` replaces every seller's handle +in the results with its position for the whole run — the masking a bug-report capture already +does for one frame, held on, because `scripts/capture-screenshots.sh` publishes what it takes. The Windows binary is **GUI-subsystem** (`WIN32_EXECUTABLE`, entered at `WinMain` in `src/main.cpp`): a console-subsystem build pops a console window beside an application whose whole UI is an overlay diff --git a/docs/reporting.md b/docs/reporting.md index b8b03ad..323c56a 100644 --- a/docs/reporting.md +++ b/docs/reporting.md @@ -66,7 +66,9 @@ resize the dialog brings must not land inside one. the user's own, on the row `account_name` marks as theirs. What a maintainer needs off the picture is that these are twenty different sellers and which row is which; the handles are somebody else's name and are worth nothing in a bug report. Prices, ages and the counts stay, - because those are what a mispricing is read against. + because those are what a mispricing is read against. The predicate is `App::mask_sellers`, not + `report_capture_pending` directly: `PPC_DEV_ANON` holds the same masking on for a whole run, + which is what the website's screenshots are taken with, and both exist for the same reason. - **No button shows its tooltip.** The cursor is on the report button when it is pressed, so without this every capture carries "Report a Bug" hovering over the panel — a picture of the act of reporting rather than of the thing being reported. diff --git a/scripts/build-site.sh b/scripts/build-site.sh index 28cfe42..9b0f543 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -42,11 +42,11 @@ PAGES=( # rather than a row of broken images. No `|` or `;` inside the copy — they are the separators. SHOTS=( "panel-rare.png|The Price Check Window|Just one hotkey away;Rebuilt item preview;Modifier parsing including Rank, Tier and Ranges;Pre-selected modifiers based on strategies, but you decide what to search by" - "listing-hover.png|See what you are comparing to|Item previews straight from the live trade site results;Rendered exactly the way your own item is;See the asking price and the gold fee as a bonus" - "unique-reference.png|A going rate, not just a list of prices|Whenever available, see the current price recorded by poe.ninja;See which way the price is going, with a week of history behind it;Recognises variable modifiers (can roll n of m modifiers)" + "listing-hover.png|See what you are comparing to|Item previews straight from the live trade site results;Rendered exactly the way your own item is;See the asking price and the gold fee as a bonus;Whenever available, the going rate poe.ninja records, and which way it is heading with a week of history behind it;Recognises variable modifiers (can roll n of m modifiers)" "map.png|Every kind of item asks its own question|Different item, different strategy;Smart search for maps based on Tier, Quantity, More X modifiers…;Custom strategy for Blighted, Blight-ravaged, Corrupted or Influenced maps;Valdo map reward and Void matching" "currency-exchange.png|Faustus does the work, you should see it|See how an item tradeable on the currency exchange actually trades;See the average price, volume, maximums and minimums for the stated period;Straight from GGG, published once each hour has closed;Every hour ever published* is crawled, going all the way back to Settlers, so no item that has ever traded there is missed;* Only to know what trades there at all — the prices you see are always the single hour named under the table" "unidentified-picker.png|It knows what it cannot know|Identify an unidentified unique from a list;Pictures included! (unless they aren't);The trade site search matches the Identified/Unidentified status, so the listings are the same product you are holding;Careful: poe.ninja shows you the identified price — don't forget to search!;Not a gambler? Never turn a Watcher's Eye into a 5c item*;* Without checking what the unidentified ones are selling for first!" + "report.png|Something priced wrong? Say so from where it happened|Bug reporting with zero friction;Sends what the tool actually used to identify the item, which is the thing a maintainer has to see;As anonymous as it can be made — every account name is scrubbed out of the trade results before the picture is taken;Everything you see is what will be sent. Nothing more. No fingerprint, no identifying information, just what matters for fixing the problem;A screenshot sometimes tells the rest of the story, so you get the preview and the choice of whether to attach it" "settings.png|Some Assembly Required|You should probably tell the tool the league you play in;Adjustable range matching including open-sided matching;Want a pleasant surprise? Set up your own account name;The tool will spot your own listings in the results and highlight them" ) @@ -107,13 +107,15 @@ inject() { { print }' } +# The class is the page's own name, which is what carries the glyph: `.nav-` in the +# stylesheet holds the icon, and a page listed here without a rule there simply goes without one. nav="" for p in "${PAGES[@]}"; do IFS='|' read -r _ out title <<<"$p" case $out in about.html|roadmap.html|privacy.html|attribution.html|contact.html) ;; *) continue ;; esac - nav+="$title" + nav+="$title" done -nav+="GitHub" +nav+="GitHub" docs_list="" for p in "${PAGES[@]}"; do @@ -210,7 +212,7 @@ done if [ -n "$gallery" ]; then { printf '
\n' - printf '

What it looks like

\n' + printf '

Gallery

\n' printf '
%s
\n' "$gallery" printf '\n' "$dots" printf '
\n' diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh index c156432..a8e1db6 100755 --- a/scripts/capture-screenshots.sh +++ b/scripts/capture-screenshots.sh @@ -21,22 +21,29 @@ cd "$(dirname "$0")/.." DISP=:99 GEOM=2560x1440x24 -HEIGHT=980 # every shot is cropped to this so the gallery reads as one set +HEIGHT=980 # the tallest a shot may be, so no one slide towers over the gallery SETTLE=16 # data bundle, poe.ninja overview, exchange digest, trade search, CDN icons +PARK=20,1400 # somewhere no window is, so nothing is left hovered when the shutter goes CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/PathOfPriceCheck/config.json" +# The update check is pointed at a closed port for the run: this build is whatever is in the tree +# and is therefore usually behind the published release, and a screenshot selling the tool should +# not open on its own "a new version is out" banner. A failed check is silent by design. +NOUPDATE=http://127.0.0.1:9/latest.json + # name | item file | hover x,y on the virtual screen (optional) # # The hover coordinate is what makes the seller's item appear beside a listing row, which has no # other way of being photographed. It is in *screen* space, so it moves whenever the panel width # or the screen geometry does — re-derive it from a first capture rather than trusting it. +# A shot that needs more than one pointer move says so in `stage` below instead. SHOTS=( "panel-rare|tests/data/examples/item_6.txt|200,1300" - "unique-reference|tests/data/examples/item_5.txt|" "listing-hover|tests/data/examples/item_5.txt|1323,486" "map|tests/data/items/map-rare-t16-corrupted.txt|" "currency-exchange|tests/data/items/currency-chaos-stack.txt|" "unidentified-picker|tests/data/items/unique-unidentified-jewel.txt|" + "report|tests/data/examples/item_5.txt|" "settings||200,1300" # no item: PPC_DEV_OVERLAY alone opens Settings ) @@ -54,29 +61,97 @@ restore() { } trap restore EXIT +# `account_name` is blanked for the run because the Settings shot draws the field: what belongs on +# the published picture is the placeholder a new user sees, not the maintainer's handle. Sellers' +# handles are somebody else's name again and are masked by `PPC_DEV_ANON` below. cp "$CONFIG" "$work/config.json" -jq '.auto_search = true | .debug_log = false' "$work/config.json" >"$CONFIG" +jq '.auto_search = true | .debug_log = false | .account_name = ""' "$work/config.json" >"$CONFIG" # A plain XWarpPointer moves the pointer without generating the motion a widget reacts to, so -# hovering has to go through XTest — the same extension the app's own copy injection uses. -cat >"$work/warp.c" <<'EOF' +# hovering has to go through XTest — the same extension the app's own copy injection uses. The +# same helper clicks and types, which is how a shot of a dialog gets taken at all: there is no +# hotkey onto the bug reporter, and a report with an empty description photographs as a form +# nobody filled in. +# +# xdo move xdo click xdo type +cat >"$work/xdo.c" <<'EOF' #include +#include +#include #include #include +#include #include + +static void move(Display* d, int x, int y) { + XTestFakeMotionEvent(d, 0, x - 8, y - 8, 0); XFlush(d); usleep(120000); + XTestFakeMotionEvent(d, 0, x, y, 0); XFlush(d); usleep(120000); + XTestFakeMotionEvent(d, 0, x, y + 1, 0); XFlush(d); usleep(120000); +} + +// ASCII is its own keysym over the printable range, so the layout lookup is the whole of it: +// find the keycode carrying that keysym and note whether it sits in the shifted slot. +static void type_char(Display* d, char c) { + KeySym ks = (c == '\n') ? XK_Return : (KeySym)(unsigned char)c; + KeyCode kc = XKeysymToKeycode(d, ks); + if (!kc) return; + int shift = XkbKeycodeToKeysym(d, kc, 0, 0) != ks && XkbKeycodeToKeysym(d, kc, 0, 1) == ks; + KeyCode sh = XKeysymToKeycode(d, XK_Shift_L); + if (shift) XTestFakeKeyEvent(d, sh, True, 0); + XTestFakeKeyEvent(d, kc, True, 0); + XTestFakeKeyEvent(d, kc, False, 0); + if (shift) XTestFakeKeyEvent(d, sh, False, 0); + XFlush(d); + usleep(12000); +} + int main(int argc, char** argv) { if (argc < 4) return 2; Display* d = XOpenDisplay(argv[1]); if (!d) return 1; - int x = atoi(argv[2]), y = atoi(argv[3]); - XTestFakeMotionEvent(d, 0, x - 8, y - 8, 0); XFlush(d); usleep(120000); - XTestFakeMotionEvent(d, 0, x, y, 0); XFlush(d); usleep(120000); - XTestFakeMotionEvent(d, 0, x, y + 1, 0); XFlush(d); + const char* what = argv[2]; + if (!strcmp(what, "type")) { + for (const char* p = argv[3]; *p; ++p) type_char(d, *p); + } else { + if (argc < 5) return 2; + move(d, atoi(argv[3]), atoi(argv[4])); + if (!strcmp(what, "click")) { + XTestFakeButtonEvent(d, 1, True, 0); XFlush(d); usleep(80000); + XTestFakeButtonEvent(d, 1, False, 0); XFlush(d); + } + } XCloseDisplay(d); return 0; } EOF -cc -O1 -o "$work/warp" "$work/warp.c" -lX11 -lXtst || die "cannot build the pointer helper" +cc -O1 -o "$work/xdo" "$work/xdo.c" -lX11 -lXtst || die "cannot build the pointer helper" + +move() { "$work/xdo" $DISP move "${1%,*}" "${1#*,}"; } +click() { "$work/xdo" $DISP click "${1%,*}" "${1#*,}"; sleep "${2:-1}"; } +type_in() { "$work/xdo" $DISP type "$1"; sleep 0.5; } + +# Everything a shot needs on screen beyond opening on an item. Coordinates are the virtual +# screen's, and the dialog ones are stable because it is a fixed size centred on it — 940x660 at +# 810,390 — so they only move when that size does. +# +# The report shot is the one that is *staged* rather than photographed as found: a description +# typed in, and the consent box ticked so the picture shows what ticking it attaches. Everything +# the dialog draws is still the app's own — the masked seller names in the preview included. +stage() { + case $1 in + report) + click 1590,368 2 # the report glyph on the results toolbar, which opens the dialog + click 1210,490 # into the description box + type_in "The reference row says 142 chaos but every listing is 2 divine - is it reading the wrong variant?" + click 1315,1022 # tick "Attach the screenshot" + ;; + *) return 0 ;; + esac + # Parked afterwards: the pointer is sitting on whatever it last pressed, and a tooltip that + # happens to be up is a picture of the click rather than of the dialog. + move "$PARK" + sleep 1.5 +} mkdir -p site/img for s in "${SHOTS[@]}"; do @@ -88,19 +163,17 @@ for s in "${SHOTS[@]}"; do Xvfb $DISP -screen 0 $GEOM -nolisten tcp >"$work/xvfb.log" 2>&1 & sleep 1.5 - if [ -n "$item" ]; then - DISPLAY=$DISP PPC_DEV_OVERLAY=1 PPC_DEV_ITEM="$item" \ - ./build/PathOfPriceCheck >"$work/app.log" 2>&1 & - else - DISPLAY=$DISP PPC_DEV_OVERLAY=1 ./build/PathOfPriceCheck >"$work/app.log" 2>&1 & - fi + vars=(DISPLAY=$DISP PPC_DEV_OVERLAY=1 PPC_DEV_UPDATE_URL=$NOUPDATE PPC_DEV_ANON=1) + [ -n "$item" ] && vars+=(PPC_DEV_ITEM="$item") # without one, PPC_DEV_OVERLAY opens Settings + env "${vars[@]}" ./build/PathOfPriceCheck >"$work/app.log" 2>&1 & app=$! sleep $SETTLE if [ -n "$hover" ]; then - "$work/warp" $DISP "${hover%,*}" "${hover#*,}" + move "$hover" sleep 2.5 fi + stage "$name" DISPLAY=$DISP import -window root "$work/raw.png" 2>/dev/null kill $app 2>/dev/null; sleep 1; kill -9 $app 2>/dev/null @@ -110,5 +183,6 @@ for s in "${SHOTS[@]}"; do identify -format '%wx%h\n' "site/img/$name.png" done -echo "capture: site/img/ updated — check each one before committing (a listing carries the" -echo " seller's handle, and your own is tinted green if account_name is set)" +echo "capture: site/img/ updated — check each one before committing. Handles are masked by" +echo " PPC_DEV_ANON and the account field is blanked, so what to look for is a name" +echo " that got through anyway, and anything else on screen that names this machine." diff --git a/site/README.md b/site/README.md index 0aa369c..564c5fc 100644 --- a/site/README.md +++ b/site/README.md @@ -56,20 +56,31 @@ The overlay is drawn on a **virtual X display**, which is what makes this reprod nothing appears on screen, the system-wide hotkey grabs land on `:99` where they bother nobody, and the screen is black everywhere the overlay did not paint, so trimming leaves the panel and the item beside it and nothing else. `PPC_DEV_ITEM` opens the panel on a captured clipboard with -no game running; the one shot that cannot be staged that way — the seller's item drawn beside a -hovered listing — is taken by moving the pointer over a row with `XTestFakeMotionEvent`, since a -plain `XWarpPointer` moves the pointer without generating the motion a widget reacts to. - -Every shot is cropped to a **common 980px height**, which is what the Settings dialog measures -and comfortably clears the tallest panel. That also crops away the footer, which on a machine -with the debug log on draws the check id and is not worth publishing. - -The run does two things worth knowing. It **spends real trade API requests**, one search per -item, because a screenshot of an empty results table sells nothing. And it overrides -`auto_search` and `debug_log` for the duration and puts the config back afterwards — without -that the Settings shot shows this machine's state and its home directory rather than what a new -user sees. - -Check each one before committing: a listing row carries the **seller's** handle (public on the -trade site anyway), and **your own** listings are tinted green with `(you)` whenever -`account_name` is filled in. +no game running. + +What that cannot open, the pointer does, through `XTestFakeMotionEvent` — a plain `XWarpPointer` +moves the pointer without generating the motion a widget reacts to. The same helper clicks and +types, and `stage` in the script is where a shot says what it needs beyond an item: the seller's +item drawn beside a hovered listing is a move, and the bug reporter is a click onto its glyph, a +description typed into the box and the consent ticked, because a report with an empty description +photographs as a form nobody filled in. Its coordinates are the **virtual screen's** and they move +whenever the panel width or the screen geometry does — re-derive them from a capture rather than +trusting them. + +No shot is taller than **980px**, which comfortably clears the tallest panel. That also crops away +the panel footer, which on a machine with the debug log on draws the check id and is not worth +publishing. Dialogs come out at their own size — the bug reporter is a fixed 940×660. + +**Nobody's name is published.** `PPC_DEV_ANON` holds on the same masking a bug-report capture +does, so every seller is `seller 1`, `seller 2` — and `account_name` is blanked in the config for +the run, so the Settings shot draws the placeholder a new user sees rather than the maintainer's +handle. The config is put back afterwards, along with the `auto_search` and `debug_log` overrides +that make the run look like a fresh install rather than this machine. The update check is pointed +at a closed port for the same reason: the build in the tree is usually behind the published +release, and a screenshot selling the tool should not open on a "new version" banner. + +Still check each one before committing. The masking covers the results table, and what it cannot +cover is anything else that happens to name this machine — a path, a league, a window title. + +The run **spends real trade API requests**, one search per item, because a screenshot of an empty +results table sells nothing. diff --git a/site/img/currency-exchange.png b/site/img/currency-exchange.png index a3bb651..1b74951 100644 Binary files a/site/img/currency-exchange.png and b/site/img/currency-exchange.png differ diff --git a/site/img/listing-hover.png b/site/img/listing-hover.png index ccf4548..94a7bab 100644 Binary files a/site/img/listing-hover.png and b/site/img/listing-hover.png differ diff --git a/site/img/map.png b/site/img/map.png index c19f63e..4dbd8c7 100644 Binary files a/site/img/map.png and b/site/img/map.png differ diff --git a/site/img/panel-rare.png b/site/img/panel-rare.png index d426821..5c7ef49 100644 Binary files a/site/img/panel-rare.png and b/site/img/panel-rare.png differ diff --git a/site/img/report.png b/site/img/report.png new file mode 100644 index 0000000..fc33231 Binary files /dev/null and b/site/img/report.png differ diff --git a/site/img/settings.png b/site/img/settings.png index 0db083c..2dfa1eb 100644 Binary files a/site/img/settings.png and b/site/img/settings.png differ diff --git a/site/img/unidentified-picker.png b/site/img/unidentified-picker.png index 2020625..71119aa 100644 Binary files a/site/img/unidentified-picker.png and b/site/img/unidentified-picker.png differ diff --git a/site/img/unique-reference.png b/site/img/unique-reference.png deleted file mode 100644 index e4f8868..0000000 Binary files a/site/img/unique-reference.png and /dev/null differ diff --git a/site/index.html b/site/index.html index 5107711..42f339c 100644 --- a/site/index.html +++ b/site/index.html @@ -64,7 +64,7 @@

Path of Price Check

{{GALLERY}}
-

What it does

+

Main features

  • One hotkey. Ctrl+D over an item. The game's own copy shortcut is synthesized for you, so there is no separate Ctrl+C to remember.
  • Reads the item properly. Implicits, explicits, fractured, crafted, enchants, corruption, influences and quality — plus the numbers the game leaves implicit, like quality-20 DPS and where a base's defences fall in their own roll range.
  • @@ -129,9 +129,9 @@

    Requirements

    -

    What it sends

    -

    No account, no telemetry, no analytics, no crash reporting, no usage counter — and no server on the other end to collect anything with. The trade queries go to the same hosts your browser would talk to, and nothing else leaves the machine.

    -

    Every outbound request, listed →

    +

    Privacy first

    +

    Privacy is not a setting you have to find here — it's the default. No account, no telemetry, no analytics, no crash reporting, no usage counter, and no server of ours on the other end to collect any of it with. What goes out is what a price check needs, to the same hosts your browser would talk to, and nothing else leaves the machine. The one thing you can send is a bug report, and only by pressing the button: the dialog shows you the whole of it — the screenshot too, if you tick that box — before any of it moves.

    +

    Read the entire privacy policy →

diff --git a/site/style.css b/site/style.css index 545b1c8..6c88a78 100644 --- a/site/style.css +++ b/site/style.css @@ -51,6 +51,30 @@ h1, h2, h3, h4, h5, h6 { color: var(--head); line-height: 1.25; font-weight: 600 } .brand img { display: block; } .bar nav { display: flex; flex-wrap: wrap; gap: 18px; margin-left: auto; font-size: 15px; } +.bar nav a { display: inline-flex; align-items: center; gap: 7px; } + +/* The nav glyphs are Font Awesome Free 6.7.2 — solid, except GitHub's, which is the brands face + — inlined as data URIs because this site fetches nothing from anywhere else, and painted as a + mask rather than drawn so each one takes its link's own colour, hover included. Icons are + CC BY 4.0, © Fonticons, Inc.; see ATTRIBUTION.md. + The class comes from the page's filename, so a page added to the nav lands here by name — one + not listed simply goes without a glyph rather than drawing a square where the mask would be. */ +@supports ((mask-image: none) or (-webkit-mask-image: none)) { + .bar nav :is(.nav-about, .nav-roadmap, .nav-privacy, .nav-attribution, .nav-contact, .nav-github)::before { + content: ""; flex: none; + width: 1em; height: 1em; + background: currentColor; + -webkit-mask: var(--i) center / contain no-repeat; + mask: var(--i) center / contain no-repeat; + } +} + +.bar nav a.nav-about { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z'/%3E%3C/svg%3E"); } +.bar nav a.nav-roadmap { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M512 96c0 50.2-59.1 125.1-84.6 155c-3.8 4.4-9.4 6.1-14.5 5L320 256c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c53 0 96 43 96 96s-43 96-96 96l-276.4 0c8.7-9.9 19.3-22.6 30-36.8c6.3-8.4 12.8-17.6 19-27.2L416 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0c-53 0-96-43-96-96s43-96 96-96l39.8 0c-21-31.5-39.8-67.7-39.8-96c0-53 43-96 96-96s96 43 96 96zM117.1 489.1c-3.8 4.3-7.2 8.1-10.1 11.3l-1.8 2-.2-.2c-6 4.6-14.6 4-20-1.8C59.8 473 0 402.5 0 352c0-53 43-96 96-96s96 43 96 96c0 30-21.1 67-43.5 97.9c-10.7 14.7-21.7 28-30.8 38.5l-.6 .7zM128 352a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM416 128a32 32 0 1 0 0-64 32 32 0 1 0 0 64z'/%3E%3C/svg%3E"); } +.bar nav a.nav-privacy { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.7 2.9C246.8 1 251.4 0 256 0zm0 66.8l0 378.1C394 378 431.1 230.1 432 141.4L256 66.8s0 0 0 0z'/%3E%3C/svg%3E"); } +.bar nav a.nav-attribution { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 512'%3E%3Cpath d='M384 32l128 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L398.4 96c-5.2 25.8-22.9 47.1-46.4 57.3L352 448l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-192 0-192 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0 0-294.7c-23.5-10.3-41.2-31.6-46.4-57.3L128 96c-17.7 0-32-14.3-32-32s14.3-32 32-32l128 0c14.6-19.4 37.8-32 64-32s49.4 12.6 64 32zm55.6 288l144.9 0L512 195.8 439.6 320zM512 416c-62.9 0-115.2-34-126-78.9c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C627.2 382 574.9 416 512 416zM126.8 195.8L54.4 320l144.9 0L126.8 195.8zM.9 337.1c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C242 382 189.7 416 126.8 416S11.7 382 .9 337.1z'/%3E%3C/svg%3E"); } +.bar nav a.nav-contact { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48L48 64zM0 176L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-208L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z'/%3E%3C/svg%3E"); } +.bar nav a.nav-github { --i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 496 512'%3E%3Cpath d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z'/%3E%3C/svg%3E"); } footer { border-top: 1px solid var(--line); @@ -72,8 +96,15 @@ footer p { max-width: 70ch; margin: 10px auto; } .tagline { font-size: 20px; color: var(--head); margin: 0 auto 14px; max-width: 34ch; } .sub { color: var(--muted); margin: 0 auto; max-width: 60ch; } +/* Two by two, filled down each column rather than across each row: that puts Windows in the + first column and Linux in the second with one flavour under the other, while leaving the + markup in the order it should collapse to when there is only one column left. Both rows are + `1fr` so the four cards square up despite their blurbs being different lengths. */ .downloads { - display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); + grid-auto-flow: column; gap: 12px; margin: 32px 0 12px; text-align: left; } /* The card is a div, not a link: it holds a second link (to Requirements) and an may not @@ -301,4 +332,6 @@ pre code { background: none; border: 0; padding: 0; } .tagline { font-size: 18px; } .bar { padding: 10px 16px; } .bar nav { gap: 14px; font-size: 14px; } + /* One column, and back to flowing by row — which is the order the cards are written in. */ + .downloads { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-flow: row; } } diff --git a/src/app.cpp b/src/app.cpp index bdc2b8a..bd8b3ba 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -333,6 +333,7 @@ int App::run(bool relaunched_after_update) { if (!std::getenv("PPC_MANAGED")) overlay_set_unmanaged(overlay_.window(), true); dev_mode_ = std::getenv("PPC_DEV_OVERLAY") != nullptr; + anonymise_ = std::getenv("PPC_DEV_ANON") != nullptr; log_session_start(); // after dev_mode_, which changes what every gate below does if (dev_mode_) { // local UI dev, no game needed overlay_.set_visible(true); diff --git a/src/app.hpp b/src/app.hpp index c6f7e83..3aa86df 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -262,6 +262,14 @@ class App { /// and the first is somebody else's name. bool report_capture_pending() const { return report_opening_ != Opening::No; } + /// Whether a seller's handle is drawn or replaced by its position in the results. + /// + /// True for the one frame a bug report is photographed on, and for the whole run under + /// `PPC_DEV_ANON` — which is what `scripts/capture-screenshots.sh` takes the website's + /// pictures with, since those are published and the handles on them are not ours to publish. + /// Only the handles: prices, ages and counts are what a screenshot is read for. + bool mask_sellers() const { return report_capture_pending() || anonymise_; } + /// 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. @@ -375,8 +383,9 @@ class App { /// 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 + bool dev_mode_ = false; ///< PPC_DEV_OVERLAY: keep overlay up regardless of focus + bool anonymise_ = false; ///< PPC_DEV_ANON: never draw a seller's handle (see mask_sellers) + bool had_focus_ = false; ///< overlay has gained focus since it was shown // One contiguous block from SDL_RegisterEvents. Kept as distinct types rather than // widening Action: handle_action() gates on the game being foreground, which would diff --git a/src/paths.cpp b/src/paths.cpp index 81290f3..f3df2ed 100644 --- a/src/paths.cpp +++ b/src/paths.cpp @@ -1,6 +1,9 @@ #include "paths.hpp" +#include +#include #include +#include namespace fs = std::filesystem; @@ -20,6 +23,23 @@ fs::path env_dir(const char* primary, const char* home_var, const char* home_chi return base / "PathOfPriceCheck"; } +/// The remainder of `s` after the directory `base` names, separator and all, or empty if `s` is +/// not inside it. `base` may be null or unset, which is a machine with no such variable. +/// +/// A prefix test and nothing cleverer — no symlink resolution, no case folding on Windows. Both +/// strings come from the same environment this process was started with, so they either agree or +/// this is not the directory being named, and getting it wrong shows a full path rather than a +/// short one. +std::string under(const std::string& s, const char* base) { + if (!base || !*base) return {}; + std::string b(base); + while (b.size() > 1 && (b.back() == '/' || b.back() == '\\')) b.pop_back(); + if (s.size() <= b.size() || s.compare(0, b.size(), b) != 0) return {}; + const char sep = s[b.size()]; + if (sep != '/' && sep != '\\') return {}; + return s.substr(b.size()); +} + } // namespace fs::path config_dir() { @@ -46,4 +66,42 @@ bool ensure_dir(const fs::path& p) { return fs::is_directory(p, ec); } +std::string display_path(const fs::path& p) { + const std::string s = p.string(); +#ifdef _WIN32 + // USERPROFILE last: it is a prefix of both of the others, so testing it first would fold + // `%LOCALAPPDATA%\…` into `%USERPROFILE%\AppData\Local\…`, which is longer and says less. + for (const char* var : {"LOCALAPPDATA", "APPDATA", "USERPROFILE"}) { + if (std::string sub = under(s, std::getenv(var)); !sub.empty()) + return "%" + std::string(var) + "%" + sub; + } +#else + if (std::string sub = under(s, std::getenv("HOME")); !sub.empty()) return "~" + sub; +#endif + // An XDG variable pointed somewhere else entirely, or there is no home at all. What is left + // is the path itself: a directory the user named is not one this can improve on. + return s; +} + +std::string file_url(const fs::path& p) { + // generic_string so Windows' separators are already the URL's, and the third slash so + // `C:/…` becomes `file:///C:/…` — a drive letter is not a hostname. + const std::string s = p.generic_string(); + std::string url = "file://"; + if (!s.empty() && s.front() != '/') url += '/'; + for (const unsigned char c : s) { + // Unreserved, plus the two that have to survive as themselves: the separator and the + // colon after a drive letter. + if (std::isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~' || c == '/' || + c == ':') { + url += static_cast(c); + } else { + char hex[4]; + std::snprintf(hex, sizeof hex, "%%%02X", c); + url += hex; + } + } + return url; +} + } // namespace ppc diff --git a/src/paths.hpp b/src/paths.hpp index 6dd2471..418b2d7 100644 --- a/src/paths.hpp +++ b/src/paths.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include namespace ppc { @@ -13,4 +14,18 @@ std::filesystem::path cache_dir(); /// mkdir -p, ignoring the error. False if the directory still isn't there afterwards. bool ensure_dir(const std::filesystem::path& p); +/// `p` with the part that names this machine's user folded back into what it came from: +/// `~/.config/PathOfPriceCheck/config.json`, `%APPDATA%\PathOfPriceCheck\config.json`. +/// +/// **For showing, never for opening** — the result is not a path any API will accept. Every one +/// of these strings ends up on somebody's screenshot, and a home directory is a name; it is also +/// simply shorter, which is what the footer it is drawn in has room for. +std::string display_path(const std::filesystem::path& p); + +/// `p` as a `file://` URL, for handing to `SDL_OpenURL`. +/// +/// Percent-encoded, because a path may hold spaces and anything else a URL reserves, and given +/// the third slash Windows needs in front of its drive letter. +std::string file_url(const std::filesystem::path& p); + } // namespace ppc diff --git a/src/screens/pricecheck_screen.cpp b/src/screens/pricecheck_screen.cpp index 1512065..bacf944 100644 --- a/src/screens/pricecheck_screen.cpp +++ b/src/screens/pricecheck_screen.cpp @@ -1598,12 +1598,13 @@ void draw_results(App& app, float gutter_top) { // indistinguishable from the rest of the page, and the page is what a price is read off: // an own listing sitting at the top reads as the market's floor, which it is not. const std::string& me = app.config().account_name; - // **Nobody's name goes in a bug report.** On the frame the panel is being read back for one, - // every handle is replaced by its position in the results — which is all a maintainer reading - // the picture ever needed: that these are twenty different sellers, and which row is which. - // The user's own marker stays, because it names nobody and is the one thing on the row that - // explains its colour. - const bool masked = app.report_capture_pending(); + // **Nobody's name goes in a picture that leaves this machine.** On the frame the panel is + // being read back for a bug report — and for the whole run when the website's screenshots are + // being taken — every handle is replaced by its position in the results, which is all a + // reader of the picture ever needed: that these are twenty different sellers, and which row + // is which. The user's own marker stays, because it names nobody and is the one thing on the + // row that explains its colour. + const bool masked = app.mask_sellers(); for (size_t i = 0; i < t.results().listings.size(); ++i) { const trade::Listing& l = t.results().listings[i]; diff --git a/src/screens/settings_screen.cpp b/src/screens/settings_screen.cpp index 943f90b..cd21bfa 100644 --- a/src/screens/settings_screen.cpp +++ b/src/screens/settings_screen.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -12,6 +13,7 @@ #include #include "app.hpp" +#include "paths.hpp" #include "platform/clipboard.hpp" #include "quickpaste.hpp" #include "ui/glyphs.hpp" @@ -492,6 +494,37 @@ void price_check_tab(App& app, Config& c) { "%.3f"); } +/// A file this dialog names, drawn short and opened by a click. +/// +/// **Short** — `~/…`, `%APPDATA%\…` — for two reasons that happen to agree: these lines have one +/// line of room, and they are on every screenshot anyone posts of this dialog, where an absolute +/// path names the person who took it. The whole of it is still a hover away, which is where what +/// the shortening took off is given back. +/// +/// **The folder, not the file**: a `.json` or `.log` handler is something a desktop may or may +/// not have, and the reason to go there is usually the other files beside it anyway. +void path_line(const std::string& full) { + ImGui::PushTextWrapPos(0.0f); // an XDG variable pointing somewhere long is not shortened + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::TextUnformatted(display_path(full).c_str()); + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + if (!ImGui::IsItemHovered()) return; + + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + // Underlined on hover only: a line that advertises itself as a link at all times is a second + // lit control beside Save, and there is one thing on this footer worth pressing. Skipped + // where the text wrapped, since the item's box is then every line at once and an underline + // under all of them is a box, not a link. + const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); + if (b.y - a.y < ImGui::GetTextLineHeight() * 1.5f) + ImGui::GetWindowDrawList()->AddLine(ImVec2(a.x, b.y), b, + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + ImGui::SetTooltip("%s\n%s", full.c_str(), ui::text(ui::Msg::OpenTheFolder)); + if (ImGui::IsItemClicked()) + SDL_OpenURL(file_url(std::filesystem::path(full).parent_path()).c_str()); +} + /// 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) { @@ -764,11 +797,11 @@ void application_tab(App& app, Config& c) { row_gutter(); if (c.debug_log) { // The path, not just "on": the user is going to attach this file to a report, and - // every price check shows the id that indexes into it. - ImGui::PushTextWrapPos(0.0f); + // every price check shows the id that indexes into it. A click opens the folder it is + // in, which is the step between reading the path and attaching the file. const std::string p = debug::log_path(); - ImGui::TextDisabled("%s", p.empty() ? ui::text(ui::Msg::LogOpenFailed) : p.c_str()); - ImGui::PopTextWrapPos(); + if (p.empty()) ImGui::TextDisabled("%s", ui::text(ui::Msg::LogOpenFailed)); + else path_line(p); } else { ImGui::TextDisabled("%s", ui::text(ui::Msg::DebugLogHelp)); } @@ -838,7 +871,7 @@ void draw_footer(App& app) { ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::AlignTextToFramePadding(); - ImGui::TextDisabled("%s", Config::path().c_str()); + path_line(Config::path()); } } // namespace diff --git a/src/ui/strings.cpp b/src/ui/strings.cpp index ebddbba..91fec31 100644 --- a/src/ui/strings.cpp +++ b/src/ui/strings.cpp @@ -136,6 +136,7 @@ constexpr const char* kEnglish[]{ "Records the copy path, item text included. Off by default.", "Save", + "Click to open the folder", }; /// Every compiled-in table. English is index 0 and is the fallback, so it is the one entry diff --git a/src/ui/strings.hpp b/src/ui/strings.hpp index 16052c3..682e923 100644 --- a/src/ui/strings.hpp +++ b/src/ui/strings.hpp @@ -139,6 +139,7 @@ enum class Msg : uint16_t { DebugLogHelp, Save, + OpenTheFolder, Count }; diff --git a/tests/paths_test.cpp b/tests/paths_test.cpp index 5f61bec..5e7d3bc 100644 --- a/tests/paths_test.cpp +++ b/tests/paths_test.cpp @@ -53,8 +53,40 @@ TEST_CASE("config and cache never collide") { set_env("HOME", "/home/tester"); CHECK(ppc::config_dir() != ppc::cache_dir()); } + +TEST_CASE("display_path folds the home directory away") { + set_env("HOME", "/home/tester"); + CHECK(ppc::display_path("/home/tester/.config/PathOfPriceCheck/config.json") == + "~/.config/PathOfPriceCheck/config.json"); + // A trailing slash on HOME must not leave "~//". + set_env("HOME", "/home/tester/"); + CHECK(ppc::display_path("/home/tester/.config") == "~/.config"); +} + +TEST_CASE("display_path leaves anything not under home alone") { + set_env("HOME", "/home/tester"); + // Somewhere else entirely — an XDG variable pointed outside the home directory. + CHECK(ppc::display_path("/srv/ppc/config.json") == "/srv/ppc/config.json"); + // The prefix matches but the boundary does not: a sibling directory is not inside it. + CHECK(ppc::display_path("/home/tester2/x") == "/home/tester2/x"); + // The home directory itself: nothing follows it, so there is nothing to shorten. + CHECK(ppc::display_path("/home/tester") == "/home/tester"); + set_env("HOME", nullptr); + CHECK(ppc::display_path("/home/tester/x") == "/home/tester/x"); +} #endif +TEST_CASE("file_url escapes what a URL reserves") { + // The separator, the drive colon and the unreserved set survive; a space does not. + CHECK(ppc::file_url("/home/t/My Games/config.json") == + "file:///home/t/My%20Games/config.json"); + CHECK(ppc::file_url("/home/t/a-b_c.d~e") == "file:///home/t/a-b_c.d~e"); + CHECK(ppc::file_url("/home/t/100%").ends_with("/100%25")); + // Whatever the platform's separator, the URL's is a forward slash, and the drive letter + // gets the third one rather than being read as a hostname. + CHECK(ppc::file_url(fs::path("C:") / "Users" / "t").starts_with("file:///")); +} + TEST_CASE("ensure_dir creates nested directories and is idempotent") { const fs::path root = fs::temp_directory_path() / "ppc-paths-test"; std::error_code ec;