From 7267d6c359b61c2b2e5336025b8b1725ee7494b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:33:13 +0000 Subject: [PATCH] design(browser): UI review and Compose prototypes of the redesigned pill Review of the shipped browser chrome (menu hierarchy, mute states, bare inputs, stock dialogs, Views/Compose parity) and a redesign: navigation capsule, page-action tiles, privacy card with plain-language consequences, a stateful collapsed handle, a first-class address editor with suggestions and paste-and-go, a floating find pill, a filterable console sheet, a three-choice permission prompt, origin-badged page dialogs, and a page-info sheet with in-place permission choices and a confirmed clear. Stateless composables in commonsUI (browser/ui/pill) with previews; BrowserPillRenderTest renders every screen offscreen in both themes to commonsUI/build/browser-pill/. Adds Cookie, NorthWest and ContentPasteGo glyphs to the Material Symbols subset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-ui-review.md | 236 ++++++++ .../font/material_symbols_outlined.ttf | Bin 498440 -> 504348 bytes .../composeResources/values/strings.xml | 100 ++++ .../commons/browser/ui/pill/AddressEditor.kt | 219 ++++++++ .../commons/browser/ui/pill/BrowserPill.kt | 503 ++++++++++++++++++ .../browser/ui/pill/BrowserPillModel.kt | 90 ++++ .../browser/ui/pill/BrowserPillPreviews.kt | 240 +++++++++ .../commons/browser/ui/pill/ConsoleSheet.kt | 231 ++++++++ .../commons/browser/ui/pill/FindInPagePill.kt | 135 +++++ .../commons/browser/ui/pill/PageSheets.kt | 476 +++++++++++++++++ .../commons/browser/ui/pill/PillComponents.kt | 348 ++++++++++++ .../commons/browser/ui/pill/PillIcons.kt | 215 ++++++++ .../commons/icons/symbols/MaterialSymbols.kt | 3 + .../browser/ui/pill/BrowserPillRenderTest.kt | 96 ++++ 14 files changed, 2892 insertions(+) create mode 100644 amethyst/plans/2026-09-26-browser-ui-review.md create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md new file mode 100644 index 0000000000..58d0284e59 --- /dev/null +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -0,0 +1,236 @@ +# Browser surfaces: UI review and redesign + +Status: **review + Compose prototypes**. The prototypes live in +`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered +offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to +`commonsUI/build/browser-pill/`. None of it is wired into the app yet (see §5). + +Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about +how it looks and feels. + +## 1. What's wrong with what shipped + +Reviewed against the code as merged: `TopControlSheet`, `NappletControlSheet`, `BottomConsoleSheet`, +`NappletConsolePanel`, `BrowserFindBar`, `EmbeddedFindBar`, `EmbeddedPageDialogs`, `BrowserJsDialogs` +and the full-screen permission / page-info `AlertDialog`s. + +**Top pill** +1. **It's a settings list, not a menu.** Up to 15 rows of equal weight in one scrolling column. Share, + copy and find (used daily) look exactly like desktop site and open-in-another-browser (used rarely). + Chrome solves this with an icon strip plus a short list; we copied the list and made it longer. +2. **No visual hierarchy.** Section labels are 12sp caps you don't notice. Every row is the same 44dp + icon + label. Nothing groups. +3. **Security state is a word, not a signal.** "Not secure" and "Onion-routed over Tor" are the same + grey 13sp text. Plain HTTP isn't warned about, and Tor, the thing Amethyst does that Chrome doesn't, + isn't celebrated. +4. **Three rows are just switches** (Tor, desktop site, console). Each is a full-width row whose only + content is the switch. They say what they are, not what they mean ("Loads over Tor" vs "the site + can't see your IP"). +5. **The text-size stepper** is squeezed into a list row: two 24dp buttons and a percentage, with no + preview and no way back to 100%. +6. **The collapsed grabber is mute.** It never shows loading, an insecure page, or console errors, so + there's nothing to invite the pull. + +**Inputs** + +7. **Edit address is a bare text field** dropped into the header. It has no clear button, no go button, + no suggestions (the launcher's omnibox has them), and no paste-and-go. +8. **Find in page** is a full-width sheet with a default `TextField`. The count is loose text, and "no + matches" has no state of its own. The native version draws a different bar. +9. **The JS prompt field** has no label. "Block dialogs from this page" is a third button squeezed in + beside Cancel/OK. Chrome uses a checkbox, because it's an option, not an answer. + +**Dialogs and sheets** + +10. **The permission prompt** is a stock `AlertDialog` with bullet text and only Allow / Block. There's + no "just this time", no icon for what's being asked, and nothing about the connection. +11. **Page info** is a paragraph in an `AlertDialog` with three crowded buttons. The site's permissions + aren't there, and Clear site data runs immediately, with no confirmation. +12. **The console** is a flat list with no filters (errors are buried in logs), no copy, and a count + that only shows inside the menu. + +**Parity** + +13. **Two renderers, two looks.** The full-screen pill is plain Views: framework `Switch`, framework + ripple, hand-rolled type sizes. Sharing the layout spec stopped the two drifting in content, but + they still don't look alike. `:nappletHost` already depends on `:commonsUI` (Compose), so the + full-screen window can host the same Compose pill in a `ComposeView`. + +## 2. Principles + +- **One tap for daily things, two for rare ones.** Navigation, star and share sit in a capsule. + Page actions are a grid of tiles. Privacy and developer settings are grouped cards below. +- **State is visible, even collapsed.** The security colour (error for HTTP, Tor accent for onion), + loading progress and console errors show on the grabber itself. +- **Inputs are first-class.** Every text input has: + - a real container and a leading icon that says what it is; + - a placeholder or label; + - clear and submit buttons; + - the right keyboard and IME action; + - an empty or error state. + + Choices are segmented buttons or switches that state their consequence, never a bare "on". +- **Explain consequences in plain words.** Say "The site can't see your IP address", not "Loads over + Tor". Say "Allow while visiting / Only this time / Don't allow", not "Allow / Block". +- **One implementation.** Stateless composables in `commonsUI`, driven by `BrowserChrome` (already + shared). The embedded tab, the full-screen window (through `ComposeView`) and a future desktop browser + all draw the same pixels. + +## 3. The redesign + +### 3.1 Collapsed handle + +A 48×24dp capsule at the top centre holding a 32dp bar. +- A 2dp progress line runs under the bar while the page loads. +- The bar takes the error colour on HTTP and the Tor accent when onion-routed. +- A 6dp error dot appears at the right edge when the console has errors. +- It keeps its current gestures: pull or tap to open. + +### 3.2 Expanded pill + +``` +╭────────────────────────────────────────────╮ +│ [P] Primal ( ✕ ) │ monogram tile · title · close (full screen) +│ ╭────────────────────────────────────────╮ │ +│ │ 🧅 primal.net ✎ │ │ origin field: tap edits the address +│ ╰────────────────────────────────────────╯ │ +│ ⓘ You left primal.net [ Back to app ] │ only when out of scope +│ ╭────────────────────────────────────────╮ │ +│ │ ← → ↻ ★ ⇪ │ │ navigation capsule +│ ╰────────────────────────────────────────╯ │ +│ ╭────╮ ╭────╮ ╭────╮ ╭────╮ │ +│ │ ⧉ │ │ 🔍 │ │ Aa │ │ ⌂+ │ │ page actions (tiles, 4 per row) +│ │Copy│ │Find│ │Text│ │Home│ │ +│ ╰────╯ ╰────╯ ╰────╯ ╰────╯ │ +│ ╭────╮ ╭────╮ ╭────╮ │ toggle tiles fill when on (Desktop) +│ │ 🖥 │ │ ⬈ │ │ ⛶ │ │ +│ ╰────╯ ╰────╯ ╰────╯ │ +│ A ━━━━━━●━━━━━━━ A 115% Reset │ appears when "Text" is on +│ ╭ Privacy ───────────────────────────────╮ │ +│ │ (🧅) Onion routing [ ● ] │ │ +│ │ The site can't see your IP │ │ +│ │ (⚙) Site settings › │ │ +│ │ Camera allowed · Location blocked │ │ +│ ╰────────────────────────────────────────╯ │ +│ >_ Console (3 errors) [ ] │ +╰────────────────────────────────────────────╯ +``` + +- **Origin field.** It looks like an input on purpose (the address is one tap away without an address + bar taking space): + - a pill-shaped container on `surfaceContainerHighest`; + - the security icon in its colour; + - the host in `titleSmall`; + - a trailing pencil. + + Tapping it opens the address editor (§3.3). Long-pressing copies the link. +- **Navigation capsule.** Five 48dp icon buttons on `surfaceContainerHigh`, `CircleShape`. Back and + forward dim when they can't be used. The star fills in `primary` when the page is pinned. Reload + becomes Stop, with a progress ring, while loading. +- **Tiles.** 72dp-tall rounded (16dp) cards on `surfaceContainer`, each with an icon and a two-line + `labelMedium` label. Toggle tiles (desktop site, text size open) switch to `secondaryContainer` with + a check badge. +- **Privacy card.** Grouped rows on one rounded container: + - each row has a leading icon in a 36dp tinted circle, a title, and a supporting line that states the + consequence; + - a trailing switch, or a chevron for navigation; + - site settings summarise camera / mic / location decisions inline. +- **Developer row.** The console with an error badge (`errorContainer` pill) and a switch. It's the + only developer item, so it gets no heading. +- **Sandboxed apps** use the same frame: + - the origin field reads "Sandboxed app" with a shield and doesn't edit; + - the capsule is reload + star; + - the tiles are find and text size; + - "What it can access" joins the privacy card. + +### 3.3 Address editor (first-class input) + +Replaces the origin field inside the pill: +- A 56dp pill field with the security/search icon leading, the URL selected, and trailing clear (✕) + and Go (a filled `primary` circle with an arrow). The keyboard is URI type with IME Go. +- Below it: + - a **Paste and go** chip when the clipboard holds a URL; + - then up to five suggestions (favorites first, then history, from `OmniboxSuggestions`), each with a + monogram, the title and the URL; + - each suggestion has a trailing ↖ that fills its URL into the field without going. +- Back or tapping outside collapses it to the origin field. + +### 3.4 Bottom: find in page and console + +- **Find pill.** A floating 56dp capsule, 12dp above the bottom edge, with shadow. It holds: + - a search icon; + - the field with placeholder "Find in page"; + - a match chip ("3 / 12", tonal; "No matches" in `errorContainer`); + - up/down, a divider, then close. + + The IME Search action jumps to the next match. +- **Console sheet.** A drag handle, then a title with filter chips "All 42 · Errors 3 · Warnings 5", + then Copy and Clear. Log rows have: + - a 3dp level-coloured stripe; + - the message in monospace; + - `file:line` muted on the right; + - long-press to copy. + + Errors show first when the Errors chip is on. + +### 3.5 Permission prompt + +A card sheet: +- the origin field (read-only) on top; +- 48dp tinted icon circles for each thing asked (camera / mic / location); +- a title in plain words ("Use your camera and microphone?") and one body line; +- when the site is on Tor and asks for camera or mic, a muted note: "Calls can reveal your IP address + even over Tor". + +Three stacked full-width buttons: **Allow while visiting** (filled, remembered), **Only this time** +(tonal, not remembered), **Don't allow** (text, remembered). + +### 3.6 JS dialogs + +A card with: +- the origin field as its header (so a page can't spoof Amethyst UI); +- the message in `bodyLarge`, scrolling past 40% of the screen; +- for `prompt`, an `OutlinedTextField` with a label, a clear button, autofocus, and IME Done that + submits; +- a **"Don't let this page show more dialogs"** checkbox (second dialog onward); +- right-aligned Cancel / OK. "Leave site?" uses a destructive-tinted Leave. + +### 3.7 Page info + +A sheet with: +- **Header:** monogram, host, and the full origin. +- **Connection:** rows with icons: encryption state (a warning tone for HTTP), then the route (Tor or + open web, with the same consequence line as the pill), then the certificate: issued to, issued by, + and expiry. +- **Permissions:** one row per camera / mic / location with a segmented button **Ask · Allow · + Block**, editable in place. +- **Cookies and site data:** an outlined destructive button, "Clear site data". The first tap turns it + into an inline confirmation ("Sign out of this site and delete its data? · Cancel · Clear") instead + of acting at once. + +## 4. Tokens + +| Use | Token | +|---|---| +| Pill surface | `surface`, 0 tonal elevation, 6dp shadow, 24dp bottom corners | +| Inputs, origin field | `surfaceContainerHighest`, `CircleShape` | +| Navigation capsule | `surfaceContainerHigh`, `CircleShape` | +| Tiles, grouped cards | `surfaceContainer`, 16dp | +| Toggle "on" | `secondaryContainer` / `onSecondaryContainer` | +| Insecure | `error` / `errorContainer` | +| Tor accent | `tertiary` (no new colour, readable in both themes) | +| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles | +| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` | + +## 5. Next steps + +1. Wire the prototypes in: `TopControlSheet` → `BrowserPill`, `EmbeddedFindBar` → `FindInPagePill`, + `BottomConsoleSheet` → `ConsoleSheet`, and the embedded dialogs → `PermissionPromptCard` / + `PageDialogCard` / `PageInfoSheet`. +2. Host the same composables in the full-screen window through `ComposeView`, and delete + `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel` and `BrowserJsDialogs`. +3. Move the pill's labels from the `commons` Android resources to the Compose catalogue (the + prototypes already use `Res.string.browser_pill_*`). +4. Add "Only this time" to the permission flow (the registry already treats "no answer" as + not-remembered). +5. Confirm before clearing site data in both surfaces. diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 3af0f83bd12753dca85367876b4f9743284cd835..05533f6459d8955444431ba52cdce4a0eacc3b6d 100644 GIT binary patch delta 9235 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, so this author is a claim An item of kind %1$d, which this client does not draw %1$d bytes of something this client does not read + + Close + Copy link + Find + Text size + Desktop + Add to Home + Other browser + Full screen + Edit address + Back + Forward + Reload + Stop loading + Add to favorites + Remove from favorites + Share + Copy link + Find in page + Text size + Add to Home + Desktop site + Open in browser + Full screen + You left %1$s + Back to app + Smaller text + Larger text + Reset + %1$d% + Privacy + Onion routing + The site can't see your IP address + The site can see your IP address + Site settings + Nothing allowed yet + What it can access + Your keys never leave Amethyst + Console + Secure connection + Not secure + Onion-routed + Sandboxed app + Camera + Microphone + Location + Ask + Allow + Block + %1$s %2$s + allowed + blocked + Search or enter address + Go + Clear + Paste and go + Use this address + Find in page + %1$d / %2$d + No matches + Previous match + Next match + Close find in page + All + Errors + Warnings + Copy + Clear + Nothing logged yet + This site wants to use + See what your camera sees + Hear what your microphone hears + Know roughly where you are + Calls can reveal your IP address even over Tor. + Allow while visiting + Only this time + Don't allow + %1$s says + This page says + Don't let this page show more dialogs + Your answer + Leave site? + Changes you made may not be saved. + Leave + OK + Cancel + Connection + Connection is encrypted + Information you send can't be read on the way. + Connection is not encrypted + Don't enter passwords or payment details. + Onion-routed over Tor + Open web + Certificate + Issued to %1$s by %2$s · valid until %3$s + Permissions + Cookies and site data + Logins and preferences this site saved for this account + Clear site data + Sign out of this site and delete its data? diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt new file mode 100644 index 0000000000..f46a97d35e --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_address_hint +import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_fill_in +import com.vitorpamplona.amethyst.commons.resources.browser_pill_go +import com.vitorpamplona.amethyst.commons.resources.browser_pill_paste_go +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * The address as a first-class input, opened from the origin field. A 56dp pill field with the URL + * pre-selected, a leading search/security icon, and trailing clear + a filled Go; below it Paste and go + * (when the clipboard holds a URL) and the omnibox suggestions, each with a ↖ that fills its address in + * without leaving. [onCancel] folds it back into the origin field. + */ +@Composable +fun AddressEditor( + initialUrl: String, + security: BrowserChrome.Security, + suggestions: List, + clipboardUrl: String?, + onGo: (String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) } + val focus = remember { FocusRequester() } + if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + + fun go(text: String = field.text) { + text.trim().takeIf { it.isNotEmpty() }?.let(onGo) + } + + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onCancel) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null) + } + Row( + Modifier + .weight(1f) + .height(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + .padding(start = 16.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // The field shows what it will do: the page's badge while it still holds the page's URL, + // a search glyph once the user types something else. + if (field.text == initialUrl) { + SecurityIcon(security, size = 20.dp) + } else { + Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(10.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (field.text.isEmpty()) { + Text(stringRes(Res.string.browser_pill_address_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + BasicTextField( + value = field, + onValueChange = { field = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { go() }), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (field.text.isNotEmpty()) { + IconButton(onClick = { field = TextFieldValue("") }) { + Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + FilledIconButton(onClick = { go() }, enabled = field.text.isNotBlank(), modifier = Modifier.size(44.dp)) { + Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_pill_go), modifier = Modifier.size(22.dp)) + } + } + } + + if (clipboardUrl != null && clipboardUrl != field.text) { + AssistChip( + onClick = { go(clipboardUrl) }, + label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) }, + modifier = Modifier.padding(start = 48.dp), + ) + } + + if (suggestions.isNotEmpty()) { + Column(Modifier.padding(start = 4.dp)) { + suggestions.take(MAX_SUGGESTIONS).forEach { suggestion -> + SuggestionRow( + suggestion = suggestion, + onOpen = { go(suggestion.url) }, + onFill = { field = TextFieldValue(suggestion.url, TextRange(suggestion.url.length)) }, + ) + } + } + } + } +} + +private const val MAX_SUGGESTIONS = 5 + +@Composable +private fun SuggestionRow( + suggestion: AddressSuggestion, + onOpen: () -> Unit, + onFill: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onOpen) + .padding(start = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + SiteMonogram(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, size = 32.dp) + if (suggestion.isFavorite) { + Box( + Modifier + .align(Alignment.BottomEnd) + .size(14.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Star, contentDescription = null, modifier = Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary, filled = true) + } + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(suggestion.url.removePrefix("https://"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + IconButton(onClick = onFill) { + Icon(MaterialSymbols.NorthWest, contentDescription = stringRes(Res.string.browser_pill_fill_in), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt new file mode 100644 index 0000000000..e7bd6ca2a3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -0,0 +1,503 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app +import com.vitorpamplona.amethyst.commons.resources.browser_pill_close +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allowed +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_blocked +import com.vitorpamplona.amethyst.commons.resources.browser_pill_left_site +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_state +import com.vitorpamplona.amethyst.commons.resources.browser_pill_privacy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * The redesigned browser pill (see `amethyst/plans/2026-09-26-browser-ui-review.md`): a grabber at the + * top centre that shows the page's state even collapsed, and, pulled down, a Chrome-PWA-style menu — + * header, navigation capsule, page-action tiles, a privacy card and the console row. + * + * Stateless apart from which inline panel (address editor, text size) is open. Which controls appear is + * [BrowserChrome]'s decision, so this renders web, nsite and napplet surfaces alike. + */ +@Composable +fun BrowserPill( + ui: BrowserPillUi, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onEvent: (BrowserPillEvent) -> Unit, + modifier: Modifier = Modifier, + showClose: Boolean = false, + suggestions: List = emptyList(), + clipboardUrl: String? = null, + initiallyEditing: Boolean = false, + initiallyTextSizeOpen: Boolean = false, +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + BrowserPillSheet( + ui = ui, + onEvent = { event -> + // Everything but in-sheet adjustments (text size) finishes the interaction. + if (event !is BrowserPillEvent.TextZoom) onExpandedChange(false) + onEvent(event) + }, + showClose = showClose, + suggestions = suggestions, + clipboardUrl = clipboardUrl, + initiallyEditing = initiallyEditing, + initiallyTextSizeOpen = initiallyTextSizeOpen, + ) + } + PillHandle(ui, expanded, onExpandedChange) + } +} + +/** + * The collapsed grabber. It tells the page's story before it's opened: the bar takes the error colour on + * plain HTTP and the Tor accent when onion-routed, a hairline fills while the page loads, and a dot + * appears when the console has errors. + */ +@Composable +fun PillHandle( + ui: BrowserPillUi, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val barColor = + when (ui.security) { + BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error + BrowserChrome.Security.TOR -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + Box( + modifier + .clip(RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f)) + .clickable { onExpandedChange(!expanded) } + .draggable( + orientation = Orientation.Vertical, + state = + rememberDraggableState { delta -> + if (delta > 1f) { + onExpandedChange(true) + } else if (delta < -1f) { + onExpandedChange(false) + } + }, + ).padding(horizontal = 18.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .width(36.dp) + .height(5.dp) + .clip(CircleShape) + .background(barColor), + ) + val progress = ui.loadProgress + if (progress != null) { + Spacer(Modifier.height(3.dp)) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.width(36.dp).height(2.dp).clip(CircleShape), + trackColor = Color.Transparent, + drawStopIndicator = {}, + ) + } + } + if (ui.consoleErrors > 0) { + Box( + Modifier + .align(Alignment.CenterEnd) + .padding(start = 44.dp) + .size(7.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error), + ) + } + } +} + +/** The expanded menu (without the grabber), for hosts that place it themselves. */ +@Composable +fun BrowserPillSheet( + ui: BrowserPillUi, + onEvent: (BrowserPillEvent) -> Unit, + modifier: Modifier = Modifier, + showClose: Boolean = false, + suggestions: List = emptyList(), + clipboardUrl: String? = null, + initiallyEditing: Boolean = false, + initiallyTextSizeOpen: Boolean = false, +) { + var editing by rememberSaveable { mutableStateOf(initiallyEditing) } + var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) } + val sections = remember(ui.chrome) { BrowserChrome.sections(ui.chrome) } + val page = sections.firstOrNull { it.kind == SectionKind.PAGE }?.actions.orEmpty() + val privacy = sections.firstOrNull { it.kind == SectionKind.PRIVACY }?.actions.orEmpty() + val developer = sections.firstOrNull { it.kind == SectionKind.DEVELOPER }?.actions.orEmpty() + + Surface( + modifier = modifier.fillMaxWidth(), + shape = PillDefaults.SheetShape, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 8.dp, + // On the black dark theme a shadow doesn't show; a hairline keeps the sheet's edge off the page. + border = PillDefaults.hairline(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(start = PillDefaults.SheetPadding, end = PillDefaults.SheetPadding, top = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (editing) { + AddressEditor( + initialUrl = ui.chrome.url, + security = ui.security, + suggestions = suggestions, + clipboardUrl = clipboardUrl, + onGo = { onEvent(BrowserPillEvent.Navigate(it)) }, + onCancel = { editing = false }, + ) + } else { + PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) }) + OriginField( + ui = ui, + onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null, + onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) else null, + ) + if (Action.BACK_TO_APP in page) { + OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) { + onEvent(BrowserPillEvent.Action(Action.BACK_TO_APP)) + } + } + NavigationCapsule(ui, onAction = { onEvent(BrowserPillEvent.Action(it)) }) + + val tiles = page.filter { it != Action.BACK_TO_APP && it != Action.EDIT_ADDRESS } + if (tiles.isNotEmpty()) { + TileGrid( + actions = tiles, + ui = ui, + textSizeOpen = textSizeOpen, + onAction = { action -> + if (action == Action.TEXT_SIZE) textSizeOpen = !textSizeOpen else onEvent(BrowserPillEvent.Action(action)) + }, + ) + } + AnimatedVisibility(visible = textSizeOpen) { + TextSizeControl(ui.textZoom) { onEvent(BrowserPillEvent.TextZoom(it)) } + } + if (privacy.isNotEmpty()) { + PrivacyCard(ui, privacy) { onEvent(BrowserPillEvent.Action(it)) } + } + if (Action.CONSOLE in developer) { + ConsoleRow(ui) { onEvent(BrowserPillEvent.Action(Action.CONSOLE)) } + } + } + } + } +} + +@Composable +private fun PillHeader( + ui: BrowserPillUi, + showClose: Boolean, + onClose: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(ui.title.ifBlank { ui.host }) + Spacer(Modifier.width(12.dp)) + Text( + ui.title.ifBlank { ui.host }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (showClose) { + IconButton(onClick = onClose) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_close), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** Chrome's out-of-scope bar, inside the menu: you're on another site now; one tap goes home. */ +@Composable +private fun OutOfScopeBanner( + homeHost: String, + onBack: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Info, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer) + Spacer(Modifier.width(10.dp)) + Text( + stringRes(Res.string.browser_pill_left_site, homeHost), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TextButton(onClick = onBack) { Text(stringRes(Res.string.browser_pill_back_to_app)) } + } +} + +/** Page actions as tiles, four per row; toggles (desktop site, text size) fill while on. */ +@Composable +private fun TileGrid( + actions: List, + ui: BrowserPillUi, + textSizeOpen: Boolean, + onAction: (Action) -> Unit, +) { + val columns = 4 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + actions.chunked(columns).forEach { rowActions -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowActions.forEach { action -> + ActionTile( + symbol = pillSymbolFor(action) ?: MaterialSymbols.Info, + label = stringRes(pillTileLabelFor(action)), + description = stringRes(pillLabelFor(action)), + onClick = { onAction(action) }, + selected = + when (action) { + Action.DESKTOP_SITE -> ui.desktopSite + Action.TEXT_SIZE -> textSizeOpen || ui.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM + else -> null + }, + modifier = Modifier.weight(1f), + ) + } + // Keep the last row's tiles the same width as the others. + repeat(columns - rowActions.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** Text size: a stepped slider between a small and a large "A", the value, and a way back to 100%. */ +@Composable +fun TextSizeControl( + percent: Int, + onChange: (Int) -> Unit, +) { + val steps = BrowserChrome.TEXT_ZOOM_STEPS + val index = steps.indexOfFirst { it >= percent }.let { if (it < 0) steps.lastIndex else it } + Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val smaller = stringRes(Res.string.browser_pill_text_smaller) + IconButton( + onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = false)) }, + modifier = Modifier.semantics { contentDescription = smaller }, + ) { + Text("A", fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 4.dp)) + } + Slider( + value = index.toFloat(), + onValueChange = { onChange(steps[it.toInt().coerceIn(0, steps.lastIndex)]) }, + valueRange = 0f..steps.lastIndex.toFloat(), + steps = steps.size - 2, + modifier = Modifier.weight(1f), + ) + val larger = stringRes(Res.string.browser_pill_text_larger) + IconButton( + onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = true)) }, + modifier = Modifier.semantics { contentDescription = larger }, + ) { + Text("A", fontSize = 22.sp, fontWeight = FontWeight.Medium) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringRes(Res.string.browser_pill_text_value, percent), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) + TextButton(onClick = { onChange(BrowserChrome.DEFAULT_TEXT_ZOOM) }, enabled = percent != BrowserChrome.DEFAULT_TEXT_ZOOM) { + Text(stringRes(Res.string.browser_pill_text_reset)) + } + } + } + } +} + +/** + * Privacy: Tor with what it means for the site, site settings with a summary of what the site may use, + * and, for sandboxed apps, what they can access. + */ +@Composable +private fun PrivacyCard( + ui: BrowserPillUi, + actions: List, + onAction: (Action) -> Unit, +) { + GroupCard(heading = stringRes(Res.string.browser_pill_privacy)) { + actions.forEachIndexed { index, action -> + if (index > 0) GroupDivider() + when (action) { + Action.TOR -> { + val on = ui.chrome.torOn == true + GroupRow( + icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(pillLabelFor(Action.TOR)), + supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = { onAction(Action.TOR) }, + ) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) } + } + Action.SITE_SETTINGS -> + GroupRow( + icon = { PillActionIcon(Action.SITE_SETTINGS, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(pillLabelFor(Action.SITE_SETTINGS)), + supporting = permissionSummary(ui.sitePermissions), + onClick = { onAction(Action.SITE_SETTINGS) }, + ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + Action.ACCESS_INFO -> + GroupRow( + icon = { PillActionIcon(Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onPrimaryContainer, size = 22.dp, filled = true) }, + iconContainer = MaterialTheme.colorScheme.primaryContainer, + title = stringRes(pillLabelFor(Action.ACCESS_INFO)), + supporting = stringRes(Res.string.browser_pill_access_desc), + onClick = { onAction(Action.ACCESS_INFO) }, + ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + else -> Unit + } + } + } +} + +@Composable +private fun permissionSummary(decisions: Map): String { + val answered = BrowserSitePermission.entries.mapNotNull { permission -> decisions[permission]?.takeIf { it != BrowserSitePermission.Decision.ASK }?.let { permission to it } } + if (answered.isEmpty()) return stringRes(Res.string.browser_pill_site_settings_none) + return answered + .map { (permission, decision) -> + stringRes( + Res.string.browser_pill_permission_state, + stringRes(permissionLabel(permission)), + stringRes(if (decision == BrowserSitePermission.Decision.ALLOW) Res.string.browser_pill_decision_allowed else Res.string.browser_pill_decision_blocked), + ) + }.joinToString(" · ") +} + +/** The developer console: one row, with an error count badge and its on/off switch. */ +@Composable +private fun ConsoleRow( + ui: BrowserPillUi, + onToggle: () -> Unit, +) { + Surface(onClick = onToggle, shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Row(Modifier.padding(start = 16.dp, end = 12.dp).heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) { + PillActionIcon(Action.CONSOLE, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) + Spacer(Modifier.width(16.dp)) + Text(stringRes(pillLabelFor(Action.CONSOLE)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (ui.consoleErrors > 0) { + CountBadge(ui.consoleErrors) + Spacer(Modifier.width(12.dp)) + } + Switch(checked = ui.consoleShowing, onCheckedChange = { onToggle() }) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt new file mode 100644 index 0000000000..b9c78e7e1f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission + +/** + * Everything the redesigned browser pill draws, as plain immutable data. [chrome] decides *which* controls + * exist (the same [BrowserChrome] layout both browsers already share); the rest is what they show. + * + * The pill composables are stateless: a host (embedded tab, full-screen window, desktop) builds this from + * its live page and handles [BrowserPillEvent]s. + */ +@Immutable +data class BrowserPillUi( + val title: String, + val chrome: BrowserChrome.State, + val isFavorite: Boolean = false, + val desktopSite: Boolean = false, + val textZoom: Int = BrowserChrome.DEFAULT_TEXT_ZOOM, + /** 0..1 while the main frame loads; null when idle. */ + val loadProgress: Float? = null, + val consoleShowing: Boolean = false, + val consoleErrors: Int = 0, + /** Answers this site already has (camera / mic / location), for the site-settings summary. */ + val sitePermissions: Map = emptyMap(), +) { + val security: BrowserChrome.Security get() = BrowserChrome.security(chrome) + val host: String get() = BrowserChrome.displayHost(chrome.url) +} + +/** What the user did in the pill. Hosts map these onto the WebView / broker. */ +sealed interface BrowserPillEvent { + /** A navigation-capsule button, a tile, or a privacy/developer row. */ + data class Action( + val action: BrowserChrome.Action, + ) : BrowserPillEvent + + data class TextZoom( + val percent: Int, + ) : BrowserPillEvent + + data class Navigate( + val input: String, + ) : BrowserPillEvent + + /** The origin field was long-pressed (copy link). */ + data object CopyOrigin : BrowserPillEvent + + data object Close : BrowserPillEvent +} + +/** One omnibox suggestion for the address editor. */ +@Immutable +data class AddressSuggestion( + val title: String, + val url: String, + val isFavorite: Boolean = false, +) + +/** One console line. */ +@Immutable +data class ConsoleLine( + val level: Level, + val message: String, + val source: String = "", + val line: Int = 0, +) { + enum class Level { LOG, INFO, WARNING, ERROR, DEBUG } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt new file mode 100644 index 0000000000..fce006a7e9 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow + +/** Sample states for the pill prototypes, shared by the previews and the offscreen render test. */ +object BrowserPillSamples { + val primal = + BrowserPillUi( + title = "Primal", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = "https://primal.net/home", + startUrl = "https://primal.net/", + canGoBack = true, + torOn = false, + ), + isFavorite = true, + consoleErrors = 3, + sitePermissions = mapOf(BrowserSitePermission.CAMERA to Decision.ALLOW, BrowserSitePermission.LOCATION to Decision.BLOCK), + ) + + val overTorOutOfScope = + BrowserPillUi( + title = "Sign in – Accounts", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.EMBEDDED, + url = "https://accounts.example.com/login", + startUrl = "https://snort.social/", + canGoBack = true, + canGoForward = true, + isLoading = true, + torOn = true, + ), + loadProgress = 0.62f, + textZoom = 115, + desktopSite = true, + ) + + val insecure = + BrowserPillUi( + title = "Old Forum", + chrome = BrowserChrome.State(BrowserChrome.Surface.WEB, BrowserChrome.Presentation.EMBEDDED, "http://oldforum.example.org/", "http://oldforum.example.org/"), + loadProgress = 0.3f, + ) + + val napplet = + BrowserPillUi( + title = "Zap Poll", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.NAPPLET, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = "", + startUrl = "", + canFavorite = true, + hasAccessInfo = true, + torOn = true, + ), + ) + + val suggestions = + listOf( + AddressSuggestion("Primal", "https://primal.net/", isFavorite = true), + AddressSuggestion("Snort", "https://snort.social/"), + AddressSuggestion("Habla — long-form Nostr", "https://habla.news/"), + AddressSuggestion("", "https://nostr.band/search?q=amethyst"), + ) + + val console = + listOf( + ConsoleLine(ConsoleLine.Level.INFO, "Connected to wss://relay.damus.io", "app.js", 112), + ConsoleLine(ConsoleLine.Level.WARNING, "window.nostr.getRelays is deprecated", "nostr.js", 40), + ConsoleLine(ConsoleLine.Level.LOG, "feed: 42 events in 180ms", "feed.js", 9), + ConsoleLine(ConsoleLine.Level.ERROR, "Uncaught TypeError: Cannot read properties of undefined (reading 'pubkey')", "https://primal.net/assets/index-4f2a.js", 2211), + ConsoleLine(ConsoleLine.Level.ERROR, "Failed to load (-2): net::ERR_NAME_NOT_RESOLVED", "https://cdn.example.com/x.png", 0), + ConsoleLine(ConsoleLine.Level.DEBUG, "cache hit: profile 7a1c…", "cache.js", 77), + ) + + val certificate = CertificateInfo(issuedTo = "primal.net", issuedBy = "Let's Encrypt", validUntil = "Dec 14, 2026") +} + +@Composable +private fun PreviewFrame(content: @Composable () -> Unit) { + ThemeComparisonRow { + Box(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(bottom = 12.dp)) { content() } + } +} + +@Preview(widthDp = 820, heightDp = 1000) +@Composable +fun BrowserPillExpandedPreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.primal, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) } +} + +@Preview(widthDp = 820, heightDp = 1100) +@Composable +fun BrowserPillTorOutOfScopePreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.overTorOutOfScope, expanded = true, onExpandedChange = {}, onEvent = {}, initiallyTextSizeOpen = true) } +} + +@Preview(widthDp = 820, heightDp = 700) +@Composable +fun BrowserPillAddressEditorPreview() { + PreviewFrame { + BrowserPill( + BrowserPillSamples.primal, + expanded = true, + onExpandedChange = {}, + onEvent = {}, + suggestions = BrowserPillSamples.suggestions, + clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + initiallyEditing = true, + ) + } +} + +@Preview(widthDp = 820, heightDp = 800) +@Composable +fun BrowserPillNappletPreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.napplet, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) } +} + +@Preview(widthDp = 820, heightDp = 160) +@Composable +fun PillHandlesPreview() { + ThemeComparisonRow { + Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(vertical = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top) { + PillHandle(BrowserPillSamples.primal.copy(consoleErrors = 0), expanded = false, onExpandedChange = {}) + PillHandle(BrowserPillSamples.insecure, expanded = false, onExpandedChange = {}) + PillHandle(BrowserPillSamples.overTorOutOfScope.copy(consoleErrors = 2), expanded = false, onExpandedChange = {}) + } + } +} + +@Preview(widthDp = 820, heightDp = 220) +@Composable +fun FindInPagePreview() { + ThemeComparisonRow { + Column(Modifier.background(MaterialTheme.colorScheme.surfaceDim)) { + FindInPagePill(query = "zap", onQueryChange = {}, active = 2, total = 12, onNext = {}, onClose = {}, autoFocus = false) + FindInPagePill(query = "lightning address", onQueryChange = {}, active = 0, total = 0, onNext = {}, onClose = {}, autoFocus = false) + } + } +} + +@Preview(widthDp = 820, heightDp = 380) +@Composable +fun ConsoleSheetPreview() { + ThemeComparisonRow { ConsoleSheet(BrowserPillSamples.console, onCopy = {}, onClear = {}, onCopyLine = {}) } +} + +@Preview(widthDp = 820, heightDp = 700) +@Composable +fun PermissionPromptPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PermissionPromptCard("meet.example.com", BrowserChrome.Security.TOR, setOf(BrowserSitePermission.CAMERA, BrowserSitePermission.MICROPHONE), onAllow = {}, onAllowOnce = {}, onDeny = {}) + } + } +} + +@Preview(widthDp = 820, heightDp = 560) +@Composable +fun PageDialogPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PageDialogCard( + type = PageDialogType.PROMPT, + host = "snort.social", + security = BrowserChrome.Security.HTTPS, + message = "Name this relay set", + defaultValue = "Friends", + offerBlock = true, + onResult = { _, _, _ -> }, + autoFocus = false, + ) + } + } +} + +@Preview(widthDp = 820, heightDp = 420) +@Composable +fun LeaveSiteDialogPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PageDialogCard(PageDialogType.BEFORE_UNLOAD, "habla.news", BrowserChrome.Security.HTTPS, message = "", onResult = { _, _, _ -> }) + } + } +} + +@Preview(widthDp = 820, heightDp = 1250) +@Composable +fun PageInfoPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp).width(380.dp)) { + PageInfoSheet(BrowserPillSamples.primal, BrowserPillSamples.certificate, onPermissionChange = { _, _ -> }, onClearSiteData = {}, initiallyConfirmingClear = true) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt new file mode 100644 index 0000000000..28de782946 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_empty +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_errors +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_warnings +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** Which console lines are shown. */ +enum class ConsoleFilter { ALL, ERRORS, WARNINGS } + +/** + * The developer console as a bottom sheet: a grab handle, the title with All / Errors / Warnings filter + * chips (with counts), Copy and Clear, then the log — one row per line with a level-coloured stripe, the + * message in monospace and `file:line` muted. Long-press a row to copy it. + */ +@Composable +fun ConsoleSheet( + lines: List, + onCopy: (List) -> Unit, + onClear: () -> Unit, + onCopyLine: (ConsoleLine) -> Unit, + modifier: Modifier = Modifier, + maxHeight: Dp = 320.dp, + initialFilter: ConsoleFilter = ConsoleFilter.ALL, +) { + var filter by remember { mutableStateOf(initialFilter) } + val errors = lines.count { it.level == ConsoleLine.Level.ERROR } + val warnings = lines.count { it.level == ConsoleLine.Level.WARNING } + val shown = + when (filter) { + ConsoleFilter.ALL -> lines + ConsoleFilter.ERRORS -> lines.filter { it.level == ConsoleLine.Level.ERROR } + ConsoleFilter.WARNINGS -> lines.filter { it.level == ConsoleLine.Level.WARNING } + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + shadowElevation = 8.dp, + border = PillDefaults.hairline(), + ) { + Column(Modifier.heightIn(max = maxHeight)) { + Box(Modifier.fillMaxWidth().padding(top = 10.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .width(32.dp) + .height(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), + ) + } + Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(Res.string.browser_pill_console), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = { onCopy(shown) }, enabled = shown.isNotEmpty()) { + Icon(MaterialSymbols.ContentCopy, contentDescription = stringRes(Res.string.browser_pill_console_copy), modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onClear, enabled = lines.isNotEmpty()) { + Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp)) + } + } + Row( + Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConsoleChip(stringRes(Res.string.browser_pill_console_all), lines.size, filter == ConsoleFilter.ALL) { filter = ConsoleFilter.ALL } + ConsoleChip(stringRes(Res.string.browser_pill_console_errors), errors, filter == ConsoleFilter.ERRORS, MaterialTheme.colorScheme.error) { filter = ConsoleFilter.ERRORS } + ConsoleChip(stringRes(Res.string.browser_pill_console_warnings), warnings, filter == ConsoleFilter.WARNINGS, warningColor()) { filter = ConsoleFilter.WARNINGS } + } + if (shown.isEmpty()) { + Text( + stringRes(Res.string.browser_pill_console_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } else { + LazyColumn(Modifier.padding(top = 6.dp, bottom = 8.dp)) { + items(shown) { line -> ConsoleRowItem(line) { onCopyLine(line) } } + } + } + } + } +} + +@Composable +private fun ConsoleChip( + label: String, + count: Int, + selected: Boolean, + dot: Color? = null, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text("$label $count") }, + leadingIcon = + dot?.let { color -> + { Box(Modifier.size(8.dp).clip(CircleShape).background(color)) } + }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer), + ) +} + +@Composable +private fun warningColor(): Color = if (MaterialTheme.colorScheme.surface.luminanceBelowHalf()) Color(0xFFFFB74D) else Color(0xFFB45309) + +private fun Color.luminanceBelowHalf(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConsoleRowItem( + line: ConsoleLine, + onLongPress: () -> Unit, +) { + val color = + when (line.level) { + ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.error + ConsoleLine.Level.WARNING -> warningColor() + ConsoleLine.Level.DEBUG -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } + val background = + when (line.level) { + ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f) + ConsoleLine.Level.WARNING -> warningColor().copy(alpha = 0.10f) + else -> Color.Transparent + } + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .background(background) + .combinedClickable(onClick = {}, onLongClick = onLongPress) + .padding(end = 16.dp), + verticalAlignment = Alignment.Top, + ) { + Box(Modifier.width(3.dp).fillMaxHeight().background(if (line.level == ConsoleLine.Level.LOG) Color.Transparent else color)) + Spacer(Modifier.width(13.dp)) + Text( + line.message, + color = color, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 16.sp, + modifier = Modifier.weight(1f).padding(vertical = 5.dp), + ) + if (line.source.isNotBlank()) { + Spacer(Modifier.width(8.dp)) + Text( + line.source.substringAfterLast('/') + ":" + line.line, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 5.dp).width(96.dp), + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt new file mode 100644 index 0000000000..c38bdfbc2c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_close +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_count +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_hint +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_next +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_previous +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Find in page as a floating capsule above the bottom edge: search glyph, the query, a match chip + * ("3 / 12", or "No matches" in the error tone), previous / next, and close. IME Search jumps to the next + * match. [active] is 0-based; [total] null means no search has run yet. + */ +@Composable +fun FindInPagePill( + query: String, + onQueryChange: (String) -> Unit, + active: Int, + total: Int?, + onNext: (forward: Boolean) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + val focus = remember { FocusRequester() } + if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val noMatches = query.isNotEmpty() && total == 0 + + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 8.dp, + border = PillDefaults.hairline(), + ) { + Row(Modifier.height(56.dp).padding(start = 18.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (noMatches) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text(stringRes(Res.string.browser_pill_find_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onNext(true) }), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (query.isNotEmpty() && total != null) { + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .clip(CircleShape) + .background(if (noMatches) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + if (noMatches) stringRes(Res.string.browser_pill_find_none) else stringRes(Res.string.browser_pill_find_count, active + 1, total), + style = MaterialTheme.typography.labelMedium, + color = if (noMatches) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + IconButton(onClick = { onNext(false) }, enabled = (total ?: 0) > 0) { + Icon(MaterialSymbols.KeyboardArrowUp, contentDescription = stringRes(Res.string.browser_pill_find_previous)) + } + IconButton(onClick = { onNext(true) }, enabled = (total ?: 0) > 0) { + Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = stringRes(Res.string.browser_pill_find_next)) + } + VerticalDivider(Modifier.height(24.dp), color = MaterialTheme.colorScheme.outlineVariant) + IconButton(onClick = onClose) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_find_close)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt new file mode 100644 index 0000000000..8c28a74bc7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -0,0 +1,476 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allow +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_ask +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_block +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_answer +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_block +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_generic +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_message +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_title +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_says +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear_confirm +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_permissions +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_allow +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_camera_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_deny +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_location_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** A compact, read-only origin badge for card headers: the page can't fake it, so every card starts with it. */ +@Composable +fun OriginBadge( + host: String, + security: BrowserChrome.Security, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(start = 10.dp, end = 12.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SecurityIcon(security, size = 16.dp) + Spacer(Modifier.width(6.dp)) + Text(host, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** The card frame every page-initiated prompt shares. */ +@Composable +private fun PageCard(content: @Composable () -> Unit) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(24.dp)) { content() } + } +} + +/** + * A site asking for camera / microphone / location: the origin badge, a tinted circle per thing asked with + * what it means, a note when calls over Tor could still expose the IP, and three plain-worded answers — + * remembered allow, one-time allow, remembered refusal. + */ +@Composable +fun PermissionPromptCard( + host: String, + security: BrowserChrome.Security, + permissions: Set, + onAllow: () -> Unit, + onAllowOnce: () -> Unit, + onDeny: () -> Unit, +) { + PageCard { + OriginBadge(host, security) + Spacer(Modifier.height(20.dp)) + Text( + stringRes(Res.string.browser_pill_perm_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + permissions.sortedBy { it.ordinal }.forEach { permission -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.titleSmall) + Text( + stringRes( + when (permission) { + BrowserSitePermission.CAMERA -> Res.string.browser_pill_perm_camera_desc + BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_perm_microphone_desc + BrowserSitePermission.LOCATION -> Res.string.browser_pill_perm_location_desc + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + // WebRTC can reach out around the SOCKS proxy; say so where it matters, without blocking the call. + if (security == BrowserChrome.Security.TOR && (BrowserSitePermission.CAMERA in permissions || BrowserSitePermission.MICROPHONE in permissions)) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillActionIcon(BrowserChrome.Action.TOR, tint = MaterialTheme.colorScheme.onTertiaryContainer, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Text(stringRes(Res.string.browser_pill_perm_tor_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer) + } + } + Spacer(Modifier.height(24.dp)) + Button(onClick = onAllow, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_allow)) } + Spacer(Modifier.height(8.dp)) + FilledTonalButton(onClick = onAllowOnce, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_once)) } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_deny)) } + } +} + +/** The kinds of page dialog. */ +enum class PageDialogType { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD } + +/** + * A page's alert / confirm / prompt / beforeunload. The origin badge heads it (so it can't pass for + * Amethyst UI); prompt gets a labelled field with clear and IME Done; repeat dialogs offer a checkbox to + * block the rest, as Chrome does. + */ +@Composable +fun PageDialogCard( + type: PageDialogType, + host: String?, + security: BrowserChrome.Security, + message: String, + defaultValue: String = "", + offerBlock: Boolean = false, + onResult: (confirmed: Boolean, text: String?, block: Boolean) -> Unit, + autoFocus: Boolean = true, +) { + var text by remember { mutableStateOf(defaultValue) } + var block by remember { mutableStateOf(false) } + val leave = type == PageDialogType.BEFORE_UNLOAD + val focus = remember { FocusRequester() } + if (type == PageDialogType.PROMPT && autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + + PageCard { + if (host != null) { + OriginBadge(host, security) + Spacer(Modifier.height(16.dp)) + } + Text( + when { + leave -> stringRes(Res.string.browser_pill_dialog_leave_title) + host != null -> stringRes(Res.string.browser_pill_dialog_says, host) + else -> stringRes(Res.string.browser_pill_dialog_generic) + }, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(12.dp)) + Column(Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState())) { + Text( + if (leave) stringRes(Res.string.browser_pill_dialog_leave_message) else message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (type == PageDialogType.PROMPT) { + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(stringRes(Res.string.browser_pill_dialog_answer)) }, + singleLine = true, + trailingIcon = { + if (text.isNotEmpty()) { + IconButton(onClick = { text = "" }) { Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear)) } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onResult(true, text, block) }), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (offerBlock) { + Spacer(Modifier.height(8.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { block = !block } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = block, onCheckedChange = { block = it }) + Text(stringRes(Res.string.browser_pill_dialog_block), style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(20.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + if (type != PageDialogType.ALERT) { + TextButton(onClick = { onResult(false, null, block) }) { Text(stringRes(Res.string.browser_pill_cancel)) } + Spacer(Modifier.width(8.dp)) + } + Button( + onClick = { onResult(true, if (type == PageDialogType.PROMPT) text else null, block) }, + colors = if (leave) ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError) else ButtonDefaults.buttonColors(), + ) { + Text(stringRes(if (leave) Res.string.browser_pill_dialog_leave else Res.string.browser_pill_ok)) + } + } + } +} + +/** Certificate facts for [PageInfoSheet]. */ +data class CertificateInfo( + val issuedTo: String, + val issuedBy: String, + val validUntil: String, +) + +/** + * Page info as a sheet: who the site is, how you're connected (encryption, route, certificate), what it + * may use (camera / mic / location as Ask · Allow · Block, editable in place), and its stored data with a + * clear that asks first. + */ +@Composable +fun PageInfoSheet( + ui: BrowserPillUi, + certificate: CertificateInfo?, + onPermissionChange: (BrowserSitePermission, Decision) -> Unit, + onClearSiteData: () -> Unit, + modifier: Modifier = Modifier, + initiallyConfirmingClear: Boolean = false, +) { + var confirmingClear by remember { mutableStateOf(initiallyConfirmingClear) } + val https = ui.chrome.url.startsWith("https://", ignoreCase = true) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + ) { + Column(Modifier.padding(PillDefaults.SheetPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(ui.title.ifBlank { ui.host }, size = 48.dp) + Spacer(Modifier.width(14.dp)) + Column { + Text(ui.host, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(BrowserChrome.originOf(ui.chrome.url) ?: ui.chrome.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) { + GroupRow( + icon = { Icon(if (https) MaterialSymbols.Lock else MaterialSymbols.NoEncryption, contentDescription = null, tint = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onErrorContainer, filled = true) }, + iconContainer = if (https) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.errorContainer, + title = stringRes(if (https) Res.string.browser_pill_info_https else Res.string.browser_pill_info_http), + supporting = stringRes(if (https) Res.string.browser_pill_info_https_desc else Res.string.browser_pill_info_http_desc), + supportingColor = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + onClick = null, + ) + ui.chrome.torOn?.let { tor -> + GroupDivider() + GroupRow( + icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open), + supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = null, + ) + } + if (certificate != null) { + GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_info_certificate), + supporting = stringRes(Res.string.browser_pill_info_certificate_desc, certificate.issuedTo, certificate.issuedBy, certificate.validUntil), + onClick = null, + ) + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_permissions)) { + BrowserSitePermission.entries.forEachIndexed { index, permission -> + if (index > 0) GroupDivider() + PermissionDecisionRow(permission, ui.sitePermissions[permission] ?: Decision.ASK) { onPermissionChange(permission, it) } + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_site_data)) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.Cookie, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Text(stringRes(Res.string.browser_pill_info_site_data_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(12.dp)) + AnimatedContent(targetState = confirmingClear, label = "clear-site-data") { confirming -> + if (!confirming) { + OutlinedButton( + onClick = { confirmingClear = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(Res.string.browser_pill_info_clear)) + } + } else { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + ) { + Text(stringRes(Res.string.browser_pill_info_clear_confirm), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { confirmingClear = false }) { Text(stringRes(Res.string.browser_pill_cancel), color = MaterialTheme.colorScheme.onErrorContainer) } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + confirmingClear = false + onClearSiteData() + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError), + ) { Text(stringRes(Res.string.browser_pill_info_clear)) } + } + } + } + } + } + } + } + } +} + +/** One permission with a first-class Ask · Allow · Block choice. */ +@Composable +private fun PermissionDecisionRow( + permission: BrowserSitePermission, + decision: Decision, + onChange: (Decision) -> Unit, +) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) { + Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(12.dp)) + Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + val options = listOf(Decision.ASK to Res.string.browser_pill_decision_ask, Decision.ALLOW to Res.string.browser_pill_decision_allow, Decision.BLOCK to Res.string.browser_pill_decision_block) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(start = 52.dp)) { + options.forEachIndexed { index, (option, label) -> + SegmentedButton( + selected = decision == option, + onClick = { onChange(option) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + icon = {}, + label = { Text(stringRes(label), textAlign = TextAlign.Center, maxLines = 1) }, + ) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt new file mode 100644 index 0000000000..1dc373369b --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** Shapes and sizes shared by every pill surface, so they read as one family. */ +object PillDefaults { + val SheetShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) + val CardShape = RoundedCornerShape(20.dp) + val TileShape = RoundedCornerShape(18.dp) + val SheetPadding = 16.dp + val TileHeight = 88.dp + val FieldHeight = 48.dp + + /** The edge every floating pill surface draws, so it stays visible where shadows don't (black theme). */ + @Composable + fun hairline(): BorderStroke = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) +} + +/** + * A site's stand-in icon: its first letter on a tonal rounded square. Real favicons can drop in later + * (the launcher already stores them per host); the monogram keeps the header from ever being empty. + */ +@Composable +fun SiteMonogram( + label: String, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val letter = label.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "•" + Box( + modifier + .size(size) + .clip(RoundedCornerShape(size * 0.3f)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + letter, + style = if (size >= 40.dp) MaterialTheme.typography.titleMedium else MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +/** + * The origin as a read-only field: security badge in its signal colour, host, and a trailing pencil that + * says "this is where the address lives". Tapping edits the address; long-pressing copies the link. + * Sandboxed apps get a non-editable version with no pencil. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OriginField( + ui: BrowserPillUi, + onEdit: (() -> Unit)?, + onLongPress: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val security = ui.security + Row( + modifier + .fillMaxWidth() + .heightIn(min = PillDefaults.FieldHeight) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .combinedClickable( + enabled = onEdit != null || onLongPress != null, + role = Role.Button, + onClick = { onEdit?.invoke() }, + onLongClick = onLongPress, + ).padding(start = 16.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SecurityIcon(security, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f).padding(vertical = 6.dp)) { + Text( + if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Only the states worth a second look get words: plain HTTP and Tor. + if (!ui.chrome.isSandbox && (security == BrowserChrome.Security.HTTP || security == BrowserChrome.Security.TOR)) { + Text( + stringRes(securityLabel(security)), + style = MaterialTheme.typography.labelSmall, + color = securityTint(security), + maxLines = 1, + ) + } + } + if (onEdit != null) { + IconButton(onClick = onEdit) { + Icon(MaterialSymbols.Edit, contentDescription = stringRes(Res.string.browser_pill_edit_address), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + Spacer(Modifier.width(10.dp)) + } + } +} + +/** + * The navigation capsule: Chrome's back · forward · reload/stop · star · share on one rounded bar. Back + * and forward dim when unavailable; the star fills when pinned; reload turns into stop, ringed by the load. + */ +@Composable +fun NavigationCapsule( + ui: BrowserPillUi, + onAction: (Action) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + BrowserChrome.iconRow(ui.chrome).forEach { action -> + val enabled = BrowserChrome.isEnabled(ui.chrome, action) + val pinned = action == Action.FAVORITE && ui.isFavorite + IconButton( + onClick = { onAction(action) }, + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors(disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)), + ) { + Box(contentAlignment = Alignment.Center) { + if (action == Action.STOP && ui.loadProgress != null) { + CircularProgressIndicator( + progress = { ui.loadProgress }, + modifier = Modifier.size(34.dp), + strokeWidth = 2.dp, + trackColor = Color.Transparent, + ) + } + PillActionIcon( + action = action, + tint = + when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + pinned -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + size = if (action == Action.STOP) 20.dp else 24.dp, + filled = pinned, + contentDescription = stringRes(pillLabelFor(action, ui.isFavorite)), + ) + } + } + } + } +} + +/** + * A page-action tile: icon over a two-line label on a rounded card. A toggle tile ([selected] non-null) + * fills with the secondary container while on, so its state reads without a switch. + */ +@Composable +fun ActionTile( + symbol: MaterialSymbol, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean? = null, + description: String = label, +) { + val on = selected == true + Surface( + onClick = onClick, + modifier = modifier.height(PillDefaults.TileHeight).semantics(mergeDescendants = true) { contentDescription = description }, + shape = PillDefaults.TileShape, + color = if (on) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer, + contentColor = if (on) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface, + ) { + Column( + // Top-aligned so every icon in a row sits on one line, whether its label takes one line or two. + Modifier.padding(start = 4.dp, end = 4.dp, top = 16.dp, bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Icon(symbol, contentDescription = null, modifier = Modifier.size(24.dp), filled = on) + Spacer(Modifier.height(6.dp)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** A rounded group of rows (the privacy card), with an optional heading above it. */ +@Composable +fun GroupCard( + heading: String?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier.fillMaxWidth()) { + if (heading != null) { + Text( + heading, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 6.dp), + ) + } + Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Column(Modifier.padding(vertical = 4.dp), content = content) + } + } +} + +/** A divider inset past a [GroupRow]'s leading icon. */ +@Composable +fun GroupDivider() { + HorizontalDivider(Modifier.padding(start = 64.dp, end = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) +} + +/** + * One row of a [GroupCard]: a tinted icon circle, a title with a supporting line that states the + * consequence, and a trailing control (switch, chevron, badge). + */ +@Composable +fun GroupRow( + icon: @Composable () -> Unit, + iconContainer: Color, + title: String, + supporting: String?, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + supportingColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + trailing: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(40.dp).clip(CircleShape).background(iconContainer), contentAlignment = Alignment.Center) { icon() } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (supporting != null) { + Text(supporting, style = MaterialTheme.typography.bodySmall, color = supportingColor, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + Spacer(Modifier.width(8.dp)) + trailing() + } +} + +/** A small count badge (console errors). */ +@Composable +fun CountBadge( + count: Int, + container: Color = MaterialTheme.colorScheme.errorContainer, + content: Color = MaterialTheme.colorScheme.onErrorContainer, +) { + Box( + Modifier + .heightIn(min = 22.dp) + .clip(CircleShape) + .background(container) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(if (count > 99) "99+" else count.toString(), style = MaterialTheme.typography.labelMedium, color = content) + } +} + +/** Dims content that is shown but not usable. */ +fun Modifier.dimmed(enabled: Boolean): Modifier = if (enabled) this else this.alpha(0.38f) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt new file mode 100644 index 0000000000..abcb5c8ac7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access +import com.vitorpamplona.amethyst.commons.resources.browser_pill_add_home +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console +import com.vitorpamplona.amethyst.commons.resources.browser_pill_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_desktop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address +import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_add +import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_remove +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find +import com.vitorpamplona.amethyst.commons.resources.browser_pill_forward +import com.vitorpamplona.amethyst.commons.resources.browser_pill_full_screen +import com.vitorpamplona.amethyst.commons.resources.browser_pill_other_browser +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_camera +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_location +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_microphone +import com.vitorpamplona.amethyst.commons.resources.browser_pill_reload +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_http +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_https +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_sandbox +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_share +import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings +import com.vitorpamplona.amethyst.commons.resources.browser_pill_stop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_size +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_desktop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_find +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_full +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_home +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_other +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_text +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_title +import org.jetbrains.compose.resources.StringResource +import androidx.compose.material3.Icon as Material3Icon + +/** Tor's onion, as a vector (the same artwork as the Android `ic_tor` drawable). */ +val OnionIcon: ImageVector by lazy { + ImageVector + .Builder(name = "Onion", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f) + .addPath(pathData = PathParser().parsePathString(ONION_PATH).toNodes(), fill = SolidColor(Color.Black)) + .build() +} + +private const val ONION_PATH = "M17.578,12.201c-0.76,-0.692 -1.721,-1.251 -2.704,-1.81 -0.446,-0.246 -1.81,-1.318 -1.34,-2.838l-0.851,-0.358c1.342,-2.078 3.085,-4.134 5.229,-6.056 -1.721,0.581 -3.24,1.476 -4.379,3.062 0.67,-1.407 1.765,-2.793 2.972,-4.201 -1.654,1.185 -3.084,2.525 -3.979,4.313l0.627,-2.503c-0.894,1.608 -1.52,3.24 -1.766,4.871l-1.317,-0.535 -0.223,0.178c1.162,2.078 0.559,3.174 -0.022,3.553 -1.162,0.783 -2.838,1.788 -3.688,2.659 -1.609,1.654 -2.078,3.218 -1.921,5.296 0.157,2.66 2.101,4.873 4.67,5.744 1.14,0.38 2.19,0.424 3.352,0.424 1.877,0 3.799,-0.491 5.207,-1.676a6.551,6.551 0,0 0,2.369 -5.027,6.875 6.875,0 0,0 -2.236,-5.096zM14.025,21.073c-0.09,0.402 -0.38,0.894 -0.737,1.341 0.134,-0.246 0.246,-0.492 0.313,-0.76 0.559,-1.989 0.805,-2.904 0.537,-5.095 -0.045,-0.224 -0.135,-0.938 -0.471,-1.721 -0.468,-1.185 -1.184,-2.303 -1.272,-2.548 -0.157,-0.38 -0.38,-1.989 -0.403,-3.084 0.023,0.938 0.089,2.659 0.335,3.329 0.067,0.225 0.715,1.229 1.185,2.459 0.312,0.849 0.38,1.632 0.446,1.854 0.224,1.007 -0.045,2.705 -0.401,4.313 -0.111,0.581 -0.426,1.252 -0.828,1.766 0.225,-0.313 0.402,-0.715 0.537,-1.185 0.269,-0.938 0.38,-2.145 0.356,-2.905 -0.021,-0.446 -0.222,-1.407 -0.558,-2.278 -0.201,-0.47 -0.492,-0.961 -0.692,-1.297 -0.224,-0.335 -0.224,-1.072 -0.313,-1.921 0.021,0.916 -0.068,1.385 0.156,2.033 0.134,0.379 0.625,0.916 0.759,1.43 0.201,0.693 0.402,1.453 0.381,1.922 0,0.536 -0.022,1.52 -0.269,2.593 -0.157,0.804 -0.515,1.497 -1.095,1.943 0.246,-0.312 0.38,-0.625 0.447,-0.938 0.089,-0.469 0.111,-0.916 0.156,-1.475a5.96,5.96 0,0 0,-0.111 -1.721c-0.179,-0.805 -0.469,-1.608 -0.604,-2.168 0.022,0.626 0.269,1.408 0.381,2.235 0.089,0.604 0.044,1.206 0.021,1.742 -0.021,0.627 -0.223,1.722 -0.492,2.258 -0.268,-0.112 -0.357,-0.269 -0.537,-0.491 -0.223,-0.291 -0.357,-0.604 -0.491,-0.962a5.043,5.043 0,0 1,-0.291 -0.915,3.071 3.071,0 0,1 0.559,-2.213c0.469,-0.671 0.559,-0.716 0.715,-1.497 -0.223,0.692 -0.379,0.759 -0.871,1.341 -0.559,0.647 -0.648,1.586 -0.648,2.346 0,0.313 0.134,0.671 0.246,1.007 0.134,0.356 0.268,0.714 0.447,0.982 0.134,0.223 0.313,0.379 0.469,0.491 -0.581,-0.156 -1.184,-0.379 -1.564,-0.692 -0.938,-0.805 -1.765,-2.167 -1.877,-3.375 -0.089,-0.982 0.804,-2.413 2.078,-3.128 1.073,-0.626 1.318,-1.319 1.542,-2.459 -0.313,0.983 -0.626,1.833 -1.654,2.348 -1.475,0.804 -2.235,2.1 -2.167,3.352 0.112,1.586 0.737,2.682 2.011,3.554 0.291,0.2 0.693,0.401 1.118,0.559 -1.587,-0.381 -1.788,-0.604 -2.324,-1.229 0,-0.045 -0.134,-0.135 -0.134,-0.156 -0.715,-0.805 -1.609,-2.19 -1.922,-3.464 -0.112,-0.447 -0.224,-0.916 -0.089,-1.363 0.581,-2.101 1.854,-2.905 3.128,-3.775 0.313,-0.225 0.626,-0.426 0.916,-0.649 0.715,-0.559 0.894,-2.012 1.05,-2.838 -0.29,1.006 -0.603,2.258 -1.162,2.659 -0.29,0.224 -0.648,0.402 -0.938,0.604 -1.318,0.894 -2.637,1.743 -3.24,3.91 -0.134,0.56 -0.044,0.962 0.089,1.498 0.335,1.317 1.229,2.748 1.989,3.597l0.134,0.135c0.335,0.381 0.76,0.67 1.274,0.871a5.945,5.945 0,0 1,-1.296 -0.469c-2.078,-1.005 -3.463,-3.173 -3.553,-4.939 -0.179,-3.597 1.542,-4.647 3.151,-5.966 0.894,-0.737 2.145,-1.095 2.86,-2.413 0.134,-0.291 0.224,-0.916 0.045,-1.587 -0.067,-0.224 -0.402,-1.028 -0.537,-1.207l1.989,0.872c-0.044,0.938 -0.067,1.698 0.112,2.391 0.2,0.76 1.184,1.854 1.586,3.129 0.783,2.41 0.583,5.561 0.023,8.019z" + +/** The glyph for [action]; null means it's drawn with [OnionIcon]. */ +fun pillSymbolFor(action: Action): MaterialSymbol? = + when (action) { + Action.BACK -> MaterialSymbols.AutoMirrored.ArrowBack + Action.FORWARD -> MaterialSymbols.AutoMirrored.ArrowForward + Action.RELOAD -> MaterialSymbols.Refresh + Action.STOP -> MaterialSymbols.Close + Action.FAVORITE -> MaterialSymbols.Star + Action.SHARE -> MaterialSymbols.Share + Action.BACK_TO_APP -> MaterialSymbols.Home + Action.COPY_LINK -> MaterialSymbols.ContentCopy + Action.EDIT_ADDRESS -> MaterialSymbols.Edit + Action.FIND_IN_PAGE -> MaterialSymbols.Search + Action.TEXT_SIZE -> MaterialSymbols.FormatSize + Action.DESKTOP_SITE -> MaterialSymbols.DesktopWindows + Action.ADD_TO_HOME_SCREEN -> MaterialSymbols.AddToHomeScreen + Action.OPEN_IN_BROWSER_APP -> MaterialSymbols.OpenInBrowser + Action.OPEN_FULL_SCREEN -> MaterialSymbols.OpenInFull + Action.TOR -> null + Action.ACCESS_INFO -> MaterialSymbols.Shield + Action.SITE_SETTINGS -> MaterialSymbols.Tune + Action.CONSOLE -> MaterialSymbols.Code + } + +fun pillLabelFor( + action: Action, + isFavorite: Boolean = false, +): StringResource = + when (action) { + Action.BACK -> Res.string.browser_pill_back + Action.FORWARD -> Res.string.browser_pill_forward + Action.RELOAD -> Res.string.browser_pill_reload + Action.STOP -> Res.string.browser_pill_stop + Action.FAVORITE -> if (isFavorite) Res.string.browser_pill_favorite_remove else Res.string.browser_pill_favorite_add + Action.SHARE -> Res.string.browser_pill_share + Action.BACK_TO_APP -> Res.string.browser_pill_back_to_app + Action.COPY_LINK -> Res.string.browser_pill_copy + Action.EDIT_ADDRESS -> Res.string.browser_pill_edit_address + Action.FIND_IN_PAGE -> Res.string.browser_pill_find + Action.TEXT_SIZE -> Res.string.browser_pill_text_size + Action.DESKTOP_SITE -> Res.string.browser_pill_desktop + Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_add_home + Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_other_browser + Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_full_screen + Action.TOR -> Res.string.browser_pill_tor_title + Action.ACCESS_INFO -> Res.string.browser_pill_access + Action.SITE_SETTINGS -> Res.string.browser_pill_site_settings + Action.CONSOLE -> Res.string.browser_pill_console + } + +/** + * The short label a page-action tile shows under its icon (tiles are ~88dp wide); the full + * [pillLabelFor] wording stays the accessibility description. + */ +fun pillTileLabelFor(action: Action): StringResource = + when (action) { + Action.COPY_LINK -> Res.string.browser_pill_tile_copy + Action.FIND_IN_PAGE -> Res.string.browser_pill_tile_find + Action.TEXT_SIZE -> Res.string.browser_pill_tile_text + Action.DESKTOP_SITE -> Res.string.browser_pill_tile_desktop + Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_tile_home + Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_tile_other + Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_tile_full + else -> pillLabelFor(action) + } + +fun securityLabel(security: Security): StringResource = + when (security) { + Security.TOR -> Res.string.browser_pill_security_tor + Security.HTTPS -> Res.string.browser_pill_security_https + Security.HTTP -> Res.string.browser_pill_security_http + Security.SANDBOX -> Res.string.browser_pill_security_sandbox + } + +fun permissionLabel(permission: BrowserSitePermission): StringResource = + when (permission) { + BrowserSitePermission.CAMERA -> Res.string.browser_pill_permission_camera + BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_permission_microphone + BrowserSitePermission.LOCATION -> Res.string.browser_pill_permission_location + } + +fun permissionSymbol(permission: BrowserSitePermission): MaterialSymbol = + when (permission) { + BrowserSitePermission.CAMERA -> MaterialSymbols.Videocam + BrowserSitePermission.MICROPHONE -> MaterialSymbols.Mic + BrowserSitePermission.LOCATION -> MaterialSymbols.LocationOn + } + +/** The colour that signals [security]: the error tone for plain HTTP, the Tor accent for onion routing. */ +@Composable +fun securityTint(security: Security): Color = + when (security) { + Security.HTTP -> MaterialTheme.colorScheme.error + Security.TOR -> MaterialTheme.colorScheme.tertiary + Security.SANDBOX -> MaterialTheme.colorScheme.primary + Security.HTTPS -> MaterialTheme.colorScheme.onSurfaceVariant + } + +/** Draws [action]'s icon (the onion for Tor). */ +@Composable +fun PillActionIcon( + action: Action, + tint: Color, + size: Dp = 24.dp, + filled: Boolean = false, + contentDescription: String? = null, +) { + val symbol = pillSymbolFor(action) + if (symbol != null) { + Icon(symbol, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint, filled = filled) + } else { + Material3Icon(OnionIcon, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint) + } +} + +/** Draws the badge for [security] in its signal colour. */ +@Composable +fun SecurityIcon( + security: Security, + size: Dp = 18.dp, + tint: Color = securityTint(security), +) { + when (security) { + Security.TOR -> Material3Icon(OnionIcon, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.HTTPS -> Icon(MaterialSymbols.Lock, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.HTTP -> Icon(MaterialSymbols.NoEncryption, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.SANDBOX -> Icon(MaterialSymbols.Shield, contentDescription = null, modifier = Modifier.size(size), tint = tint, filled = true) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 595b7cf845..6920115b06 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -77,11 +77,13 @@ object MaterialSymbols { val CloudSync = MaterialSymbol("\uEB5A") val CloudUpload = MaterialSymbol("\uE2C3") val Code = MaterialSymbol("\uE86F") + val Cookie = MaterialSymbol("\uEAAC") val Collections = MaterialSymbol("\uE3D3") val CollectionsBookmark = MaterialSymbol("\uE431") val Commit = MaterialSymbol("\uEAF5") val ContentCopy = MaterialSymbol("\uE14D") val ContentPaste = MaterialSymbol("\uE14F") + val ContentPasteGo = MaterialSymbol("\uEA8E") val CurrencyBitcoin = MaterialSymbol("\uEBC5") val Dashboard = MaterialSymbol("\uE871") val DateRange = MaterialSymbol("\uE916") @@ -173,6 +175,7 @@ object MaterialSymbols { val News = MaterialSymbol("\uE032") val NoAccounts = MaterialSymbol("\uF03E") val NoEncryption = MaterialSymbol("\uF03F") + val NorthWest = MaterialSymbol("\uF1E2", autoMirror = true) val Notifications = MaterialSymbol("\uE7F5") val NotificationsOff = MaterialSymbol("\uE7F6") val Numbers = MaterialSymbol("\uEAC7") diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt new file mode 100644 index 0000000000..3ac0d504e9 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.EncodedImageFormat +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders every browser-pill prototype offscreen (no device, no display) through `ImageComposeScene`, in the + * dark and light Amethyst themes side by side, and writes each to `commonsUI/build/browser-pill/.png`. + * + * It exists so the redesign can be *looked at* while it's built — see + * `amethyst/plans/2026-09-26-browser-ui-review.md` — and it fails if a screen throws or draws nothing. + */ +class BrowserPillRenderTest { + private val outDir = File("build/browser-pill").apply { mkdirs() } + + private fun render( + name: String, + widthDp: Int, + heightDp: Int, + content: @Composable () -> Unit, + ) { + val density = 2f + val width = (widthDp * density).toInt() + val height = (heightDp * density).toInt() + val scene = ImageComposeScene(width = width, height = height, density = Density(density), content = content) + try { + // Strings and fonts load asynchronously from compose resources: let a few frames settle. + var image = scene.render(0) + repeat(SETTLE_FRAMES) { frame -> + Thread.sleep(FRAME_MILLIS) + image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L) + } + val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode $name") + File(outDir, "$name.png").writeBytes(png.bytes) + + val pixels = image.peekPixels() ?: error("no pixels for $name") + val distinct = HashSet() + for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y) + assertTrue(distinct.size > 20, "$name rendered almost nothing (${distinct.size} colours)") + } finally { + scene.close() + } + } + + @Test fun expanded() = render("01-expanded", 820, 1000) { BrowserPillExpandedPreview() } + + @Test fun torOutOfScope() = render("02-tor-out-of-scope", 820, 1100) { BrowserPillTorOutOfScopePreview() } + + @Test fun addressEditor() = render("03-address-editor", 820, 700) { BrowserPillAddressEditorPreview() } + + @Test fun napplet() = render("04-napplet", 820, 800) { BrowserPillNappletPreview() } + + @Test fun handles() = render("05-handles", 820, 160) { PillHandlesPreview() } + + @Test fun find() = render("06-find", 820, 220) { FindInPagePreview() } + + @Test fun console() = render("07-console", 820, 380) { ConsoleSheetPreview() } + + @Test fun permission() = render("08-permission", 820, 700) { PermissionPromptPreview() } + + @Test fun dialog() = render("09-dialog", 820, 560) { PageDialogPreview() } + + @Test fun leave() = render("10-leave", 820, 420) { LeaveSiteDialogPreview() } + + @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() } + + private companion object { + const val SETTLE_FRAMES = 12 + const val FRAME_MILLIS = 60L + } +}