mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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.
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
+219
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+503
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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 }
|
||||
}
|
||||
+240
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+231
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+476
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+348
@@ -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)
|
||||
+215
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
@@ -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")
|
||||
|
||||
+96
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user