diff --git a/README.md b/README.md index 76d2aef..2aa50d9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ works. | `0005-Make-the-sidebars-foldable` | A switch for the Modrinth Servers button, a news section that folds away, and a title bar button that folds the right sidebar away. | | `0006-Add-Ely.by-accounts` | Sign in with Ely.by, launched through authlib-injector. | | `0007-Sign-in-to-Microsoft-in-the-...` | Microsoft sign-in happens in your own browser instead of a webview, so your password manager works. | +| `0008-Round-the-window-corners-on-Linux` | The undecorated window gets rounded corners on Linux. | +| `0009-Scroll-with-the-middle-mouse-button` | Middle-click autoscroll on Linux and macOS, as browsers do it on Windows. | ### Offline accounts @@ -77,6 +79,16 @@ None of the three reach Modrinth. Preference syncing maps a fixed list of named directions and these are not in it, so they are neither sent to your Modrinth account nor overwritten by another device. +### Window + +On Linux the window has rounded corners while it floats. Maximized, fullscreen or with native +decorations turned on, it is square as before. The window is created transparent for this, which +needs a compositor; without one the corners show black. + +A click with the middle mouse button on anything that scrolls starts autoscroll: press and release +to scroll until the next click, or hold and drag to scroll until you let go. Links and text fields +keep their middle-click. Windows is left alone, since WebView2 autoscrolls by itself. + ### Modrinth+ Nothing in the app is gated behind Modrinth+. In upstream it decides whether the ad slot, the diff --git a/patches/0008-Round-the-window-corners-on-Linux.patch b/patches/0008-Round-the-window-corners-on-Linux.patch new file mode 100644 index 0000000..1dd8e74 --- /dev/null +++ b/patches/0008-Round-the-window-corners-on-Linux.patch @@ -0,0 +1,106 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Mon, 14 Sep 2026 19:15:01 +0200 +Subject: [PATCH] Round the window corners on Linux + +--- + apps/app-frontend/src/App.vue | 10 +++++++++ + .../src/assets/stylesheets/global.scss | 17 ++++++++++++++ + apps/app-frontend/src/main.js | 5 +++++ + apps/app/tauri.linux.conf.json | 22 ++++++++++++++++++- + 4 files changed, 53 insertions(+), 1 deletion(-) + +diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue +index a9e3945..5a32c65 100644 +--- a/apps/app-frontend/src/App.vue ++++ b/apps/app-frontend/src/App.vue +@@ -529,6 +529,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => { + document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen) + }) + ++watch( ++ [os, isMaximized, isFullscreen, () => appSettings.nativeDecorations], ++ ([osName, maximized, fullscreen, nativeDecorations]) => { ++ document.documentElement.classList.toggle( ++ 'rounded-window', ++ osName === 'Linux' && !nativeDecorations && !maximized && !fullscreen, ++ ) ++ }, ++) ++ + const authUnreachableDebug = useDebugLogger('AuthReachableChecker') + const authServerQuery = useQuery({ + queryKey: ['authServerReachability'], +diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss +index a9fa554..ba4da08 100644 +--- a/apps/app-frontend/src/assets/stylesheets/global.scss ++++ b/apps/app-frontend/src/assets/stylesheets/global.scss +@@ -66,6 +66,23 @@ body { + overflow: hidden; + } + ++// An undecorated window on Linux is transparent (tauri.linux.conf.json), so the ++// page paints its own background and cuts the corners off. clip-path rather ++// than overflow, because overflow does not clip fixed-position modals. ++html.rounded-window, ++html.rounded-window body { ++ background: transparent; ++} ++ ++html.rounded-window body { ++ clip-path: inset(0 round 10px); ++} ++ ++html.rounded-window #app { ++ height: 100%; ++ background-color: var(--color-raised-bg); ++} ++ + * { + box-sizing: border-box; + } +diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js +index 9607cfb..e7991ef 100644 +--- a/apps/app-frontend/src/main.js ++++ b/apps/app-frontend/src/main.js +@@ -13,6 +13,11 @@ import i18nDebugPlugin from '@/plugins/i18n-debug' + import router from '@/routes' + + debugStartup('Frontend entry module evaluated') ++// Round the corners from the first frame on; App.vue squares them again once it ++// knows the window is maximized or natively decorated. ++if (navigator.userAgent.includes('Linux')) { ++ document.documentElement.classList.add('rounded-window') ++} + const app = createApp(App) + setupErrorReporting(app, router) + +diff --git a/apps/app/tauri.linux.conf.json b/apps/app/tauri.linux.conf.json +index 81a368c..68fadf3 100644 +--- a/apps/app/tauri.linux.conf.json ++++ b/apps/app/tauri.linux.conf.json +@@ -1,3 +1,23 @@ + { +- "mainBinaryName": "ModrinthEnhanced" ++ "mainBinaryName": "ModrinthEnhanced", ++ "app": { ++ "windows": [ ++ { ++ "titleBarStyle": "Overlay", ++ "hiddenTitle": true, ++ "fullscreen": false, ++ "height": 800, ++ "resizable": true, ++ "title": "Modrinth Enhanced", ++ "label": "main", ++ "width": 1280, ++ "minHeight": 700, ++ "minWidth": 1100, ++ "visible": false, ++ "zoomHotkeysEnabled": false, ++ "decorations": false, ++ "transparent": true ++ } ++ ] ++ } + } diff --git a/patches/0009-Scroll-with-the-middle-mouse-button.patch b/patches/0009-Scroll-with-the-middle-mouse-button.patch new file mode 100644 index 0000000..c3f044d --- /dev/null +++ b/patches/0009-Scroll-with-the-middle-mouse-button.patch @@ -0,0 +1,305 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Mon, 14 Sep 2026 19:17:53 +0200 +Subject: [PATCH] Scroll with the middle mouse button + +--- + apps/app-frontend/src/App.vue | 8 + + .../src/assets/stylesheets/global.scss | 44 ++++ + apps/app-frontend/src/helpers/autoscroll.ts | 201 ++++++++++++++++++ + 3 files changed, 253 insertions(+) + create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts + +diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue +index 5a32c65..469ae08 100644 +--- a/apps/app-frontend/src/App.vue ++++ b/apps/app-frontend/src/App.vue +@@ -123,6 +123,7 @@ import { + } from '@/helpers/ads.js' + import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' + import { check_reachable } from '@/helpers/auth.js' ++import { installAutoscroll } from '@/helpers/autoscroll.ts' + import { get_user, get_user_many, get_version } from '@/helpers/cache.js' + import { gameSettingsQueryOptions } from '@/helpers/game-options' + import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' +@@ -589,11 +590,18 @@ onMounted(async () => { + document.querySelector('body').addEventListener('auxclick', handleAuxClick) + document.querySelector('body').addEventListener('contextmenu', handleContextMenu) + document.addEventListener('fullscreenchange', handleFullscreenChange) ++ // Chromium, and so WebView2 on Windows, already autoscrolls by itself ++ if (!navigator.userAgent.includes('Windows')) { ++ uninstallAutoscroll = installAutoscroll() ++ } + + checkUpdates() + }) + ++let uninstallAutoscroll ++ + onUnmounted(async () => { ++ uninstallAutoscroll?.() + document.querySelector('body').removeEventListener('click', handleClick) + document.querySelector('body').removeEventListener('auxclick', handleAuxClick) + document.querySelector('body').removeEventListener('contextmenu', handleContextMenu) +diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss +index ba4da08..442f181 100644 +--- a/apps/app-frontend/src/assets/stylesheets/global.scss ++++ b/apps/app-frontend/src/assets/stylesheets/global.scss +@@ -83,6 +83,50 @@ html.rounded-window #app { + background-color: var(--color-raised-bg); + } + ++// Middle-click autoscroll (helpers/autoscroll.ts) ++html.autoscrolling, ++html.autoscrolling * { ++ cursor: all-scroll !important; ++} ++ ++.autoscroll-indicator { ++ position: fixed; ++ z-index: 2147483647; ++ pointer-events: none; ++ width: 32px; ++ height: 32px; ++ border-radius: 50%; ++ color: var(--color-contrast); ++ background-color: var(--color-raised-bg); ++ // A ring instead of a border, which would shrink the space the icon has. ++ box-shadow: ++ 0 0 0 1px var(--color-button-bg), ++ 0 2px 8px rgba(0, 0, 0, 0.35); ++ ++ // Overrides the global `svg { width: 1em; height: 1em }`, which squeezed the ++ // icon into the top left corner. ++ svg { ++ display: block; ++ width: 100%; ++ height: 100%; ++ } ++ ++ path { ++ opacity: 0.45; ++ transition: ++ opacity 0.1s, ++ stroke 0.1s; ++ } ++ ++ &[data-dir*='n'] [data-dir='n'], ++ &[data-dir*='s'] [data-dir='s'], ++ &[data-dir*='e'] [data-dir='e'], ++ &[data-dir*='w'] [data-dir='w'] { ++ opacity: 1; ++ stroke: var(--color-brand); ++ } ++} ++ + * { + box-sizing: border-box; + } +diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts +new file mode 100644 +index 0000000..1faef8e +--- /dev/null ++++ b/apps/app-frontend/src/helpers/autoscroll.ts +@@ -0,0 +1,201 @@ ++// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none: ++// on Linux the middle button pastes instead. ++// ++// Press and release to keep scrolling until the next click, or press, drag and ++// release to scroll only while the button is held. ++ ++const DEAD_ZONE = 12 ++// A press shorter than this, without leaving the dead zone, is a click that ++// leaves autoscroll running. ++const CLICK_MS = 300 ++ ++const INTERACTIVE = ++ 'a, button, input, textarea, select, [contenteditable]:not([contenteditable="false"])' ++ ++type Axes = { x: boolean; y: boolean } ++ ++function scrollableAxes(el: Element): Axes { ++ const style = getComputedStyle(el) ++ const scrolls = (overflow: string) => overflow === 'auto' || overflow === 'scroll' ++ return { ++ x: scrolls(style.overflowX) && el.scrollWidth > el.clientWidth, ++ y: scrolls(style.overflowY) && el.scrollHeight > el.clientHeight, ++ } ++} ++ ++function findScrollable(start: Element | null): { el: Element; axes: Axes } | null { ++ for (let el = start; el && el !== document.documentElement; el = el.parentElement) { ++ const axes = scrollableAxes(el) ++ if (axes.x || axes.y) return { el, axes } ++ } ++ return null ++} ++ ++function createIndicator(x: number, y: number, axes: Axes): HTMLElement { ++ const chevrons = [ ++ axes.y ? '' : '', ++ axes.x ? '' : '', ++ ].join('') ++ const indicator = document.createElement('div') ++ indicator.className = 'autoscroll-indicator' ++ indicator.style.left = `${x - 16}px` ++ indicator.style.top = `${y - 16}px` ++ indicator.innerHTML = `${chevrons}` ++ document.body.appendChild(indicator) ++ return indicator ++} ++ ++// Pixels per second for a pointer this far from where autoscroll started. ++function speed(distance: number): number { ++ const past = Math.abs(distance) - DEAD_ZONE ++ if (past <= 0) return 0 ++ return Math.sign(distance) * Math.min(past ** 1.5 * 1.2, 8000) ++} ++ ++export function installAutoscroll(): () => void { ++ let active: { ++ el: Element ++ axes: Axes ++ startX: number ++ startY: number ++ pointerX: number ++ pointerY: number ++ pressedAt: number ++ held: boolean ++ carryX: number ++ carryY: number ++ lastFrame: number ++ frame: number ++ indicator: HTMLElement ++ } | null = null ++ // The mouseup, click and auxclick that belong to a press autoscroll consumed. ++ let swallowButtons = 0 ++ ++ function stop() { ++ if (!active) return ++ cancelAnimationFrame(active.frame) ++ active.indicator.remove() ++ document.documentElement.classList.remove('autoscrolling') ++ active = null ++ } ++ ++ function tick(now: number) { ++ if (!active) return ++ const dt = Math.min(now - active.lastFrame, 100) / 1000 ++ active.lastFrame = now ++ ++ active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0 ++ active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0 ++ const left = Math.trunc(active.carryX) ++ const top = Math.trunc(active.carryY) ++ if (left || top) { ++ active.el.scrollBy({ left, top, behavior: 'instant' }) ++ active.carryX -= left ++ active.carryY -= top ++ } ++ active.frame = requestAnimationFrame(tick) ++ } ++ ++ function swallow(e: Event) { ++ e.preventDefault() ++ e.stopImmediatePropagation() ++ } ++ ++ function onMouseDown(e: MouseEvent) { ++ if (active) { ++ // Any press ends a running autoscroll and does nothing else. ++ swallow(e) ++ stop() ++ swallowButtons = 1 << e.button ++ return ++ } ++ swallowButtons = 0 ++ if (e.button !== 1 || !(e.target instanceof Element)) return ++ if (e.target.closest(INTERACTIVE)) return ++ ++ const found = findScrollable(e.target) ++ if (!found) return ++ ++ swallow(e) ++ swallowButtons = 1 << e.button ++ active = { ++ ...found, ++ startX: e.clientX, ++ startY: e.clientY, ++ pointerX: e.clientX, ++ pointerY: e.clientY, ++ pressedAt: performance.now(), ++ held: false, ++ carryX: 0, ++ carryY: 0, ++ lastFrame: performance.now(), ++ frame: 0, ++ indicator: createIndicator(e.clientX, e.clientY, found.axes), ++ } ++ document.documentElement.classList.add('autoscrolling') ++ active.frame = requestAnimationFrame(tick) ++ } ++ ++ function onMouseMove(e: MouseEvent) { ++ if (!active) return ++ active.pointerX = e.clientX ++ active.pointerY = e.clientY ++ const dx = e.clientX - active.startX ++ const dy = e.clientY - active.startY ++ if (Math.abs(dx) > DEAD_ZONE || Math.abs(dy) > DEAD_ZONE) { ++ active.held = true ++ } ++ ++ // Light up the chevrons for the direction the page is moving in ++ const vertical = active.axes.y && Math.abs(dy) > DEAD_ZONE ? (dy < 0 ? 'n' : 's') : '' ++ const horizontal = active.axes.x && Math.abs(dx) > DEAD_ZONE ? (dx < 0 ? 'w' : 'e') : '' ++ active.indicator.dataset.dir = vertical + horizontal ++ } ++ ++ function onMouseUp(e: MouseEvent) { ++ if (!(swallowButtons & (1 << e.button))) return ++ swallow(e) ++ if ( ++ active && ++ e.button === 1 && ++ (active.held || performance.now() - active.pressedAt > CLICK_MS) ++ ) { ++ stop() ++ } ++ } ++ ++ function onClick(e: MouseEvent) { ++ if (!(swallowButtons & (1 << e.button))) return ++ swallow(e) ++ swallowButtons &= ~(1 << e.button) ++ } ++ ++ function onKeyDown(e: KeyboardEvent) { ++ if (!active) return ++ if (e.key === 'Escape') swallow(e) ++ stop() ++ } ++ ++ const listeners: [string, (e: never) => void][] = [ ++ ['mousedown', onMouseDown], ++ ['mousemove', onMouseMove], ++ ['mouseup', onMouseUp], ++ ['click', onClick], ++ ['auxclick', onClick], ++ ['keydown', onKeyDown], ++ ['wheel', stop], ++ // Capturing on window also sees every element losing focus; only the ++ // window itself losing it matters. ++ ['blur', (e: FocusEvent) => e.target === window && stop()], ++ ] ++ for (const [type, listener] of listeners) { ++ window.addEventListener(type, listener as EventListener, { capture: true }) ++ } ++ ++ return () => { ++ stop() ++ for (const [type, listener] of listeners) { ++ window.removeEventListener(type, listener as EventListener, { capture: true }) ++ } ++ } ++} diff --git a/scripts/check.sh b/scripts/check.sh index 13ccc48..694649c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -84,6 +84,27 @@ check "the news section can be collapsed" \ check "the right sidebar has a fold button" \ contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)' +# tauri.linux.conf.json replaces the whole window list, so an upstream change to +# the main window would otherwise silently not reach Linux. +same_linux_window() { + node -e ' + const fs = require("fs") + const [base, linux] = process.argv.slice(1).map((p) => JSON.parse(fs.readFileSync(p, "utf8")).app.windows[0]) + delete linux.transparent + const sorted = (o) => JSON.stringify(o, Object.keys(o).sort()) + process.exit(sorted(base) === sorted(linux) ? 0 : 1) + ' "$WORKTREE/apps/app/tauri.conf.json" "$WORKTREE/apps/app/tauri.linux.conf.json" +} + +log "Window" +check "the Linux window is transparent" \ + contains "$WORKTREE/apps/app/tauri.linux.conf.json" '"transparent": true' +check "the Linux window otherwise matches upstream" same_linux_window +check "its corners are rounded" \ + contains "$WORKTREE/apps/app-frontend/src/App.vue" "'rounded-window'" +check "the middle button autoscrolls" \ + contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()' + log "No advertising or upsells" check "no Modrinth+ upsell in the app" \ missing "$WORKTREE/apps/app-frontend/src/App.vue" "modrinth.plus"