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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
Claude
2026-09-26 17:33:13 +00:00
parent 40d06f4152
commit 7267d6c359
14 changed files with 2892 additions and 0 deletions
@@ -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.
@@ -5351,4 +5351,104 @@
<string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string>
<string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string>
<string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string>
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
<string name="browser_pill_close">Close</string>
<string name="browser_pill_tile_copy">Copy link</string>
<string name="browser_pill_tile_find">Find</string>
<string name="browser_pill_tile_text">Text size</string>
<string name="browser_pill_tile_desktop">Desktop</string>
<string name="browser_pill_tile_home">Add to Home</string>
<string name="browser_pill_tile_other">Other browser</string>
<string name="browser_pill_tile_full">Full screen</string>
<string name="browser_pill_edit_address">Edit address</string>
<string name="browser_pill_back">Back</string>
<string name="browser_pill_forward">Forward</string>
<string name="browser_pill_reload">Reload</string>
<string name="browser_pill_stop">Stop loading</string>
<string name="browser_pill_favorite_add">Add to favorites</string>
<string name="browser_pill_favorite_remove">Remove from favorites</string>
<string name="browser_pill_share">Share</string>
<string name="browser_pill_copy">Copy link</string>
<string name="browser_pill_find">Find in page</string>
<string name="browser_pill_text_size">Text size</string>
<string name="browser_pill_add_home">Add to Home</string>
<string name="browser_pill_desktop">Desktop site</string>
<string name="browser_pill_other_browser">Open in browser</string>
<string name="browser_pill_full_screen">Full screen</string>
<string name="browser_pill_left_site">You left %1$s</string>
<string name="browser_pill_back_to_app">Back to app</string>
<string name="browser_pill_text_smaller">Smaller text</string>
<string name="browser_pill_text_larger">Larger text</string>
<string name="browser_pill_text_reset">Reset</string>
<string name="browser_pill_text_value">%1$d%</string>
<string name="browser_pill_privacy">Privacy</string>
<string name="browser_pill_tor_title">Onion routing</string>
<string name="browser_pill_tor_on">The site can't see your IP address</string>
<string name="browser_pill_tor_off">The site can see your IP address</string>
<string name="browser_pill_site_settings">Site settings</string>
<string name="browser_pill_site_settings_none">Nothing allowed yet</string>
<string name="browser_pill_access">What it can access</string>
<string name="browser_pill_access_desc">Your keys never leave Amethyst</string>
<string name="browser_pill_console">Console</string>
<string name="browser_pill_security_https">Secure connection</string>
<string name="browser_pill_security_http">Not secure</string>
<string name="browser_pill_security_tor">Onion-routed</string>
<string name="browser_pill_security_sandbox">Sandboxed app</string>
<string name="browser_pill_permission_camera">Camera</string>
<string name="browser_pill_permission_microphone">Microphone</string>
<string name="browser_pill_permission_location">Location</string>
<string name="browser_pill_decision_ask">Ask</string>
<string name="browser_pill_decision_allow">Allow</string>
<string name="browser_pill_decision_block">Block</string>
<string name="browser_pill_permission_state">%1$s %2$s</string>
<string name="browser_pill_decision_allowed">allowed</string>
<string name="browser_pill_decision_blocked">blocked</string>
<string name="browser_pill_address_hint">Search or enter address</string>
<string name="browser_pill_go">Go</string>
<string name="browser_pill_clear">Clear</string>
<string name="browser_pill_paste_go">Paste and go</string>
<string name="browser_pill_fill_in">Use this address</string>
<string name="browser_pill_find_hint">Find in page</string>
<string name="browser_pill_find_count">%1$d / %2$d</string>
<string name="browser_pill_find_none">No matches</string>
<string name="browser_pill_find_previous">Previous match</string>
<string name="browser_pill_find_next">Next match</string>
<string name="browser_pill_find_close">Close find in page</string>
<string name="browser_pill_console_all">All</string>
<string name="browser_pill_console_errors">Errors</string>
<string name="browser_pill_console_warnings">Warnings</string>
<string name="browser_pill_console_copy">Copy</string>
<string name="browser_pill_console_clear">Clear</string>
<string name="browser_pill_console_empty">Nothing logged yet</string>
<string name="browser_pill_perm_title">This site wants to use</string>
<string name="browser_pill_perm_camera_desc">See what your camera sees</string>
<string name="browser_pill_perm_microphone_desc">Hear what your microphone hears</string>
<string name="browser_pill_perm_location_desc">Know roughly where you are</string>
<string name="browser_pill_perm_tor_note">Calls can reveal your IP address even over Tor.</string>
<string name="browser_pill_perm_allow">Allow while visiting</string>
<string name="browser_pill_perm_once">Only this time</string>
<string name="browser_pill_perm_deny">Don't allow</string>
<string name="browser_pill_dialog_says">%1$s says</string>
<string name="browser_pill_dialog_generic">This page says</string>
<string name="browser_pill_dialog_block">Don't let this page show more dialogs</string>
<string name="browser_pill_dialog_answer">Your answer</string>
<string name="browser_pill_dialog_leave_title">Leave site?</string>
<string name="browser_pill_dialog_leave_message">Changes you made may not be saved.</string>
<string name="browser_pill_dialog_leave">Leave</string>
<string name="browser_pill_ok">OK</string>
<string name="browser_pill_cancel">Cancel</string>
<string name="browser_pill_info_connection">Connection</string>
<string name="browser_pill_info_https">Connection is encrypted</string>
<string name="browser_pill_info_https_desc">Information you send can't be read on the way.</string>
<string name="browser_pill_info_http">Connection is not encrypted</string>
<string name="browser_pill_info_http_desc">Don't enter passwords or payment details.</string>
<string name="browser_pill_info_tor">Onion-routed over Tor</string>
<string name="browser_pill_info_open">Open web</string>
<string name="browser_pill_info_certificate">Certificate</string>
<string name="browser_pill_info_certificate_desc">Issued to %1$s by %2$s · valid until %3$s</string>
<string name="browser_pill_info_permissions">Permissions</string>
<string name="browser_pill_info_site_data">Cookies and site data</string>
<string name="browser_pill_info_site_data_desc">Logins and preferences this site saved for this account</string>
<string name="browser_pill_info_clear">Clear site data</string>
<string name="browser_pill_info_clear_confirm">Sign out of this site and delete its data?</string>
</resources>
@@ -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<AddressSuggestion>,
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)
}
}
}
@@ -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<AddressSuggestion> = 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<AddressSuggestion> = 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<Action>,
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<Action>,
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<BrowserSitePermission, BrowserSitePermission.Decision>): 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() })
}
}
}
@@ -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<BrowserSitePermission, BrowserSitePermission.Decision> = 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 }
}
@@ -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)
}
}
}
@@ -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<ConsoleLine>,
onCopy: (List<ConsoleLine>) -> 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),
)
}
}
}
@@ -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))
}
}
}
}
@@ -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<BrowserSitePermission>,
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) },
)
}
}
}
}
@@ -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)
@@ -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)
}
}
@@ -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")
@@ -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/<name>.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<Int>()
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
}
}