The desktop app's title bar is a web view of its own, above the site's, so no page is ever under it.
The bar was drawn over the site and pushed the page down, which left whatever a page pins to the top of the window, g1t's rail and sidebar among them, under the bar, and gave the window a scrollbar. Now the main window holds two web views, one above the other: the bar, a page bundled with the app (launcher/titlebar.html), 36px across the top, and the site under it, which has the rest of the window and needs no room made for the bar. On Linux, where the web views share a GTK box, the bar is packed to keep its height. The site's bridge no longer draws anything: it reports the page's title, its search, whether there is a way back or forward, and its colours, resolved from g1t's rail tokens, light or dark, or from any other page's own, and the bar shows them. The bar's search comes back to the page as an event; back and forward run in the page; a click on the bar gives the keyboard back to the page. The settings window draws its own bar in its page. The site's CSS for making room under the bar is gone.
- Claude-Session:
- https://claude.ai/code/session_01MMRPpook51KPMQQRurb5kC
| 29 | 29 | user-select: none; | |
| 30 | 30 | cursor: default; | |
| 31 | 31 | } | |
| 32 | − | /* Under the app's own title bar (src/frame.rs): the page fills what is below it. */ | |
| 33 | − | html[data-titlebar] { | |
| 34 | − | padding-top: 0; | |
| 35 | − | } | |
| 36 | − | html[data-titlebar] body { | |
| 37 | − | padding-top: 36px; | |
| 38 | − | } | |
| 39 | 32 | main { | |
| 40 | 33 | width: min(26rem, calc(100vw - 2rem)); | |
| 41 | 34 | text-align: center; |
| 26 | 26 | user-select: none; | |
| 27 | 27 | cursor: default; | |
| 28 | 28 | } | |
| 29 | + | /* g1t's own title bar, when the window has no system frame: fixed at the top, 36px tall. */ | |
| 30 | + | .titlebar { | |
| 31 | + | position: fixed; | |
| 32 | + | inset: 0 0 auto 0; | |
| 33 | + | z-index: 10; | |
| 34 | + | height: 36px; | |
| 35 | + | display: flex; | |
| 36 | + | align-items: center; | |
| 37 | + | justify-content: space-between; | |
| 38 | + | padding-left: 14px; | |
| 39 | + | background: #050507; | |
| 40 | + | color: var(--muted); | |
| 41 | + | font-size: 12px; | |
| 42 | + | font-weight: 500; | |
| 43 | + | } | |
| 44 | + | .titlebar[hidden] { | |
| 45 | + | display: none; | |
| 46 | + | } | |
| 47 | + | .mac .titlebar { | |
| 48 | + | padding-left: 78px; | |
| 49 | + | } | |
| 50 | + | .mac .titlebar .caption { | |
| 51 | + | display: none; | |
| 52 | + | } | |
| 53 | + | .titlebar .caption { | |
| 54 | + | all: unset; | |
| 55 | + | display: inline-flex; | |
| 56 | + | align-items: center; | |
| 57 | + | justify-content: center; | |
| 58 | + | width: 46px; | |
| 59 | + | height: 36px; | |
| 60 | + | } | |
| 61 | + | .titlebar .caption:hover { | |
| 62 | + | background: #c42b1c; | |
| 63 | + | color: #fff; | |
| 64 | + | } | |
| 65 | + | /* Under it, the page scrolls on its own, so its scrollbar starts below the bar. */ | |
| 66 | + | body.framed .page { | |
| 67 | + | position: fixed; | |
| 68 | + | inset: 36px 0 0 0; | |
| 69 | + | overflow-y: auto; | |
| 70 | + | } | |
| 29 | 71 | main { | |
| 30 | 72 | max-width: 36rem; | |
| 31 | 73 | margin: 0 auto; |
| 13 | 13 | <link rel="stylesheet" href="settings.css" /> | |
| 14 | 14 | </head> | |
| 15 | 15 | <body> | |
| 16 | − | <main> | |
| 17 | − | <h1>Settings</h1> | |
| 18 | − | <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p> | |
| 16 | + | <!-- Its title bar, when g1t draws its own (src/frame.rs): drag it to move the window. --> | |
| 17 | + | <header class="titlebar" id="titlebar" hidden> | |
| 18 | + | <span>Settings</span> | |
| 19 | + | <button type="button" id="close-window" class="caption" aria-label="Close" title="Close" tabindex="-1"> | |
| 20 | + | <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" aria-hidden="true"> | |
| 21 | + | <line x1="4" y1="4" x2="12" y2="12" /> | |
| 22 | + | <line x1="12" y1="4" x2="4" y2="12" /> | |
| 23 | + | </svg> | |
| 24 | + | </button> | |
| 25 | + | </header> | |
| 26 | + | <div class="page"> | |
| 27 | + | <main> | |
| 28 | + | <h1>Settings</h1> | |
| 29 | + | <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p> | |
| 19 | 30 | ||
| 20 | − | <section aria-labelledby="general-heading"> | |
| 21 | − | <h2 id="general-heading">Starting and closing</h2> | |
| 22 | − | <label class="setting"> | |
| 23 | − | <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span> | |
| 24 | − | <input type="checkbox" role="switch" id="openAtLogin" /> | |
| 25 | − | </label> | |
| 26 | − | <label class="setting"> | |
| 27 | − | <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span> | |
| 28 | − | <input type="checkbox" role="switch" id="startHidden" /> | |
| 29 | − | </label> | |
| 30 | − | <label class="setting"> | |
| 31 | − | <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span> | |
| 32 | − | <input type="checkbox" role="switch" id="closeToTray" /> | |
| 33 | − | </label> | |
| 34 | − | </section> | |
| 31 | + | <section aria-labelledby="general-heading"> | |
| 32 | + | <h2 id="general-heading">Starting and closing</h2> | |
| 33 | + | <label class="setting"> | |
| 34 | + | <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span> | |
| 35 | + | <input type="checkbox" role="switch" id="openAtLogin" /> | |
| 36 | + | </label> | |
| 37 | + | <label class="setting"> | |
| 38 | + | <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span> | |
| 39 | + | <input type="checkbox" role="switch" id="startHidden" /> | |
| 40 | + | </label> | |
| 41 | + | <label class="setting"> | |
| 42 | + | <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span> | |
| 43 | + | <input type="checkbox" role="switch" id="closeToTray" /> | |
| 44 | + | </label> | |
| 45 | + | </section> | |
| 35 | 46 | ||
| 36 | − | <section aria-labelledby="notifications-heading"> | |
| 37 | − | <h2 id="notifications-heading">Notifications</h2> | |
| 38 | − | <label class="setting"> | |
| 39 | − | <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span> | |
| 40 | − | <input type="checkbox" role="switch" id="notifications" /> | |
| 41 | − | </label> | |
| 42 | − | <label class="setting"> | |
| 43 | − | <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span> | |
| 44 | − | <input type="checkbox" role="switch" id="flash" /> | |
| 45 | − | </label> | |
| 46 | − | <label class="setting"> | |
| 47 | − | <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span> | |
| 48 | − | <input type="checkbox" role="switch" id="badge" /> | |
| 49 | − | </label> | |
| 50 | − | </section> | |
| 47 | + | <section aria-labelledby="notifications-heading"> | |
| 48 | + | <h2 id="notifications-heading">Notifications</h2> | |
| 49 | + | <label class="setting"> | |
| 50 | + | <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span> | |
| 51 | + | <input type="checkbox" role="switch" id="notifications" /> | |
| 52 | + | </label> | |
| 53 | + | <label class="setting"> | |
| 54 | + | <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span> | |
| 55 | + | <input type="checkbox" role="switch" id="flash" /> | |
| 56 | + | </label> | |
| 57 | + | <label class="setting"> | |
| 58 | + | <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span> | |
| 59 | + | <input type="checkbox" role="switch" id="badge" /> | |
| 60 | + | </label> | |
| 61 | + | </section> | |
| 51 | 62 | ||
| 52 | − | <section aria-labelledby="window-heading"> | |
| 53 | − | <h2 id="window-heading">Window</h2> | |
| 54 | − | <label class="setting"> | |
| 55 | − | <span | |
| 56 | − | ><strong>Use the system's title bar</strong | |
| 57 | − | ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span | |
| 58 | − | > | |
| 59 | − | <input type="checkbox" role="switch" id="systemTitleBar" /> | |
| 60 | − | </label> | |
| 61 | − | <div class="setting" id="restart-row" hidden> | |
| 62 | − | <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span> | |
| 63 | − | <button type="button" id="restart-app" class="primary">Restart now</button> | |
| 64 | − | </div> | |
| 65 | − | <label class="setting"> | |
| 66 | − | <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span> | |
| 67 | − | <select id="zoom"> | |
| 68 | − | <option value="0.67">67%</option> | |
| 69 | − | <option value="0.75">75%</option> | |
| 70 | − | <option value="0.8">80%</option> | |
| 71 | − | <option value="0.9">90%</option> | |
| 72 | − | <option value="1">100%</option> | |
| 73 | − | <option value="1.1">110%</option> | |
| 74 | − | <option value="1.25">125%</option> | |
| 75 | − | <option value="1.5">150%</option> | |
| 76 | − | <option value="1.75">175%</option> | |
| 77 | − | <option value="2">200%</option> | |
| 78 | − | </select> | |
| 79 | − | </label> | |
| 80 | − | </section> | |
| 63 | + | <section aria-labelledby="window-heading"> | |
| 64 | + | <h2 id="window-heading">Window</h2> | |
| 65 | + | <label class="setting"> | |
| 66 | + | <span | |
| 67 | + | ><strong>Use the system's title bar</strong | |
| 68 | + | ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span | |
| 69 | + | > | |
| 70 | + | <input type="checkbox" role="switch" id="systemTitleBar" /> | |
| 71 | + | </label> | |
| 72 | + | <div class="setting" id="restart-row" hidden> | |
| 73 | + | <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span> | |
| 74 | + | <button type="button" id="restart-app" class="primary">Restart now</button> | |
| 75 | + | </div> | |
| 76 | + | <label class="setting"> | |
| 77 | + | <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span> | |
| 78 | + | <select id="zoom"> | |
| 79 | + | <option value="0.67">67%</option> | |
| 80 | + | <option value="0.75">75%</option> | |
| 81 | + | <option value="0.8">80%</option> | |
| 82 | + | <option value="0.9">90%</option> | |
| 83 | + | <option value="1">100%</option> | |
| 84 | + | <option value="1.1">110%</option> | |
| 85 | + | <option value="1.25">125%</option> | |
| 86 | + | <option value="1.5">150%</option> | |
| 87 | + | <option value="1.75">175%</option> | |
| 88 | + | <option value="2">200%</option> | |
| 89 | + | </select> | |
| 90 | + | </label> | |
| 91 | + | </section> | |
| 81 | 92 | ||
| 82 | − | <section aria-labelledby="updates-heading"> | |
| 83 | − | <h2 id="updates-heading">Updates</h2> | |
| 84 | − | <label class="setting"> | |
| 85 | − | <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span> | |
| 86 | − | <input type="checkbox" role="switch" id="autoUpdate" /> | |
| 87 | − | </label> | |
| 88 | − | <div class="setting" id="updates-row"> | |
| 89 | − | <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite"> </span></span> | |
| 90 | − | <span class="buttons"> | |
| 91 | − | <button type="button" id="restart" class="primary" hidden>Restart now</button> | |
| 92 | − | <button type="button" id="check">Check for updates</button> | |
| 93 | − | </span> | |
| 94 | − | </div> | |
| 95 | − | </section> | |
| 93 | + | <section aria-labelledby="updates-heading"> | |
| 94 | + | <h2 id="updates-heading">Updates</h2> | |
| 95 | + | <label class="setting"> | |
| 96 | + | <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span> | |
| 97 | + | <input type="checkbox" role="switch" id="autoUpdate" /> | |
| 98 | + | </label> | |
| 99 | + | <div class="setting" id="updates-row"> | |
| 100 | + | <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite"> </span></span> | |
| 101 | + | <span class="buttons"> | |
| 102 | + | <button type="button" id="restart" class="primary" hidden>Restart now</button> | |
| 103 | + | <button type="button" id="check">Check for updates</button> | |
| 104 | + | </span> | |
| 105 | + | </div> | |
| 106 | + | </section> | |
| 96 | 107 | ||
| 97 | − | <section aria-labelledby="server-heading"> | |
| 98 | − | <h2 id="server-heading">Server</h2> | |
| 99 | − | <div class="setting"> | |
| 100 | − | <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span> | |
| 101 | − | <button type="button" id="change-server">Change…</button> | |
| 102 | − | </div> | |
| 103 | − | </section> | |
| 108 | + | <section aria-labelledby="server-heading"> | |
| 109 | + | <h2 id="server-heading">Server</h2> | |
| 110 | + | <div class="setting"> | |
| 111 | + | <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span> | |
| 112 | + | <button type="button" id="change-server">Change…</button> | |
| 113 | + | </div> | |
| 114 | + | </section> | |
| 104 | 115 | ||
| 105 | − | <p class="error" id="error" role="alert" hidden></p> | |
| 106 | − | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p> | |
| 107 | − | </main> | |
| 116 | + | <p class="error" id="error" role="alert" hidden></p> | |
| 117 | + | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p> | |
| 118 | + | </main> | |
| 119 | + | </div> | |
| 108 | 120 | <script src="settings.js"></script> | |
| 109 | 121 | </body> | |
| 110 | 122 | </html> |
| 178 | 178 | invoke("open_url", { url: anchor.href }).catch(function () {}); | |
| 179 | 179 | }); | |
| 180 | 180 | ||
| 181 | + | // g1t's own title bar, when the window has no system frame. | |
| 182 | + | function frame(view) { | |
| 183 | + | if (!view.customFrame) return; | |
| 184 | + | if (view.platform === "macos") document.documentElement.classList.add("mac"); | |
| 185 | + | var bar = byId("titlebar"); | |
| 186 | + | bar.hidden = false; | |
| 187 | + | document.body.classList.add("framed"); | |
| 188 | + | bar.addEventListener("mousedown", function (event) { | |
| 189 | + | if (event.button !== 0 || event.target.closest("button")) return; | |
| 190 | + | invoke("window_control", { action: "drag" }).catch(function () {}); | |
| 191 | + | }); | |
| 192 | + | byId("close-window").addEventListener("click", function () { | |
| 193 | + | invoke("window_control", { action: "close" }).catch(function () {}); | |
| 194 | + | }); | |
| 195 | + | } | |
| 196 | + | ||
| 181 | 197 | invoke("get_settings") | |
| 182 | 198 | .then(function (view) { | |
| 199 | + | frame(view); | |
| 183 | 200 | platform(view.platform); | |
| 184 | 201 | render(view); | |
| 185 | 202 | // Opened by "Check for updates…" in a menu. |
| 1 | + | /* The title bar, in the colours of the page under it (titlebar.js sets | |
| 2 | + | them): g1t's rail on g1t's pages, light or dark. 36px tall. */ | |
| 3 | + | :root { | |
| 4 | + | --bg: #050507; | |
| 5 | + | --fg: #ededef; | |
| 6 | + | --muted: #a0a0a8; | |
| 7 | + | --accent: #b6a8ff; | |
| 8 | + | --hover: rgba(237, 237, 239, 0.1); | |
| 9 | + | --pill: rgba(237, 237, 239, 0.09); | |
| 10 | + | --pill-hover: rgba(237, 237, 239, 0.14); | |
| 11 | + | --pill-edge: rgba(237, 237, 239, 0.08); | |
| 12 | + | } | |
| 13 | + | * { | |
| 14 | + | box-sizing: border-box; | |
| 15 | + | } | |
| 16 | + | html, | |
| 17 | + | body { | |
| 18 | + | height: 100%; | |
| 19 | + | margin: 0; | |
| 20 | + | overflow: hidden; | |
| 21 | + | background: var(--bg); | |
| 22 | + | } | |
| 23 | + | .bar { | |
| 24 | + | height: 100%; | |
| 25 | + | display: flex; | |
| 26 | + | align-items: center; | |
| 27 | + | gap: 2px; | |
| 28 | + | padding-left: 6px; | |
| 29 | + | color: var(--muted); | |
| 30 | + | font: 500 12px/1 system-ui, "Segoe UI", -apple-system, Roboto, sans-serif; | |
| 31 | + | user-select: none; | |
| 32 | + | -webkit-user-select: none; | |
| 33 | + | cursor: default; | |
| 34 | + | } | |
| 35 | + | .mac .bar { | |
| 36 | + | padding-left: 78px; | |
| 37 | + | } | |
| 38 | + | .side { | |
| 39 | + | display: flex; | |
| 40 | + | align-items: center; | |
| 41 | + | gap: 2px; | |
| 42 | + | flex: 1 1 0; | |
| 43 | + | min-width: 0; | |
| 44 | + | } | |
| 45 | + | .end { | |
| 46 | + | justify-content: flex-end; | |
| 47 | + | } | |
| 48 | + | .middle { | |
| 49 | + | flex: 0 1 560px; | |
| 50 | + | min-width: 0; | |
| 51 | + | display: flex; | |
| 52 | + | justify-content: center; | |
| 53 | + | } | |
| 54 | + | button { | |
| 55 | + | all: unset; | |
| 56 | + | box-sizing: border-box; | |
| 57 | + | display: inline-flex; | |
| 58 | + | align-items: center; | |
| 59 | + | justify-content: center; | |
| 60 | + | color: inherit; | |
| 61 | + | } | |
| 62 | + | .icon { | |
| 63 | + | width: 28px; | |
| 64 | + | height: 26px; | |
| 65 | + | border-radius: 6px; | |
| 66 | + | } | |
| 67 | + | .icon:hover { | |
| 68 | + | background: var(--hover); | |
| 69 | + | color: var(--fg); | |
| 70 | + | } | |
| 71 | + | .icon:disabled { | |
| 72 | + | opacity: 0.35; | |
| 73 | + | background: none; | |
| 74 | + | color: inherit; | |
| 75 | + | } | |
| 76 | + | .search { | |
| 77 | + | width: 100%; | |
| 78 | + | height: 24px; | |
| 79 | + | gap: 8px; | |
| 80 | + | padding: 0 10px; | |
| 81 | + | justify-content: flex-start; | |
| 82 | + | border-radius: 6px; | |
| 83 | + | background: var(--pill); | |
| 84 | + | box-shadow: inset 0 0 0 1px var(--pill-edge); | |
| 85 | + | } | |
| 86 | + | .search:hover { | |
| 87 | + | background: var(--pill-hover); | |
| 88 | + | color: var(--fg); | |
| 89 | + | } | |
| 90 | + | .search span, | |
| 91 | + | .title { | |
| 92 | + | overflow: hidden; | |
| 93 | + | text-overflow: ellipsis; | |
| 94 | + | white-space: nowrap; | |
| 95 | + | } | |
| 96 | + | .title { | |
| 97 | + | padding: 0 8px; | |
| 98 | + | } | |
| 99 | + | .caption { | |
| 100 | + | width: 46px; | |
| 101 | + | height: 36px; | |
| 102 | + | } | |
| 103 | + | .caption:hover { | |
| 104 | + | background: var(--hover); | |
| 105 | + | color: var(--fg); | |
| 106 | + | } | |
| 107 | + | .caption.close:hover { | |
| 108 | + | background: #c42b1c; | |
| 109 | + | color: #fff; | |
| 110 | + | } | |
| 111 | + | button:focus-visible { | |
| 112 | + | outline: 2px solid var(--accent); | |
| 113 | + | outline-offset: -2px; | |
| 114 | + | } |
| 1 | + | <!doctype html> | |
| 2 | + | <!-- | |
| 3 | + | The title bar: a page of its own, across the top of the main window, | |
| 4 | + | above the site's (src/frame.rs). It shows what the site's page reports | |
| 5 | + | (its title, its search, whether there is a way back, its colours), and | |
| 6 | + | its buttons act on the window and on that page through the app. | |
| 7 | + | --> | |
| 8 | + | <html lang="en"> | |
| 9 | + | <head> | |
| 10 | + | <meta charset="utf-8" /> | |
| 11 | + | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 12 | + | <title>g1t</title> | |
| 13 | + | <link rel="stylesheet" href="titlebar.css" /> | |
| 14 | + | </head> | |
| 15 | + | <body> | |
| 16 | + | <div class="bar" id="bar" role="toolbar" aria-label="Window"> | |
| 17 | + | <div class="side" id="left"></div> | |
| 18 | + | <div class="middle" id="middle"></div> | |
| 19 | + | <div class="side end" id="right"></div> | |
| 20 | + | </div> | |
| 21 | + | <script src="titlebar.js"></script> | |
| 22 | + | </body> | |
| 23 | + | </html> |
| 1 | + | // The title bar's work: draw what the page under it reports, and send its | |
| 2 | + | // buttons to the app (src/bridge.rs: title_bar_current, title_bar_act, | |
| 3 | + | // window_control, window_menu). A click here hands the keyboard back to | |
| 4 | + | // the page, so typing goes on where it was. | |
| 5 | + | (function () { | |
| 6 | + | var core = window.__TAURI__ && window.__TAURI__.core; | |
| 7 | + | var events = window.__TAURI__ && window.__TAURI__.event; | |
| 8 | + | function invoke(command, args) { | |
| 9 | + | if (core && typeof core.invoke === "function") return core.invoke(command, args || {}); | |
| 10 | + | return Promise.reject(new Error("the title bar cannot reach the app")); | |
| 11 | + | } | |
| 12 | + | function quietly(promise) { | |
| 13 | + | promise.catch(function () {}); | |
| 14 | + | } | |
| 15 | + | function act(action) { | |
| 16 | + | quietly(invoke("title_bar_act", { action: action })); | |
| 17 | + | } | |
| 18 | + | function control(action) { | |
| 19 | + | return invoke("window_control", { action: action }); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | var ua = navigator.userAgent; | |
| 23 | + | var MAC = /Macintosh|Mac OS X/.test(ua) && !/Windows/.test(ua); | |
| 24 | + | if (MAC) document.documentElement.classList.add("mac"); | |
| 25 | + | ||
| 26 | + | function svg(body) { | |
| 27 | + | return ( | |
| 28 | + | '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + | |
| 29 | + | body + | |
| 30 | + | "</svg>" | |
| 31 | + | ); | |
| 32 | + | } | |
| 33 | + | // Drawn here, from lines and boxes, as each system draws its own. | |
| 34 | + | var ICONS = { | |
| 35 | + | menu: svg('<line x1="3" y1="4.5" x2="13" y2="4.5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11.5" x2="13" y2="11.5"/>'), | |
| 36 | + | back: svg('<polyline points="9.5,3.5 5,8 9.5,12.5"/><line x1="5" y1="8" x2="13" y2="8"/>'), | |
| 37 | + | forward: svg('<polyline points="6.5,3.5 11,8 6.5,12.5"/><line x1="3" y1="8" x2="11" y2="8"/>'), | |
| 38 | + | search: svg('<circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="13" y2="13"/>'), | |
| 39 | + | minimize: svg('<line x1="3.5" y1="8" x2="12.5" y2="8"/>'), | |
| 40 | + | maximize: svg('<rect x="3.5" y="3.5" width="9" height="9" rx="1"/>'), | |
| 41 | + | restore: svg('<rect x="3.5" y="5.5" width="7" height="7" rx="1"/><path d="M5.5 5.5V4.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1"/>'), | |
| 42 | + | close: svg('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'), | |
| 43 | + | }; | |
| 44 | + | ||
| 45 | + | function button(kind, label, icon, onClick) { | |
| 46 | + | var b = document.createElement("button"); | |
| 47 | + | b.type = "button"; | |
| 48 | + | b.tabIndex = -1; | |
| 49 | + | b.className = kind; | |
| 50 | + | b.setAttribute("aria-label", label); | |
| 51 | + | b.title = label; | |
| 52 | + | if (icon) b.innerHTML = ICONS[icon]; | |
| 53 | + | b.addEventListener("click", function (event) { | |
| 54 | + | event.stopPropagation(); | |
| 55 | + | onClick(b); | |
| 56 | + | }); | |
| 57 | + | return b; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var bar = document.getElementById("bar"); | |
| 61 | + | var left = document.getElementById("left"); | |
| 62 | + | var middle = document.getElementById("middle"); | |
| 63 | + | var right = document.getElementById("right"); | |
| 64 | + | ||
| 65 | + | if (!MAC) { | |
| 66 | + | left.append( | |
| 67 | + | button("icon", "Menu", "menu", function (b) { | |
| 68 | + | var box = b.getBoundingClientRect(); | |
| 69 | + | quietly(invoke("window_menu", { x: box.left, y: box.bottom })); | |
| 70 | + | }), | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | var back = button("icon", "Back", "back", function () { | |
| 74 | + | act("back"); | |
| 75 | + | }); | |
| 76 | + | var forward = button("icon", "Forward", "forward", function () { | |
| 77 | + | act("forward"); | |
| 78 | + | }); | |
| 79 | + | left.append(back, forward); | |
| 80 | + | ||
| 81 | + | var maximize = null; | |
| 82 | + | function drawMaximized(maximized) { | |
| 83 | + | if (!maximize) return; | |
| 84 | + | maximize.innerHTML = maximized ? ICONS.restore : ICONS.maximize; | |
| 85 | + | maximize.setAttribute("aria-label", maximized ? "Restore" : "Maximize"); | |
| 86 | + | maximize.title = maximized ? "Restore" : "Maximize"; | |
| 87 | + | } | |
| 88 | + | // The window's buttons, at the right as Windows and Linux put them; macOS has its own. | |
| 89 | + | if (!MAC) { | |
| 90 | + | maximize = button("caption", "Maximize", "maximize", function () { | |
| 91 | + | control("maximize").then(drawMaximized, function () {}); | |
| 92 | + | }); | |
| 93 | + | right.append( | |
| 94 | + | button("caption", "Minimize", "minimize", function () { | |
| 95 | + | quietly(control("minimize")); | |
| 96 | + | }), | |
| 97 | + | maximize, | |
| 98 | + | button("caption close", "Close", "close", function () { | |
| 99 | + | quietly(control("close")); | |
| 100 | + | }), | |
| 101 | + | ); | |
| 102 | + | var resizing = 0; | |
| 103 | + | window.addEventListener("resize", function () { | |
| 104 | + | clearTimeout(resizing); | |
| 105 | + | resizing = setTimeout(function () { | |
| 106 | + | control("maximized").then(drawMaximized, function () {}); | |
| 107 | + | }, 120); | |
| 108 | + | }); | |
| 109 | + | control("maximized").then(drawMaximized, function () {}); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Dragging the bar moves the window; a double click maximizes it, as a system's title bar does. | |
| 113 | + | bar.addEventListener("mousedown", function (event) { | |
| 114 | + | if (event.button !== 0 || event.target.closest("button")) return; | |
| 115 | + | if (event.detail === 2) { | |
| 116 | + | control("maximize").then(drawMaximized, function () {}); | |
| 117 | + | return; | |
| 118 | + | } | |
| 119 | + | quietly(control("drag")); | |
| 120 | + | }); | |
| 121 | + | // The keyboard belongs to the page: a click here gives it back. | |
| 122 | + | window.addEventListener("mouseup", function () { | |
| 123 | + | act("focus"); | |
| 124 | + | }); | |
| 125 | + | ||
| 126 | + | var title = document.createElement("span"); | |
| 127 | + | title.className = "title"; | |
| 128 | + | var pill = button("search", "Search", "search", function () { | |
| 129 | + | act("search"); | |
| 130 | + | }); | |
| 131 | + | var pillText = document.createElement("span"); | |
| 132 | + | pill.append(pillText); | |
| 133 | + | ||
| 134 | + | function setColors(colors) { | |
| 135 | + | var root = document.documentElement.style; | |
| 136 | + | if (!colors) return; | |
| 137 | + | var shade = colors.dark ? "237, 237, 239" : "15, 15, 17"; | |
| 138 | + | root.setProperty("--bg", colors.background); | |
| 139 | + | root.setProperty("--fg", colors.foreground); | |
| 140 | + | root.setProperty("--muted", colors.muted); | |
| 141 | + | root.setProperty("--accent", colors.accent); | |
| 142 | + | root.setProperty("--hover", "rgba(" + shade + ", " + (colors.dark ? 0.1 : 0.07) + ")"); | |
| 143 | + | root.setProperty("--pill", "rgba(" + shade + ", " + (colors.dark ? 0.09 : 0.05) + ")"); | |
| 144 | + | root.setProperty("--pill-hover", "rgba(" + shade + ", " + (colors.dark ? 0.14 : 0.09) + ")"); | |
| 145 | + | root.setProperty("--pill-edge", "rgba(" + shade + ", " + (colors.dark ? 0.08 : 0.1) + ")"); | |
| 146 | + | document.documentElement.style.colorScheme = colors.dark ? "dark" : "light"; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | function draw(page) { | |
| 150 | + | if (!page) return; | |
| 151 | + | middle.textContent = ""; | |
| 152 | + | if (page.search) { | |
| 153 | + | pillText.textContent = page.search; | |
| 154 | + | pill.setAttribute("aria-label", page.search); | |
| 155 | + | pill.removeAttribute("title"); | |
| 156 | + | middle.append(pill); | |
| 157 | + | } else { | |
| 158 | + | title.textContent = page.title || "g1t"; | |
| 159 | + | middle.append(title); | |
| 160 | + | } | |
| 161 | + | back.disabled = page.canGoBack === false; | |
| 162 | + | forward.disabled = page.canGoForward === false; | |
| 163 | + | setColors(page.colors); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | if (events && typeof events.listen === "function") { | |
| 167 | + | events | |
| 168 | + | .listen("g1t-title-bar", function (event) { | |
| 169 | + | draw(event.payload); | |
| 170 | + | }) | |
| 171 | + | .catch(function () {}); | |
| 172 | + | } | |
| 173 | + | invoke("title_bar_current").then(draw, function () {}); | |
| 174 | + | })(); |
| 16 | 16 | tauri-build = { version = "2", features = [] } | |
| 17 | 17 | ||
| 18 | 18 | [dependencies] | |
| 19 | − | tauri = { version = "2", features = ["tray-icon", "image-png"] } | |
| 19 | + | # `unstable`: a window holding more than one web view, so the title bar | |
| 20 | + | # and the site sit one above the other (src/frame.rs). | |
| 21 | + | tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] } | |
| 20 | 22 | # Updates: a signed manifest at g1t.sh/downloads/desktop/latest.json. | |
| 21 | 23 | tauri-plugin-updater = "2" | |
| 22 | 24 | # Native notifications, from the site's live feed. |
| 21 | 21 | "restart", | |
| 22 | 22 | "window_control", | |
| 23 | 23 | "window_menu", | |
| 24 | + | "title_bar_page", | |
| 25 | + | "title_bar_current", | |
| 26 | + | "title_bar_act", | |
| 24 | 27 | ]))) | |
| 25 | 28 | .expect("the app's build could not be prepared"); | |
| 26 | 29 | } |
| 1 | 1 | { | |
| 2 | 2 | "$schema": "../gen/schemas/desktop-schema.json", | |
| 3 | 3 | "identifier": "launcher", | |
| 4 | − | "description": "The launcher page bundled with the app, which finds the server and goes there. The site's own pages get their capability at run time, for the server that is configured (src/config.rs).", | |
| 4 | + | "description": "The pages bundled with the app in the main window: the launcher, which finds the server and goes there, and the title bar across the top (src/frame.rs). The site's own pages get their capability at run time, for the server that is configured (src/config.rs).", | |
| 5 | 5 | "windows": ["main"], | |
| 6 | 6 | "permissions": [ | |
| 7 | 7 | "core:default", | |
| ⋯ | |||
| 12 | 12 | "allow-check-for-updates", | |
| 13 | 13 | "allow-open-settings", | |
| 14 | 14 | "allow-window-control", | |
| 15 | − | "allow-window-menu" | |
| 15 | + | "allow-window-menu", | |
| 16 | + | "allow-title-bar-page", | |
| 17 | + | "allow-title-bar-current", | |
| 18 | + | "allow-title-bar-act" | |
| 16 | 19 | ] | |
| 17 | 20 | } | |
| 6 | 6 | ||
| 7 | 7 | use tauri::{AppHandle, Manager}; | |
| 8 | 8 | ||
| 9 | − | use crate::{WINDOW, config, tray}; | |
| 9 | + | use crate::{config, tray}; | |
| 10 | 10 | ||
| 11 | 11 | pub fn set(app: &AppHandle, count: i64) { | |
| 12 | 12 | *app.state::<config::State>().waiting.lock().unwrap() = count; | |
| 13 | 13 | let shown = if config::settings(app).badge { count } else { 0 }; | |
| 14 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 14 | + | if let Some(window) = crate::frame::window(app) { | |
| 15 | 15 | #[cfg(target_os = "windows")] | |
| 16 | 16 | let _ = window.set_overlay_icon(if shown > 0 { Some(dot()) } else { None }); | |
| 17 | 17 | #[cfg(not(target_os = "windows"))] |
| 9 | 9 | if (window.g1tDesktop) return; | |
| 10 | 10 | var VERSION = "__VERSION__"; | |
| 11 | 11 | var PLATFORM = "__PLATFORM__"; | |
| 12 | − | // Which window this is ("main" or "settings"), whether it draws g1t's | |
| 13 | − | // own title bar ("custom") or has the system's, and the bar's height. | |
| 14 | − | var WINDOW = "__WINDOW__"; | |
| 12 | + | // Whether g1t's own title bar is above this page ("custom"), or the | |
| 13 | + | // system's ("system"). The bar is a page of its own (src/frame.rs); this | |
| 14 | + | // page only tells it what to show (`report`), and is never under it. | |
| 15 | 15 | var FRAME = "__FRAME__"; | |
| 16 | − | var HEIGHT = "__HEIGHT__"; | |
| 17 | 16 | // The site's pages for reading about g1t, not using it, which open in | |
| 18 | 17 | // the browser (navigation.rs); one ending in "/" is every page under it. | |
| 19 | 18 | var OUTSIDE = "__OUTSIDE__"; | |
| ⋯ | |||
| 91 | 90 | setSearch: function (label, open) { | |
| 92 | 91 | var mine = typeof open === "function" ? { label: String(label || "Search"), open: open } : null; | |
| 93 | 92 | search = mine; | |
| 94 | − | drawSearch(); | |
| 93 | + | report(); | |
| 95 | 94 | return function () { | |
| 96 | 95 | if (search === mine) { | |
| 97 | 96 | search = null; | |
| 98 | − | drawSearch(); | |
| 97 | + | report(); | |
| 99 | 98 | } | |
| 100 | 99 | }; | |
| 101 | 100 | }, | |
| 102 | 101 | // Whether g1t draws the title bar, and how tall it is: 0 under the system's. | |
| 103 | − | titleBarHeight: FRAME === "custom" ? HEIGHT : 0, | |
| 102 | + | // The page is below it, never under it: this says only that the bar is there. | |
| 103 | + | titleBarHeight: FRAME === "custom" ? 36 : 0, | |
| 104 | 104 | }; | |
| 105 | 105 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); | |
| 106 | 106 | ||
| ⋯ | |||
| 154 | 154 | }; | |
| 155 | 155 | ||
| 156 | 156 | // ── The title bar ───────────────────────────────────────────────────── | |
| 157 | − | // g1t's own, in place of the system's (src/frame.rs), on every page the | |
| 158 | − | // window shows: the menu button, back and forward, search in the middle, | |
| 159 | − | // and the window's buttons; on macOS the system's traffic lights sit at | |
| 160 | − | // its left. It is drawn in a closed shadow root, so no page's styles | |
| 161 | − | // reach it, in the colours of the site's rail where the page has them. | |
| 162 | − | // `data-titlebar` on <html> tells the page to make room: the site does | |
| 163 | − | // (`--frame-top`, apps/web/app/app.css); any other page is pushed down. | |
| 157 | + | // What the bar above the page shows of it: the title, the search the | |
| 158 | + | // site handed over (`setSearch`), whether there is a page to go back or | |
| 159 | + | // forward to, and the page's colours, so the bar is the colour of g1t's | |
| 160 | + | // rail, light or dark, or of whatever page is showing. Sent whenever any | |
| 161 | + | // of it changes; the bar's search comes back as an event. | |
| 164 | 162 | var search = null; | |
| 165 | − | var drawSearch = function () {}; | |
| 163 | + | var report = function () {}; | |
| 166 | 164 | if (FRAME !== "custom") return; | |
| 167 | − | var MAC = PLATFORM === "macos"; | |
| 168 | − | var MAIN = WINDOW === "main"; | |
| 169 | 165 | ||
| 170 | − | function whenThere(test, then) { | |
| 171 | − | if (test()) return then(); | |
| 172 | − | var watch = new MutationObserver(function () { | |
| 173 | − | if (!test()) return; | |
| 174 | − | watch.disconnect(); | |
| 175 | − | then(); | |
| 176 | − | }); | |
| 177 | − | watch.observe(document, { childList: true, subtree: true }); | |
| 178 | − | } | |
| 179 | − | ||
| 180 | − | // Room for the bar, before the page paints. With no specificity of its | |
| 181 | − | // own, any rule of the page's for the same thing wins. | |
| 182 | − | var ROOM = ":where(html[data-titlebar]) { padding-top: " + HEIGHT + "px; }"; | |
| 183 | − | try { | |
| 184 | − | var sheet = new CSSStyleSheet(); | |
| 185 | − | sheet.replaceSync(ROOM); | |
| 186 | − | document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet); | |
| 187 | − | } catch (error) { | |
| 188 | − | whenThere( | |
| 189 | − | function () { | |
| 190 | − | return document.head; | |
| 191 | − | }, | |
| 192 | − | function () { | |
| 193 | − | var style = document.createElement("style"); | |
| 194 | − | style.textContent = ROOM; | |
| 195 | − | document.head.appendChild(style); | |
| 196 | − | }, | |
| 197 | − | ); | |
| 198 | − | } | |
| 199 | − | whenThere( | |
| 200 | − | function () { | |
| 201 | − | return document.documentElement; | |
| 202 | − | }, | |
| 203 | − | function () { | |
| 204 | − | document.documentElement.setAttribute("data-titlebar", PLATFORM); | |
| 205 | − | }, | |
| 206 | − | ); | |
| 207 | − | ||
| 208 | − | function svg(body) { | |
| 209 | − | return '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>"; | |
| 210 | − | } | |
| 211 | − | // Drawn here, from lines and boxes, as each system draws its own. | |
| 212 | − | var ICONS = { | |
| 213 | − | menu: svg('<line x1="3" y1="4.5" x2="13" y2="4.5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11.5" x2="13" y2="11.5"/>'), | |
| 214 | − | back: svg('<polyline points="9.5,3.5 5,8 9.5,12.5"/><line x1="5" y1="8" x2="13" y2="8"/>'), | |
| 215 | − | forward: svg('<polyline points="6.5,3.5 11,8 6.5,12.5"/><line x1="3" y1="8" x2="11" y2="8"/>'), | |
| 216 | − | search: svg('<circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="13" y2="13"/>'), | |
| 217 | − | minimize: svg('<line x1="3.5" y1="8" x2="12.5" y2="8"/>'), | |
| 218 | − | maximize: svg('<rect x="3.5" y="3.5" width="9" height="9" rx="1"/>'), | |
| 219 | − | restore: svg('<rect x="3.5" y="5.5" width="7" height="7" rx="1"/><path d="M5.5 5.5V4.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1"/>'), | |
| 220 | − | close: svg('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'), | |
| 221 | − | }; | |
| 222 | − | ||
| 223 | − | var STYLE = | |
| 224 | − | ":host{position:fixed;top:0;left:0;right:0;height:" + HEIGHT + "px;z-index:2147483646;display:block;}" + | |
| 225 | − | ".bar{box-sizing:border-box;height:100%;display:flex;align-items:center;gap:2px;padding:0 0 0 6px;" + | |
| 226 | − | "background:var(--color-rail,#050507);color:var(--color-muted,#a0a0a8);" + | |
| 227 | − | "font:500 12px/1 system-ui,'Segoe UI',-apple-system,Roboto,sans-serif;user-select:none;-webkit-user-select:none;cursor:default;}" + | |
| 228 | − | ".mac .bar{padding-left:78px;}" + | |
| 229 | − | ".side{display:flex;align-items:center;gap:2px;flex:1 1 0;min-width:0;}" + | |
| 230 | − | ".end{justify-content:flex-end;}" + | |
| 231 | − | ".middle{flex:0 1 560px;min-width:0;display:flex;justify-content:center;}" + | |
| 232 | − | "button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;color:inherit;}" + | |
| 233 | − | ".icon{width:28px;height:26px;border-radius:6px;}" + | |
| 234 | − | ".icon:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" + | |
| 235 | − | ".icon:disabled{opacity:.35;background:none;color:inherit;}" + | |
| 236 | − | ".search{width:100%;height:24px;gap:8px;padding:0 10px;justify-content:flex-start;border-radius:6px;" + | |
| 237 | − | "background:color-mix(in srgb,var(--color-fg,#ededef) 9%,transparent);" + | |
| 238 | − | "box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--color-fg,#ededef) 8%,transparent);}" + | |
| 239 | − | ".search:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 14%,transparent);color:var(--color-fg,#ededef);}" + | |
| 240 | − | ".search span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" + | |
| 241 | − | ".title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 8px;}" + | |
| 242 | − | ".caption{width:46px;height:" + HEIGHT + "px;}" + | |
| 243 | − | ".caption:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" + | |
| 244 | − | ".caption.close:hover{background:#c42b1c;color:#fff;}" + | |
| 245 | − | "button:focus-visible{outline:2px solid var(--color-accent,#b6a8ff);outline-offset:-2px;}"; | |
| 246 | − | ||
| 247 | − | function button(kind, label, onClick, extra) { | |
| 248 | − | var b = document.createElement("button"); | |
| 249 | − | b.type = "button"; | |
| 250 | − | b.tabIndex = -1; | |
| 251 | − | b.className = kind + (extra ? " " + extra : ""); | |
| 252 | − | b.setAttribute("aria-label", label); | |
| 253 | − | b.title = label; | |
| 254 | − | b.addEventListener("click", function (event) { | |
| 255 | − | event.stopPropagation(); | |
| 256 | − | onClick(event, b); | |
| 257 | − | }); | |
| 258 | − | return b; | |
| 259 | − | } | |
| 260 | − | ||
| 261 | − | function control(action) { | |
| 262 | − | return invoke("window_control", { action: action }); | |
| 263 | − | } | |
| 264 | − | ||
| 265 | − | var host = document.createElement("g1t-titlebar"); | |
| 266 | − | var shadow = host.attachShadow({ mode: "closed" }); | |
| 267 | − | var frame = document.createElement("div"); | |
| 268 | − | frame.className = MAC ? "mac" : ""; | |
| 269 | − | frame.style.height = "100%"; | |
| 270 | − | var styleEl = document.createElement("style"); | |
| 271 | − | styleEl.textContent = STYLE; | |
| 272 | − | var bar = document.createElement("div"); | |
| 273 | − | bar.className = "bar"; | |
| 274 | − | bar.setAttribute("role", "toolbar"); | |
| 275 | − | bar.setAttribute("aria-label", "Window"); | |
| 276 | − | var left = document.createElement("div"); | |
| 277 | − | left.className = "side"; | |
| 278 | − | var middle = document.createElement("div"); | |
| 279 | − | middle.className = "middle"; | |
| 280 | − | var right = document.createElement("div"); | |
| 281 | − | right.className = "side end"; | |
| 282 | − | bar.append(left, middle, right); | |
| 283 | − | frame.append(styleEl, bar); | |
| 284 | − | shadow.append(frame); | |
| 285 | − | ||
| 286 | − | var back = null; | |
| 287 | − | var forward = null; | |
| 288 | − | if (MAIN) { | |
| 289 | − | if (!MAC) { | |
| 290 | − | left.append( | |
| 291 | − | button("icon", "Menu", function (event, b) { | |
| 292 | − | var box = b.getBoundingClientRect(); | |
| 293 | − | quietly(invoke("window_menu", { x: box.left, y: box.bottom })); | |
| 294 | − | }), | |
| 295 | − | ); | |
| 166 | + | // A colour the page names, resolved: `var(--color-rail)` is light-dark(…) until it is drawn. | |
| 167 | + | var probe = null; | |
| 168 | + | function resolve(value) { | |
| 169 | + | if (!document.body) return ""; | |
| 170 | + | if (!probe) { | |
| 171 | + | probe = document.createElement("span"); | |
| 172 | + | probe.setAttribute("aria-hidden", "true"); | |
| 173 | + | probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;"; | |
| 296 | 174 | } | |
| 297 | − | back = button("icon", "Back", function () { | |
| 298 | − | history.back(); | |
| 299 | − | }); | |
| 300 | − | forward = button("icon", "Forward", function () { | |
| 301 | − | history.forward(); | |
| 302 | − | }); | |
| 303 | − | back.innerHTML = ICONS.back; | |
| 304 | − | forward.innerHTML = ICONS.forward; | |
| 305 | − | left.append(back, forward); | |
| 306 | − | if (!MAC) left.firstChild.innerHTML = ICONS.menu; | |
| 175 | + | if (probe.parentNode !== document.body) document.body.appendChild(probe); | |
| 176 | + | probe.style.color = ""; | |
| 177 | + | probe.style.color = value; | |
| 178 | + | var color = getComputedStyle(probe).color; | |
| 179 | + | probe.remove(); | |
| 180 | + | return color; | |
| 307 | 181 | } | |
| 308 | − | ||
| 309 | − | // Back and forward, greyed when there is nowhere to go, where the web view can tell. | |
| 310 | − | function drawHistory() { | |
| 311 | − | if (!back) return; | |
| 312 | − | var nav = window.navigation; | |
| 313 | − | if (!nav || typeof nav.canGoBack !== "boolean") return; | |
| 314 | − | back.disabled = !nav.canGoBack; | |
| 315 | − | forward.disabled = !nav.canGoForward; | |
| 182 | + | function opaque(color) { | |
| 183 | + | return !!color && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color); | |
| 316 | 184 | } | |
| 317 | − | if (window.navigation && typeof window.navigation.addEventListener === "function") { | |
| 318 | − | window.navigation.addEventListener("currententrychange", drawHistory); | |
| 185 | + | function luminance(color) { | |
| 186 | + | var parts = (color.match(/[\d.]+/g) || []).map(Number); | |
| 187 | + | return parts.length < 3 ? 0 : (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255; | |
| 319 | 188 | } | |
| 320 | − | drawHistory(); | |
| 321 | − | ||
| 322 | − | // The middle: the site's search when it has handed one over, otherwise the page's title. | |
| 323 | − | var title = document.createElement("span"); | |
| 324 | − | title.className = "title"; | |
| 325 | − | drawSearch = function () { | |
| 326 | − | middle.textContent = ""; | |
| 327 | − | if (search) { | |
| 328 | − | var pill = button("search", search.label, function () { | |
| 329 | − | search.open(); | |
| 330 | − | }); | |
| 331 | − | pill.innerHTML = ICONS.search; | |
| 332 | − | var text = document.createElement("span"); | |
| 333 | − | text.textContent = search.label; | |
| 334 | − | pill.append(text); | |
| 335 | − | middle.append(pill); | |
| 336 | − | } else { | |
| 337 | − | title.textContent = document.title || "g1t"; | |
| 338 | − | middle.append(title); | |
| 339 | − | } | |
| 340 | − | }; | |
| 341 | − | drawSearch(); | |
| 342 | − | { | |
| 343 | − | var retitle = function () { | |
| 344 | − | if (!search) title.textContent = document.title || "g1t"; | |
| 189 | + | // g1t's pages name their colours (apps/web/app/app.css); any other page has its own background and text. | |
| 190 | + | function colors() { | |
| 191 | + | if (!document.body) return null; | |
| 192 | + | var root = getComputedStyle(document.documentElement); | |
| 193 | + | var ours = root.getPropertyValue("--color-rail").trim() !== ""; | |
| 194 | + | var body = getComputedStyle(document.body); | |
| 195 | + | var background = ours ? resolve("var(--color-rail)") : body.backgroundColor; | |
| 196 | + | if (!opaque(background)) background = root.backgroundColor; | |
| 197 | + | if (!opaque(background)) return null; | |
| 198 | + | var foreground = ours ? resolve("var(--color-fg)") : body.color; | |
| 199 | + | return { | |
| 200 | + | background: background, | |
| 201 | + | foreground: foreground, | |
| 202 | + | muted: ours ? resolve("var(--color-muted)") : foreground, | |
| 203 | + | accent: ours ? resolve("var(--color-accent)") : foreground, | |
| 204 | + | dark: luminance(background) < 0.5, | |
| 345 | 205 | }; | |
| 346 | − | document.addEventListener("DOMContentLoaded", retitle); | |
| 347 | − | whenThere( | |
| 348 | − | function () { | |
| 349 | − | return document.head; | |
| 350 | − | }, | |
| 351 | − | function () { | |
| 352 | − | retitle(); | |
| 353 | − | new MutationObserver(retitle).observe(document.head, { subtree: true, childList: true, characterData: true }); | |
| 354 | − | }, | |
| 355 | − | ); | |
| 356 | 206 | } | |
| 357 | 207 | ||
| 358 | − | // The window's buttons, at the right as Windows and Linux put them; macOS has its own. | |
| 359 | − | if (!MAC) { | |
| 360 | − | if (MAIN) { | |
| 361 | − | var minimize = button("caption", "Minimize", function () { | |
| 362 | − | quietly(control("minimize")); | |
| 363 | − | }); | |
| 364 | − | minimize.innerHTML = ICONS.minimize; | |
| 365 | − | var maximize = button("caption", "Maximize", function () { | |
| 366 | − | control("maximize").then(drawMaximized, function () {}); | |
| 367 | − | }); | |
| 368 | − | maximize.innerHTML = ICONS.maximize; | |
| 369 | − | right.append(minimize, maximize); | |
| 370 | − | var drawMaximized = function (maximized) { | |
| 371 | − | maximize.innerHTML = maximized ? ICONS.restore : ICONS.maximize; | |
| 372 | − | maximize.setAttribute("aria-label", maximized ? "Restore" : "Maximize"); | |
| 373 | − | maximize.title = maximized ? "Restore" : "Maximize"; | |
| 208 | + | var sent = ""; | |
| 209 | + | var waiting = 0; | |
| 210 | + | report = function () { | |
| 211 | + | clearTimeout(waiting); | |
| 212 | + | waiting = setTimeout(function () { | |
| 213 | + | var nav = window.navigation; | |
| 214 | + | var page = { | |
| 215 | + | title: document.title || "g1t", | |
| 216 | + | search: search ? search.label : null, | |
| 217 | + | canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null, | |
| 218 | + | canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null, | |
| 219 | + | colors: colors(), | |
| 374 | 220 | }; | |
| 375 | − | var resizing = 0; | |
| 376 | − | window.addEventListener("resize", function () { | |
| 377 | − | clearTimeout(resizing); | |
| 378 | − | resizing = setTimeout(function () { | |
| 379 | − | control("maximized").then(drawMaximized, function () {}); | |
| 380 | − | }, 120); | |
| 381 | − | }); | |
| 382 | − | control("maximized").then(drawMaximized, function () {}); | |
| 383 | − | } | |
| 384 | − | var close = button("caption", "Close", function () { | |
| 385 | − | quietly(control("close")); | |
| 386 | − | }, "close"); | |
| 387 | − | close.innerHTML = ICONS.close; | |
| 388 | − | right.append(close); | |
| 389 | − | } | |
| 221 | + | var text = JSON.stringify(page); | |
| 222 | + | if (text === sent) return; | |
| 223 | + | sent = text; | |
| 224 | + | invoke("title_bar_page", { page: page }).catch(function () {}); | |
| 225 | + | }, 30); | |
| 226 | + | }; | |
| 390 | 227 | ||
| 391 | − | // Dragging the bar moves the window; a double click maximizes it, as a system's title bar does. | |
| 392 | − | bar.addEventListener("mousedown", function (event) { | |
| 393 | − | if (event.button !== 0 || event.target.closest("button")) return; | |
| 394 | − | if (event.detail === 2) { | |
| 395 | − | if (MAIN) control("maximize").then(drawMaximized || function () {}, function () {}); | |
| 396 | − | return; | |
| 397 | − | } | |
| 398 | − | quietly(control("drag")); | |
| 228 | + | document.addEventListener("DOMContentLoaded", function () { | |
| 229 | + | report(); | |
| 230 | + | if (document.head) new MutationObserver(report).observe(document.head, { subtree: true, childList: true, characterData: true }); | |
| 231 | + | // The site's Light, Dark or Auto (data-theme on <html>), and the system's own. | |
| 232 | + | new MutationObserver(report).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] }); | |
| 399 | 233 | }); | |
| 234 | + | window.addEventListener("load", report); | |
| 235 | + | window.addEventListener("pageshow", report); | |
| 236 | + | window.addEventListener("popstate", report); | |
| 237 | + | if (window.matchMedia) { | |
| 238 | + | var scheme = window.matchMedia("(prefers-color-scheme: dark)"); | |
| 239 | + | if (typeof scheme.addEventListener === "function") scheme.addEventListener("change", report); | |
| 240 | + | } | |
| 241 | + | if (window.navigation && typeof window.navigation.addEventListener === "function") { | |
| 242 | + | window.navigation.addEventListener("currententrychange", report); | |
| 243 | + | } | |
| 400 | 244 | ||
| 401 | − | // In the page from as early as there is a page, and back if a page's script takes it out. | |
| 402 | − | function attach() { | |
| 403 | − | if (host.parentNode !== document.documentElement) document.documentElement.appendChild(host); | |
| 245 | + | // The bar's search was clicked: the site's search opens. | |
| 246 | + | var events = window.__TAURI__ && window.__TAURI__.event; | |
| 247 | + | if (events && typeof events.listen === "function") { | |
| 248 | + | events | |
| 249 | + | .listen("g1t-search", function () { | |
| 250 | + | if (search) search.open(); | |
| 251 | + | }) | |
| 252 | + | .catch(function () {}); | |
| 404 | 253 | } | |
| 405 | − | whenThere( | |
| 406 | − | function () { | |
| 407 | − | return document.body; | |
| 408 | − | }, | |
| 409 | − | function () { | |
| 410 | − | attach(); | |
| 411 | − | new MutationObserver(attach).observe(document.documentElement, { childList: true }); | |
| 412 | − | }, | |
| 413 | − | ); | |
| 414 | 254 | })(); | |
| 13 | 13 | use url::Url; | |
| 14 | 14 | ||
| 15 | 15 | use crate::settings::{self, Settings}; | |
| 16 | − | use crate::{WINDOW, badge, config, updates}; | |
| 16 | + | use crate::{badge, config, frame, updates}; | |
| 17 | 17 | ||
| 18 | 18 | const SCRIPT: &str = include_str!("bridge.js"); | |
| 19 | 19 | ||
| 20 | − | /// The bridge, run at the start of every page a window loads: `window` is | |
| 21 | − | /// its label, and `custom` whether it draws g1t's title bar (frame.rs). | |
| 22 | − | pub fn script(window: &str, custom: bool) -> String { | |
| 20 | + | /// The bridge, run at the start of every page the site's web view loads: | |
| 21 | + | /// `custom` is whether g1t's title bar is above it (frame.rs). | |
| 22 | + | pub fn script(custom: bool) -> String { | |
| 23 | 23 | SCRIPT | |
| 24 | − | .replace("__WINDOW__", window) | |
| 25 | 24 | .replace("__FRAME__", if custom { "custom" } else { "system" }) | |
| 26 | − | .replace("\"__HEIGHT__\"", &crate::frame::HEIGHT.to_string()) | |
| 27 | 25 | .replace("__VERSION__", env!("CARGO_PKG_VERSION")) | |
| 28 | 26 | .replace("__PLATFORM__", std::env::consts::OS) | |
| 29 | 27 | .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON")) | |
| ⋯ | |||
| 65 | 63 | from_site(app, webview).or_else(|_| from_launcher(webview)) | |
| 66 | 64 | } | |
| 67 | 65 | ||
| 68 | − | /// The page asking is one the title bar is drawn on: the site's, the | |
| 69 | − | /// app's own, or GitHub's while signing in. | |
| 70 | − | fn from_frame(app: &AppHandle, webview: &Webview) -> Result<(), String> { | |
| 66 | + | /// The page asking is the one under the title bar, whichever it is: the | |
| 67 | + | /// site's, the launcher, or GitHub's while signing in. | |
| 68 | + | fn from_page(app: &AppHandle, webview: &Webview) -> Result<(), String> { | |
| 71 | 69 | let url = caller(webview)?; | |
| 72 | − | if config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url) { | |
| 73 | − | Ok(()) | |
| 74 | − | } else { | |
| 75 | − | Err("only g1t's own pages may ask that".into()) | |
| 76 | − | } | |
| 70 | + | let shown = config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url); | |
| 71 | + | if webview.label() == frame::SITE && shown { Ok(()) } else { Err("only the page in the window may ask that".into()) } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The page asking is the title bar's, bundled with the app. | |
| 75 | + | fn from_bar(webview: &Webview) -> Result<(), String> { | |
| 76 | + | from_launcher(webview)?; | |
| 77 | + | if webview.label() == frame::BAR { Ok(()) } else { Err("only the title bar may ask that".into()) } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// The page asking has window buttons: the title bar, or the settings window's own. | |
| 81 | + | fn from_frame(webview: &Webview) -> Result<(), String> { | |
| 82 | + | from_bar(webview).or_else(|_| from_settings(webview)) | |
| 77 | 83 | } | |
| 78 | 84 | ||
| 79 | 85 | #[tauri::command] | |
| ⋯ | |||
| 93 | 99 | pub fn notify(app: AppHandle, webview: Webview, title: String, body: String) -> Result<(), String> { | |
| 94 | 100 | from_site(&app, &webview)?; | |
| 95 | 101 | let settings = config::settings(&app); | |
| 96 | − | if let Some(window) = app.get_webview_window(WINDOW).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) { | |
| 102 | + | if let Some(window) = crate::frame::window(&app).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) { | |
| 97 | 103 | let _ = window.request_user_attention(Some(UserAttentionType::Informational)); | |
| 98 | 104 | } | |
| 99 | 105 | if !settings.notifications { | |
| ⋯ | |||
| 158 | 164 | /// `drag`, or `maximized` to ask. Answers whether the window is maximized. | |
| 159 | 165 | #[tauri::command] | |
| 160 | 166 | pub fn window_control(app: AppHandle, webview: Webview, action: String) -> Result<bool, String> { | |
| 161 | − | from_frame(&app, &webview)?; | |
| 162 | − | let window = app.get_webview_window(webview.label()).ok_or("no such window")?; | |
| 163 | − | crate::frame::control(&app, &window, &action) | |
| 167 | + | from_frame(&webview)?; | |
| 168 | + | crate::frame::control(&app, &webview.window(), &action) | |
| 164 | 169 | } | |
| 165 | 170 | ||
| 166 | 171 | /// The title bar's menu button: the app's menu, at that place in the window. | |
| 167 | 172 | #[tauri::command] | |
| 168 | − | pub fn window_menu(app: AppHandle, webview: Webview, x: f64, y: f64) -> Result<(), String> { | |
| 169 | − | from_frame(&app, &webview)?; | |
| 170 | − | let window = app.get_webview_window(WINDOW).filter(|_| webview.label() == WINDOW).ok_or("only the main window has a menu")?; | |
| 171 | − | crate::frame::menu(&window, x, y) | |
| 173 | + | pub fn window_menu(webview: Webview, x: f64, y: f64) -> Result<(), String> { | |
| 174 | + | from_bar(&webview)?; | |
| 175 | + | crate::frame::menu(&webview.window(), x, y) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The page under the title bar says what it is: its title, its search, | |
| 179 | + | /// whether there is a way back, its colours (frame.rs). | |
| 180 | + | #[tauri::command] | |
| 181 | + | pub fn title_bar_page(app: AppHandle, webview: Webview, page: frame::Page) -> Result<(), String> { | |
| 182 | + | from_page(&app, &webview)?; | |
| 183 | + | frame::page(&app, page); | |
| 184 | + | Ok(()) | |
| 172 | 185 | } | |
| 173 | 186 | ||
| 187 | + | /// What the title bar shows now: it asks as it loads. | |
| 188 | + | #[tauri::command] | |
| 189 | + | pub fn title_bar_current(app: AppHandle, webview: Webview) -> Result<frame::Page, String> { | |
| 190 | + | from_bar(&webview)?; | |
| 191 | + | Ok(frame::current(&app)) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// A title bar button that acts on the page: `back`, `forward`, `search`, `focus`. | |
| 195 | + | #[tauri::command] | |
| 196 | + | pub fn title_bar_act(app: AppHandle, webview: Webview, action: String) -> Result<(), String> { | |
| 197 | + | from_bar(&webview)?; | |
| 198 | + | frame::act(&app, &action) | |
| 199 | + | } | |
| 200 | + | ||
| 174 | 201 | /// Restarts g1t, installing a downloaded update on the way: the settings | |
| 175 | 202 | /// window offers it after a change that needs one. | |
| 176 | 203 | #[tauri::command] | |
| ⋯ | |||
| 236 | 263 | mod tests { | |
| 237 | 264 | #[test] | |
| 238 | 265 | fn the_bridge_has_nothing_left_to_fill_in() { | |
| 239 | − | let script = super::script("main", true); | |
| 240 | − | for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__WINDOW__", "__FRAME__", "__HEIGHT__"] { | |
| 266 | + | let script = super::script(true); | |
| 267 | + | for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__FRAME__"] { | |
| 241 | 268 | assert!(!script.contains(placeholder), "{placeholder} is left in the bridge"); | |
| 242 | 269 | } | |
| 243 | − | assert!(script.contains(r#"var HEIGHT = 36;"#) && script.contains(r#"var FRAME = "custom";"#)); | |
| 244 | − | assert!(super::script("settings", false).contains(r#"var FRAME = "system";"#)); | |
| 270 | + | assert!(script.contains(r#"var FRAME = "custom";"#)); | |
| 271 | + | assert!(super::script(false).contains(r#"var FRAME = "system";"#)); | |
| 245 | 272 | assert!(script.contains(r#"var OUTSIDE = ["/pricing""#)); | |
| 246 | 273 | } | |
| 247 | 274 | } | |
| 165 | 165 | "allow-open-url", | |
| 166 | 166 | "allow-check-for-updates", | |
| 167 | 167 | "allow-open-settings", | |
| 168 | − | "allow-window-control", | |
| 169 | − | "allow-window-menu", | |
| 168 | + | "allow-title-bar-page", | |
| 170 | 169 | ]; | |
| 171 | 170 | ||
| 172 | − | /// What GitHub's pages may call while someone signs in: the title bar's buttons. | |
| 173 | − | const FRAME: &[&str] = &["allow-window-control", "allow-window-menu"]; | |
| 171 | + | /// What GitHub's pages may call while someone signs in: telling the title bar their title. | |
| 172 | + | const FRAME: &[&str] = &["allow-title-bar-page"]; | |
| 174 | 173 | ||
| 175 | 174 | /// Lets the server's pages call the app: a capability for its origin, | |
| 176 | 175 | /// added once. Pages from anywhere else get no IPC at all, and bridge.rs | |
| ⋯ | |||
| 187 | 186 | site = site.permission(*permission); | |
| 188 | 187 | } | |
| 189 | 188 | app.add_capability(site)?; | |
| 190 | − | // Signing in with GitHub: its pages get the title bar's buttons, and nothing else. | |
| 189 | + | // Signing in with GitHub: its pages tell the title bar what they are, and nothing else. | |
| 191 | 190 | if allowed.is_empty() { | |
| 192 | 191 | let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string()); | |
| 193 | 192 | for permission in FRAME { | |
| 2 | 2 | //! do, so the window is all g1t and looks the same on every system: on | |
| 3 | 3 | //! Windows and Linux the system's frame goes and the bar has the menu | |
| 4 | 4 | //! button, back and forward, search, and minimize, maximize and close; on | |
| 5 | − | //! macOS the bar sits under the window's own traffic lights. The bar | |
| 6 | − | //! itself is bridge.js's, drawn on every page the window shows, the | |
| 7 | − | //! site's, the launcher's, and GitHub's while signing in; the site makes | |
| 8 | − | //! room for it (`--frame-top`, apps/web/app/app.css). Settings can give | |
| 9 | − | //! the window back its system frame, and the menu bar with it, from the | |
| 10 | − | //! next start: the bar is a script each window gets as it opens. | |
| 5 | + | //! macOS the bar sits under the window's own traffic lights. | |
| 6 | + | //! | |
| 7 | + | //! The main window holds two web views, one above the other: the bar, a | |
| 8 | + | //! page bundled with the app (`launcher/titlebar.html`), across the top, | |
| 9 | + | //! and the site under it. The site is never drawn under the bar, so no page | |
| 10 | + | //! of it, or of GitHub's while signing in, needs to know the bar is there. | |
| 11 | + | //! What the bar shows of the page (its title, its search, whether there is | |
| 12 | + | //! a way back, its colours) the page's bridge reports (`page`), and what | |
| 13 | + | //! the bar is asked to do comes back to the page through the app (`act`). | |
| 14 | + | //! | |
| 15 | + | //! Settings can give the window back its system frame, and the menu bar | |
| 16 | + | //! with it, from the next start; the site then has the window to itself. | |
| 17 | + | ||
| 18 | + | use std::sync::Mutex; | |
| 11 | 19 | ||
| 12 | − | use tauri::{AppHandle, LogicalPosition, Manager, Runtime, WebviewWindow, WebviewWindowBuilder}; | |
| 20 | + | use serde::{Deserialize, Serialize}; | |
| 21 | + | use tauri::webview::WebviewBuilder; | |
| 22 | + | use tauri::window::{Color, WindowBuilder}; | |
| 23 | + | use tauri::{AppHandle, Emitter, EventTarget, LogicalPosition, LogicalSize, Manager, Runtime, Webview, WebviewUrl, WebviewWindowBuilder, Window}; | |
| 13 | 24 | ||
| 14 | 25 | use crate::{WINDOW, config, tray}; | |
| 15 | 26 | ||
| 16 | − | /// The bar's height, in CSS pixels; bridge.js draws it this tall. | |
| 27 | + | /// The bar's height, in CSS pixels. | |
| 17 | 28 | pub const HEIGHT: f64 = 36.0; | |
| 18 | 29 | ||
| 30 | + | /// The site's web view, in the main window. | |
| 31 | + | pub const SITE: &str = "site"; | |
| 32 | + | /// The bar's web view, across the top of the main window. | |
| 33 | + | pub const BAR: &str = "titlebar"; | |
| 34 | + | ||
| 35 | + | /// What the bar listens for: what to show (`Page`). | |
| 36 | + | pub const PAGE_EVENT: &str = "g1t-title-bar"; | |
| 37 | + | /// What the site listens for: the bar's search was clicked. | |
| 38 | + | pub const SEARCH_EVENT: &str = "g1t-search"; | |
| 39 | + | ||
| 40 | + | /// g1t's rail, dark, which the window is painted while a page loads. | |
| 41 | + | const RAIL: Color = Color(5, 5, 7, 255); | |
| 42 | + | ||
| 19 | 43 | /// Whether g1t draws its own title bar, as the settings say now. | |
| 20 | 44 | pub fn custom(app: &AppHandle) -> bool { | |
| 21 | 45 | !config::settings(app).system_title_bar | |
| ⋯ | |||
| 29 | 53 | *STARTED_CUSTOM.get_or_init(|| custom(app)) | |
| 30 | 54 | } | |
| 31 | 55 | ||
| 32 | − | /// A window with g1t's title bar, or the system's. | |
| 33 | − | pub fn style<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> { | |
| 56 | + | /// The main window, holding the bar and the site. | |
| 57 | + | pub fn window(app: &AppHandle) -> Option<Window> { | |
| 58 | + | app.get_window(WINDOW) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// The site's web view. | |
| 62 | + | pub fn site(app: &AppHandle) -> Option<Webview> { | |
| 63 | + | app.get_webview(SITE) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// The main window, before its web views: g1t's frame, or the system's. | |
| 67 | + | pub fn main_window<'a, R: Runtime, M: Manager<R>>(app: &'a M, custom: bool) -> tauri::Result<WindowBuilder<'a, R, M>> { | |
| 68 | + | #[cfg(target_os = "macos")] | |
| 69 | + | let builder = if custom { | |
| 70 | + | // The traffic lights stay, over the bar and centred in it. | |
| 71 | + | let config = tauri::utils::config::WindowConfig { | |
| 72 | + | label: WINDOW.into(), | |
| 73 | + | traffic_light_position: Some(tauri::utils::config::LogicalPosition { x: 14.0, y: (HEIGHT - 14.0) / 2.0 }), | |
| 74 | + | ..Default::default() | |
| 75 | + | }; | |
| 76 | + | WindowBuilder::from_config(app, &config)?.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true) | |
| 77 | + | } else { | |
| 78 | + | WindowBuilder::new(app, WINDOW) | |
| 79 | + | }; | |
| 80 | + | #[cfg(not(target_os = "macos"))] | |
| 81 | + | let builder = WindowBuilder::new(app, WINDOW).decorations(!custom); | |
| 82 | + | Ok(builder.title("g1t").inner_size(1280.0, 840.0).min_inner_size(720.0, 480.0).visible(false)) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The bar's and the site's web views, in the main window, as tall as the window leaves them. | |
| 86 | + | pub fn fill(window: &Window, custom: bool, site: WebviewBuilder<tauri::Wry>) -> tauri::Result<()> { | |
| 87 | + | let size = logical_size(window); | |
| 88 | + | let top = if custom { HEIGHT } else { 0.0 }; | |
| 89 | + | if custom { | |
| 90 | + | window.add_child( | |
| 91 | + | WebviewBuilder::new(BAR, WebviewUrl::App("titlebar.html".into())).background_color(RAIL).on_navigation(config::is_launcher), | |
| 92 | + | LogicalPosition::new(0.0, 0.0), | |
| 93 | + | LogicalSize::new(size.width, HEIGHT), | |
| 94 | + | )?; | |
| 95 | + | } | |
| 96 | + | window.add_child(site.background_color(RAIL), LogicalPosition::new(0.0, top), LogicalSize::new(size.width, (size.height - top).max(0.0)))?; | |
| 97 | + | #[cfg(target_os = "linux")] | |
| 98 | + | if custom { | |
| 99 | + | pack_bar(window); | |
| 100 | + | } | |
| 101 | + | Ok(()) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// On Linux the web views sit in a GTK box, one under the other, which | |
| 105 | + | /// shares the window between them equally whatever size they are given: | |
| 106 | + | /// the bar is told to keep its height, and the site takes the rest. | |
| 107 | + | #[cfg(target_os = "linux")] | |
| 108 | + | fn pack_bar(window: &Window) { | |
| 109 | + | use gtk::prelude::*; | |
| 110 | + | let Ok(vbox) = window.default_vbox() else { return }; | |
| 111 | + | // The bar is the first web view added; the menu bar, hidden, is not one. | |
| 112 | + | if let Some(bar) = vbox.children().into_iter().find(|child| child.type_().name() == "WebKitWebView") { | |
| 113 | + | vbox.set_child_packing(&bar, false, true, 0, gtk::PackType::Start); | |
| 114 | + | bar.set_size_request(-1, HEIGHT as i32); | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn logical_size(window: &Window) -> LogicalSize<f64> { | |
| 119 | + | let scale = window.scale_factor().unwrap_or(1.0); | |
| 120 | + | window.inner_size().map(|size| size.to_logical(scale)).unwrap_or(LogicalSize::new(1280.0, 840.0)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// The window changed size: the bar keeps its height, and the site takes the rest. | |
| 124 | + | pub fn layout(window: &Window) { | |
| 125 | + | let app = window.app_handle(); | |
| 126 | + | let size = logical_size(window); | |
| 127 | + | let bar = app.get_webview(BAR); | |
| 128 | + | let top = if bar.is_some() { HEIGHT } else { 0.0 }; | |
| 129 | + | if let Some(bar) = bar { | |
| 130 | + | let _ = bar.set_position(LogicalPosition::new(0.0, 0.0)); | |
| 131 | + | let _ = bar.set_size(LogicalSize::new(size.width, HEIGHT)); | |
| 132 | + | } | |
| 133 | + | if let Some(site) = site(app) { | |
| 134 | + | let _ = site.set_position(LogicalPosition::new(0.0, top)); | |
| 135 | + | let _ = site.set_size(LogicalSize::new(size.width, (size.height - top).max(0.0))); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// The settings window: g1t's frame, or the system's. Its page draws its | |
| 140 | + | /// own bar (launcher/settings.html), being g1t's own. | |
| 141 | + | pub fn dialog<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> { | |
| 34 | 142 | if !custom { | |
| 35 | 143 | return builder; | |
| 36 | 144 | } | |
| 37 | 145 | #[cfg(target_os = "macos")] | |
| 38 | 146 | { | |
| 39 | − | // The traffic lights stay, over the bar and centred in it. | |
| 40 | 147 | builder | |
| 41 | 148 | .title_bar_style(tauri::TitleBarStyle::Overlay) | |
| 42 | 149 | .hidden_title(true) | |
| ⋯ | |||
| 51 | 158 | /// The main window's menu bar: shown under the system's frame, and kept | |
| 52 | 159 | /// hidden under g1t's, whose menu button opens the same menu (`menu`) and | |
| 53 | 160 | /// whose shortcuts still work. | |
| 54 | − | pub fn menu_bar(window: &WebviewWindow, custom: bool) { | |
| 161 | + | pub fn menu_bar(window: &Window, custom: bool) { | |
| 55 | 162 | let _ = if custom { window.hide_menu() } else { window.show_menu() }; | |
| 56 | 163 | // GTK shows everything in a window each time the window is shown, the | |
| 57 | 164 | // hidden menu bar too, unless the menu bar is told to sit that out. | |
| ⋯ | |||
| 64 | 171 | } | |
| 65 | 172 | } | |
| 66 | 173 | ||
| 67 | − | /// What a button on the bar asks of the window it is in. | |
| 68 | − | pub fn control(app: &AppHandle, window: &WebviewWindow, action: &str) -> Result<bool, String> { | |
| 174 | + | /// What a window button asks of the window it is in: `minimize`, | |
| 175 | + | /// `maximize` (which toggles), `close`, `drag`, or `maximized` to ask. | |
| 176 | + | /// Answers whether the window is maximized. | |
| 177 | + | pub fn control(app: &AppHandle, window: &Window, action: &str) -> Result<bool, String> { | |
| 69 | 178 | let main = window.label() == WINDOW; | |
| 70 | 179 | match action { | |
| 71 | 180 | "minimize" => window.minimize().map_err(|error| error.to_string())?, | |
| ⋯ | |||
| 83 | 192 | } | |
| 84 | 193 | ||
| 85 | 194 | /// The menu button: the window's menu, under the button. | |
| 86 | − | pub fn menu(window: &WebviewWindow, x: f64, y: f64) -> Result<(), String> { | |
| 195 | + | pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> { | |
| 87 | 196 | #[cfg(target_os = "macos")] | |
| 88 | 197 | let menu = window.app_handle().menu(); | |
| 89 | 198 | #[cfg(not(target_os = "macos"))] | |
| ⋯ | |||
| 91 | 200 | let menu = menu.ok_or("the window has no menu")?; | |
| 92 | 201 | window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string()) | |
| 93 | 202 | } | |
| 203 | + | ||
| 204 | + | /// The page's colours, as the bar takes them: CSS colours, resolved. | |
| 205 | + | #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] | |
| 206 | + | pub struct Colors { | |
| 207 | + | pub background: String, | |
| 208 | + | pub foreground: String, | |
| 209 | + | pub muted: String, | |
| 210 | + | pub accent: String, | |
| 211 | + | /// Whether the page is dark, for the bar's own hover shades. | |
| 212 | + | pub dark: bool, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// What the bar shows of the page in the window under it. | |
| 216 | + | #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] | |
| 217 | + | #[serde(rename_all = "camelCase")] | |
| 218 | + | pub struct Page { | |
| 219 | + | pub title: String, | |
| 220 | + | /// The site's search, by its label ("Search Flagon"), when it has handed one over. | |
| 221 | + | pub search: Option<String>, | |
| 222 | + | /// Whether there is a page to go back or forward to; absent where the web view cannot tell. | |
| 223 | + | pub can_go_back: Option<bool>, | |
| 224 | + | pub can_go_forward: Option<bool>, | |
| 225 | + | pub colors: Option<Colors>, | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// The page as last reported, kept for a bar that loads after it. | |
| 229 | + | #[derive(Default)] | |
| 230 | + | pub struct State(Mutex<Page>); | |
| 231 | + | ||
| 232 | + | /// The site's page reported what it is: the bar is told. | |
| 233 | + | pub fn page(app: &AppHandle, mut page: Page) { | |
| 234 | + | page.title.truncate(300); | |
| 235 | + | if let Some(search) = page.search.as_mut() { | |
| 236 | + | search.truncate(120); | |
| 237 | + | } | |
| 238 | + | let state = app.state::<State>(); | |
| 239 | + | let mut kept = state.0.lock().unwrap(); | |
| 240 | + | // A page that knows no colours of its own keeps the last ones. | |
| 241 | + | if page.colors.is_none() { | |
| 242 | + | page.colors = kept.colors.clone(); | |
| 243 | + | } | |
| 244 | + | if *kept == page { | |
| 245 | + | return; | |
| 246 | + | } | |
| 247 | + | *kept = page.clone(); | |
| 248 | + | drop(kept); | |
| 249 | + | let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// What the bar shows now: it asks as it loads. | |
| 253 | + | pub fn current(app: &AppHandle) -> Page { | |
| 254 | + | app.state::<State>().0.lock().unwrap().clone() | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// A button on the bar that acts on the page: `back`, `forward`, `search`, | |
| 258 | + | /// or `focus`, which hands the keyboard back to the page after a click on | |
| 259 | + | /// the bar. | |
| 260 | + | pub fn act(app: &AppHandle, action: &str) -> Result<(), String> { | |
| 261 | + | let site = site(app).ok_or("there is no page")?; | |
| 262 | + | match action { | |
| 263 | + | "back" => site.eval("history.back()").map_err(|error| error.to_string())?, | |
| 264 | + | "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?, | |
| 265 | + | "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?, | |
| 266 | + | "focus" => {} | |
| 267 | + | other => return Err(format!("the bar cannot {other}")), | |
| 268 | + | } | |
| 269 | + | let _ = site.set_focus(); | |
| 270 | + | Ok(()) | |
| 271 | + | } | |
| 4 | 4 | //! started the app is read on the way up; one that arrives while it runs | |
| 5 | 5 | //! comes through the deep-link plugin, from a second launch too. | |
| 6 | 6 | ||
| 7 | − | use tauri::{AppHandle, Emitter, Manager}; | |
| 7 | + | use tauri::{AppHandle, Emitter, EventTarget}; | |
| 8 | 8 | use tauri_plugin_deep_link::DeepLinkExt; | |
| 9 | 9 | use url::Url; | |
| 10 | 10 | ||
| 11 | − | use crate::{WINDOW, config, tray}; | |
| 11 | + | use crate::{config, frame, tray}; | |
| 12 | 12 | ||
| 13 | 13 | /// What the page listens for (bridge.js). | |
| 14 | 14 | pub const EVENT: &str = "g1t-navigate"; | |
| ⋯ | |||
| 60 | 60 | pub fn go(app: &AppHandle, path: &str) { | |
| 61 | 61 | tray::show(app); | |
| 62 | 62 | if config::on_site(app) { | |
| 63 | − | let _ = app.emit_to(WINDOW, EVENT, path.to_string()); | |
| 63 | + | let _ = app.emit_to(EventTarget::webview(frame::SITE), EVENT, path.to_string()); | |
| 64 | 64 | return; | |
| 65 | 65 | } | |
| 66 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 67 | − | if let Ok(target) = config::server(app).join(path.trim_start_matches('/')) { | |
| 68 | − | let _ = window.navigate(target); | |
| 69 | − | } | |
| 66 | + | if let (Some(site), Ok(target)) = (frame::site(app), config::server(app).join(path.trim_start_matches('/'))) { | |
| 67 | + | let _ = site.navigate(target); | |
| 70 | 68 | } | |
| 71 | 69 | } | |
| 72 | 70 | ||
| 23 | 23 | mod tray; | |
| 24 | 24 | mod updates; | |
| 25 | 25 | ||
| 26 | − | use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent}; | |
| 26 | + | use tauri::webview::WebviewBuilder; | |
| 27 | + | use tauri::{RunEvent, WebviewUrl, WindowEvent}; | |
| 27 | 28 | use tauri_plugin_window_state::{StateFlags, WindowExt}; | |
| 28 | 29 | ||
| 29 | 30 | /// What the window keeps between launches: its size, place, and whether it | |
| ⋯ | |||
| 58 | 59 | // Opening at sign-in, which settings turn on; the argument tells that start from someone's. | |
| 59 | 60 | .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![settings::AT_LOGIN]))) | |
| 60 | 61 | .manage(config::State::default()) | |
| 62 | + | .manage(frame::State::default()) | |
| 61 | 63 | .invoke_handler(tauri::generate_handler![ | |
| 62 | 64 | bridge::desktop_info, | |
| 63 | 65 | bridge::notify, | |
| ⋯ | |||
| 75 | 77 | bridge::restart, | |
| 76 | 78 | bridge::window_control, | |
| 77 | 79 | bridge::window_menu, | |
| 80 | + | bridge::title_bar_page, | |
| 81 | + | bridge::title_bar_current, | |
| 82 | + | bridge::title_bar_act, | |
| 78 | 83 | ]) | |
| 79 | 84 | .on_menu_event(|app, event| menu::handle(app, event.id().as_ref())) | |
| 80 | 85 | .setup(|app| { | |
| ⋯ | |||
| 85 | 90 | let hidden = settings::starts_hidden(&handle); | |
| 86 | 91 | let prefs = config::settings(&handle); | |
| 87 | 92 | let custom = frame::started(&handle); | |
| 88 | − | let builder = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into())) | |
| 89 | − | .title("g1t") | |
| 90 | − | .inner_size(1280.0, 840.0) | |
| 91 | − | .min_inner_size(720.0, 480.0) | |
| 92 | − | .visible(false) | |
| 93 | − | .initialization_script(bridge::script(WINDOW, custom)) | |
| 93 | + | // The site's web view: the launcher first, which goes on to the server. | |
| 94 | + | let site = WebviewBuilder::new(frame::SITE, WebviewUrl::App("index.html".into())) | |
| 95 | + | .initialization_script(bridge::script(custom)) | |
| 94 | 96 | .on_navigation({ | |
| 95 | 97 | let handle = handle.clone(); | |
| 96 | 98 | move |url| navigation::allowed(&handle, url) | |
| ⋯ | |||
| 99 | 101 | let handle = handle.clone(); | |
| 100 | 102 | move |_webview, payload| config::at(&handle, payload.url().clone()) | |
| 101 | 103 | }); | |
| 104 | + | let builder = frame::main_window(app, custom)?; | |
| 102 | 105 | // macOS has one menu bar, the app's; elsewhere the menu is the window's own. | |
| 103 | 106 | #[cfg(target_os = "macos")] | |
| 104 | 107 | app.set_menu(menu::build(&handle)?)?; | |
| 105 | 108 | #[cfg(not(target_os = "macos"))] | |
| 106 | 109 | let builder = builder.menu(menu::build(&handle)?); | |
| 107 | − | let window = frame::style(builder, custom).build()?; | |
| 110 | + | let window = builder.build()?; | |
| 108 | 111 | // Opened at sign-in, g1t waits in the tray; otherwise the window comes up where it was. | |
| 109 | 112 | let _ = window.restore_state(KEPT - StateFlags::VISIBLE); | |
| 113 | + | frame::fill(&window, custom, site)?; | |
| 114 | + | frame::layout(&window); | |
| 110 | 115 | #[cfg(not(target_os = "macos"))] | |
| 111 | 116 | frame::menu_bar(&window, custom); | |
| 112 | 117 | if !hidden { | |
| 113 | 118 | let _ = window.show(); | |
| 119 | + | if let Some(site) = frame::site(&handle) { | |
| 120 | + | let _ = site.set_focus(); | |
| 121 | + | } | |
| 114 | 122 | } | |
| 115 | − | if prefs.zoom != 1.0 { | |
| 116 | − | let _ = window.set_zoom(prefs.zoom); | |
| 123 | + | if let Some(site) = frame::site(&handle).filter(|_| prefs.zoom != 1.0) { | |
| 124 | + | let _ = site.set_zoom(prefs.zoom); | |
| 117 | 125 | } | |
| 118 | − | // Closing the window keeps g1t running, for notifications, unless settings say it quits. | |
| 126 | + | // Closing the window keeps g1t running, for notifications, unless | |
| 127 | + | // settings say it quits. A new size gives the bar and the site theirs. | |
| 119 | 128 | window.on_window_event({ | |
| 120 | 129 | let handle = handle.clone(); | |
| 121 | − | move |event| { | |
| 122 | − | if let WindowEvent::CloseRequested { api, .. } = event { | |
| 130 | + | let window = window.clone(); | |
| 131 | + | move |event| match event { | |
| 132 | + | WindowEvent::CloseRequested { api, .. } => { | |
| 123 | 133 | api.prevent_close(); | |
| 124 | 134 | tray::close(&handle); | |
| 125 | 135 | } | |
| 136 | + | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => frame::layout(&window), | |
| 137 | + | _ => {} | |
| 126 | 138 | } | |
| 127 | 139 | }); | |
| 128 | 140 | tray::install(&handle)?; | |
| 5 | 5 | //! every menu event, from either, comes to `handle`. | |
| 6 | 6 | ||
| 7 | 7 | use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; | |
| 8 | − | use tauri::{AppHandle, Manager, Wry}; | |
| 8 | + | use tauri::{AppHandle, Wry}; | |
| 9 | 9 | use tauri_plugin_opener::OpenerExt; | |
| 10 | 10 | ||
| 11 | − | use crate::{WINDOW, links, settings, tray, updates}; | |
| 11 | + | use crate::{links, settings, tray, updates}; | |
| 12 | 12 | ||
| 13 | 13 | const DOCS: &str = "https://docs.g1t.sh/guides/desktop/"; | |
| 14 | 14 | const STATUS: &str = "https://status.g1t.sh/"; | |
| ⋯ | |||
| 130 | 130 | ||
| 131 | 131 | /// Runs the script on the main window's page. | |
| 132 | 132 | fn eval(app: &AppHandle, script: &str) { | |
| 133 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 134 | − | let _ = window.eval(script); | |
| 133 | + | if let Some(site) = crate::frame::site(app) { | |
| 134 | + | let _ = site.eval(script); | |
| 135 | 135 | } | |
| 136 | 136 | } | |
| 137 | 137 | ||
| ⋯ | |||
| 147 | 147 | "quit" => updates::quit(app), | |
| 148 | 148 | "close" => tray::close(app), | |
| 149 | 149 | "reload" => { | |
| 150 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 151 | − | let _ = window.reload(); | |
| 150 | + | if let Some(site) = crate::frame::site(app) { | |
| 151 | + | let _ = site.reload(); | |
| 152 | 152 | } | |
| 153 | 153 | } | |
| 154 | 154 | "zoom-in" => settings::zoom(app, 1), | |
| 10 | 10 | use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; | |
| 11 | 11 | use tauri_plugin_autostart::ManagerExt; | |
| 12 | 12 | ||
| 13 | − | use crate::{WINDOW, config}; | |
| 13 | + | use crate::config; | |
| 14 | 14 | ||
| 15 | 15 | /// The settings window's label. | |
| 16 | 16 | pub const SETTINGS: &str = "settings"; | |
| ⋯ | |||
| 74 | 74 | pub checked_at: Option<u64>, | |
| 75 | 75 | /// The title bar setting differs from the one the windows have: a restart applies it. | |
| 76 | 76 | pub title_bar_changed: bool, | |
| 77 | + | /// Whether this window draws g1t's own title bar, which the page then draws (settings.html). | |
| 78 | + | pub custom_frame: bool, | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 79 | 81 | pub fn view(app: &AppHandle) -> View { | |
| ⋯ | |||
| 86 | 88 | update_ready: crate::updates::ready(app), | |
| 87 | 89 | checked_at: crate::updates::checked_at(app), | |
| 88 | 90 | title_bar_changed: crate::frame::started(app) != crate::frame::custom(app), | |
| 91 | + | custom_frame: crate::frame::started(app), | |
| 89 | 92 | } | |
| 90 | 93 | } | |
| 91 | 94 | ||
| ⋯ | |||
| 94 | 97 | settings.zoom = Settings::clamp_zoom(settings.zoom); | |
| 95 | 98 | let before = config::settings(app); | |
| 96 | 99 | config::set_settings(app, settings.clone())?; | |
| 97 | − | if let Some(window) = app.get_webview_window(WINDOW).filter(|_| before.zoom != settings.zoom) { | |
| 98 | − | let _ = window.set_zoom(settings.zoom); | |
| 100 | + | if let Some(site) = crate::frame::site(app).filter(|_| before.zoom != settings.zoom) { | |
| 101 | + | let _ = site.set_zoom(settings.zoom); | |
| 99 | 102 | } | |
| 100 | 103 | if before.badge != settings.badge { | |
| 101 | 104 | crate::badge::refresh(app); | |
| ⋯ | |||
| 157 | 160 | let page = if check { "settings.html?check" } else { "settings.html" }; | |
| 158 | 161 | let custom = crate::frame::started(app); | |
| 159 | 162 | let builder = WebviewWindowBuilder::new(app, SETTINGS, WebviewUrl::App(page.into())) | |
| 160 | − | .title("g1t settings") | |
| 161 | − | .initialization_script(crate::bridge::script(SETTINGS, custom)); | |
| 162 | − | let built = crate::frame::style(builder, custom) | |
| 163 | + | .title("g1t settings"); | |
| 164 | + | let built = crate::frame::dialog(builder, custom) | |
| 163 | 165 | .inner_size(560.0, 680.0) | |
| 164 | 166 | .min_inner_size(440.0, 420.0) | |
| 165 | 167 | .maximizable(false) | |
| 8 | 8 | ||
| 9 | 9 | use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; | |
| 10 | 10 | use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; | |
| 11 | − | use tauri::{AppHandle, Manager, Wry}; | |
| 11 | + | use tauri::{AppHandle, Wry}; | |
| 12 | 12 | ||
| 13 | − | use crate::{WINDOW, config, updates}; | |
| 13 | + | use crate::{config, updates}; | |
| 14 | 14 | ||
| 15 | 15 | pub const TRAY: &str = "g1t"; | |
| 16 | 16 | ||
| ⋯ | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | pub fn show(app: &AppHandle) { | |
| 74 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 74 | + | if let Some(window) = crate::frame::window(app) { | |
| 75 | 75 | let _ = window.show(); | |
| 76 | 76 | let _ = window.unminimize(); | |
| 77 | 77 | let _ = window.set_focus(); | |
| 78 | 78 | } | |
| 79 | + | if let Some(site) = crate::frame::site(app) { | |
| 80 | + | let _ = site.set_focus(); | |
| 81 | + | } | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | /// The window was closed: it waits in the tray, or g1t quits, as settings say. | |
| ⋯ | |||
| 88 | 91 | } | |
| 89 | 92 | ||
| 90 | 93 | pub fn hide(app: &AppHandle) { | |
| 91 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 94 | + | if let Some(window) = crate::frame::window(app) { | |
| 92 | 95 | let _ = window.hide(); | |
| 93 | 96 | } | |
| 94 | 97 | } | |
| ⋯ | |||
| 106 | 109 | ||
| 107 | 110 | /// Takes the window to the launcher: `change` to choose another server. | |
| 108 | 111 | pub fn launcher(app: &AppHandle, query: &str) { | |
| 109 | − | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 110 | − | let _ = window.navigate(launcher_url(query)); | |
| 112 | + | if let Some(site) = crate::frame::site(app) { | |
| 113 | + | let _ = site.navigate(launcher_url(query)); | |
| 111 | 114 | show(app); | |
| 112 | 115 | } | |
| 113 | 116 | } | |
| 90 | 90 | ||
| 91 | 91 | Drag the bar to move the window, and double-click it to maximize or | |
| 92 | 92 | restore it. The bar is there on every page the window shows, signing in | |
| 93 | − | with GitHub included. | |
| 93 | + | with GitHub included, and the page sits below it, never under it. | |
| 94 | 94 | ||
| 95 | 95 | To have the system's title bar instead, turn on **Use the system's title | |
| 96 | 96 | bar** in [the settings](#settings). On Windows and Linux the menu bar |
| 372 | 372 | --topbar-h: calc(var(--frame-top) + 3.5rem); | |
| 373 | 373 | --page-h: calc(100dvh - var(--topbar-h) - var(--tabbar-h)); | |
| 374 | 374 | } | |
| 375 | − | /* | |
| 376 | − | * The desktop app's own title bar (apps/desktop, src/frame.rs), drawn over | |
| 377 | − | * the top of the window on <html data-titlebar>: what is above the page is | |
| 378 | − | * the bar, whatever the frame. --titlebar-h is for frames that do not | |
| 379 | − | * read --frame-top (the public header, the standalone frame). The bar | |
| 380 | − | * pushes any page down by itself; g1t's pages make room their own way. | |
| 381 | − | */ | |
| 382 | − | :root { | |
| 383 | − | --titlebar-h: 0px; | |
| 384 | − | } | |
| 385 | − | html[data-titlebar] { | |
| 386 | − | --titlebar-h: 36px; | |
| 387 | − | padding-top: 0; | |
| 388 | − | } | |
| 389 | − | html[data-titlebar] body { | |
| 390 | − | --frame-top: var(--titlebar-h); | |
| 391 | − | } | |
| 392 | 375 | @media (max-width: 767px) { | |
| 393 | 376 | body[data-frame="app"] { | |
| 394 | 377 | --frame-top: env(safe-area-inset-top); |
| 66 | 66 | const params = useParams(); | |
| 67 | 67 | const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null; | |
| 68 | 68 | return ( | |
| 69 | − | <header className="sticky top-(--titlebar-h) z-40 border-b border-line bg-surface/85 backdrop-blur"> | |
| 69 | + | <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur"> | |
| 70 | 70 | <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4"> | |
| 71 | 71 | <Link to="/" aria-label="g1t home" className="mr-2 flex"> | |
| 72 | 72 | <Logo /> |
| 19 | 19 | const terms = POLICIES.find((policy) => policy.slug === "terms"); | |
| 20 | 20 | const privacy = POLICIES.find((policy) => policy.slug === "privacy"); | |
| 21 | 21 | return ( | |
| 22 | − | <div className="flex min-h-dvh flex-col pt-[max(env(safe-area-inset-top),var(--titlebar-h))] pb-[env(safe-area-inset-bottom)]"> | |
| 22 | + | <div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]"> | |
| 23 | 23 | <Progress /> | |
| 24 | 24 | <div id="content" tabIndex={-1} {...leaving} className={`flex grow flex-col items-center px-4 pt-[max(2.5rem,9vh)] pb-10 outline-none ${leaving.className}`}> | |
| 25 | 25 | <Link to="/" aria-label="g1t" className="flex rounded-md p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"> |
| 74 | 74 | * // The search in the middle of the app's own title bar: its label, and | |
| 75 | 75 | * // what it opens. Returns what takes it away. | |
| 76 | 76 | * setSearch(label, open) { return () => {} }, | |
| 77 | − | * // How tall the app's own title bar is, 0 under the system's. The page | |
| 78 | − | * // also finds it as <html data-titlebar> and --titlebar-h (app.css). | |
| 77 | + | * // How tall the app's own title bar is, 0 under the system's. The bar | |
| 78 | + | * // is a web view of its own, above the page, never over it. | |
| 79 | 79 | * titleBarHeight: 36, | |
| 80 | 80 | * }; | |
| 81 | 81 | * |
| 443 | 443 | ) : ( | |
| 444 | 444 | <> | |
| 445 | 445 | <Progress /> | |
| 446 | − | {/* Under the desktop app's title bar, the header starts below it. */} | |
| 447 | − | <div aria-hidden="true" className="h-(--titlebar-h) shrink-0" /> | |
| 448 | 446 | <PublicHeader user={user} /> | |
| 449 | 447 | {banner} | |
| 450 | 448 | <div id="content" tabIndex={-1} {...leaving} className={`grow outline-none ${leaving.className}`}> |