web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat: favorites — a strip of one-click pills in the bar, apart from bookmarks
The star is the archive; favorites are the handful of places worth a
permanent spot. They show as a row of label pills between the tab row and
the controls row whenever there are any (no empty row — the bar stays two
rows otherwise, so controls_y is now measured from the bar's bottom edge).
Click loads in the active tab and folds the bar; middle-click opens a new
tab and leaves it out. Pills take their label's width up to FAV_MAX_W and
the strip stops at the bar's edge rather than scrolling.
Add/remove: Ctrl+Shift+D, the right-click menu's "Add to Favorites", or the
new `favorite` link on a bookmark row (which keeps the starred title).
cce://favorites (about:favorites, Ctrl+Shift+B) lists, reorders (up/down),
renames (a GET form per row) and removes; the store is favorites.tsv beside
bookmarks.tsv, sharing Entry and the TSV helpers. Labels default to the
title, else the host without www., else the file name; internal pages are
refused.
Both hosts carry the store and expose favorites()/active_favorited()/
toggle_favorite(); the chrome keeps a snapshot refreshed with the page state,
which is how the page's edits reach the strip. The Servo backend is mirrored
by analogy and not built.
Verified in a scale-2 shadow: add via route, pill hover, click-to-load and
fold, chord toggle both ways, context-menu entry, rename form, bookmarks
cross-reference. 19 tests pass (3 new for the store).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 46 +++++++--
src/main.rs | 177 ++++++++++++++++++++++++++++++--
src/pages.rs | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/webview.rs | 29 +++++-
src/wpe/host.rs | 25 +++++
5 files changed, 554 insertions(+), 31 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 91cea97..38fe1c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,7 @@ Eight files, ~3k lines:
| `src/instance.rs` | single-instance forwarding: a later launch hands its argument to the running instance's socket and exits |
| `src/bin/open.rs` | `cce-browser-open`, the desktop entry's `Exec` target: a ~500KB forwarder linking only libc (~4ms vs ~22ms through the full binary), exec'ing `cce-browser` when no instance answers |
| `src/webview.rs` | `ServoHost` — Servo boot, the delegate, one `WebView` per tab, the frame pipeline |
-| `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks stores |
+| `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks / Favorites stores |
| `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
| `src/session.rs` | open-tab persistence: the tab set survives a restart |
| `src/settings.rs` | the per-app KDL config |
@@ -166,9 +166,34 @@ are choices, not accidents:
There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
`PaintCtx` primitives in `display_list` (`display_list_text()` returns `true`), and
every hit test in `handle_mouse_input` re-derives the same rects from the same
-`bar_rect`/`tab_rect`/`btn_rect`/`url_rect` helpers. **Draw and hit-test are two
+`bar_rect`/`tab_rect`/`btn_rect`/`url_rect`/`fav_rects` helpers. **Draw and hit-test are two
readings of one geometry** — change a rect helper, not one call site.
+### Favorites are not bookmarks
+
+Two stores, two meanings. The **star** (`Ctrl+D`, `cce://bookmarks`) is the
+archive: everything worth finding again, newest first. **Favorites**
+(`Ctrl+Shift+D`, the right-click menu's "Add to Favorites", or the
+`favorite` link on a bookmark row; managed at `cce://favorites` /
+`about:favorites`, `Ctrl+Shift+B`) are the handful of places worth a
+permanent one-click spot: a **strip of label pills inside the bar**, between
+the tab row and the controls row. Click loads the favorite in the active tab
+and folds the bar (a menu pick); middle-click opens it in a new tab and
+leaves the bar out. Insertion order is strip order; the page reorders
+(▲/▼), renames (a GET form per row — form submissions reach the `cce:`
+handler like any other navigation) and removes.
+
+Geometry points that are choices: the bar has **no empty row** — with no
+favorites it is the two-row bar it always was (`bar_h(favorites)`), so
+`controls_y` is measured from the bar's *bottom* edge rather than counted
+down from the top. The strip does not scroll or wrap: pills take their
+label's width up to `FAV_MAX_W`, and `fav_rects` simply stops at the bar's
+edge, so a too-long strip loses its tail. The chrome keeps a snapshot
+(`favs`) refreshed with the rest of the page state, which is also how edits
+made on the `cce://favorites` page — on the way into a navigation — reach
+the strip. A label defaults to the page title, else the host (`www.`
+stripped), else the file name; internal pages are refused.
+
### The bar is a circle menu
The chrome's persistent element is the **DE's corner control** —
@@ -230,10 +255,11 @@ applies the inverted delta itself.
## `cce://` pages
`CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
-`cce://history`, `cce://bookmarks`, `cce://downloads` and `cce://cookies` are **real
-pages fetched through Servo's network stack** and rendered like any other. That is why
-every mutating action is an ordinary link (`cce://history/clear`,
-`cce://bookmarks/remove?url=…`) — no chrome plumbing needed.
+`cce://history`, `cce://bookmarks`, `cce://favorites`, `cce://downloads` and
+`cce://cookies` are **real pages fetched through Servo's network stack** and rendered
+like any other. That is why every mutating action is an ordinary link
+(`cce://history/clear`, `cce://bookmarks/remove?url=…`,
+`cce://favorites/up?url=…`) — no chrome plumbing needed.
### Servo leaks a document per load — the biggest live hazard
@@ -262,9 +288,11 @@ therefore *cannot reach Servo itself*: `cce://cookies/clear` sets an `AtomicBool
the next `pump` acts on via `site_data_manager()`. Anything else needing engine access
from a page has to take the same route.
-History and bookmarks are TSV under `~/.local/state/cce/browser/`; `sanitize()` strips
-tabs and newlines because the format has no escaping. All four pages share the `page()`
-skeleton — restyle there, not per page.
+History, bookmarks and favorites are TSV under `~/.local/state/cce/browser/`;
+`sanitize()` strips tabs and newlines because the format has no escaping. All the
+pages share the `page()` skeleton — restyle there, not per page. A page's own
+`<style>` goes in through `head_extra`, which lands *before* the skeleton's, so
+an override has to out-specify it (`.e .w`, not `.w`).
Clearing cookies is a **confirm-then-act page**, and Ctrl+Shift+Delete opens it rather
than clearing outright: sessions persist now, so an accidental chord would sign the
diff --git a/src/main.rs b/src/main.rs
index 1f19595..9fbcadb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,8 +4,8 @@
//! finished frame is read back and uploaded to cce-ui's image registry,
//! then drawn as a single quad under the chrome: the DE's circular corner
//! control (`cce_ui::widget::plate_dock`), which here toggles the utility
-//! bar (tabs, back / forward / reload, URL field) that unfolds from under
-//! it. Input over the page area is translated into Servo input events; the
+//! bar (tabs, the favorites strip, back / forward / reload, URL field) that
+//! unfolds from under it. Input over the page area is translated into Servo input events; the
//! URL bar is a small hand-rolled line editor.
mod downloads;
@@ -53,8 +53,13 @@ pub enum EditingCommand {
}
const BAR_MARGIN: f32 = 10.0;
-/// Two rows: tab strip on top, nav controls + URL field below.
-const BAR_H: f32 = BAR_PAD + TAB_H + ROW_GAP + BTN_H + BAR_PAD;
+/// Two rows — tab strip on top, nav controls + URL field below — with the
+/// favorites strip between them whenever there is one to show. The bar
+/// does not carry an empty row: no favorites, no strip, two-row bar.
+fn bar_h(favorites: bool) -> f32 {
+ let favs = if favorites { FAV_H + ROW_GAP } else { 0.0 };
+ BAR_PAD + TAB_H + ROW_GAP + favs + BTN_H + BAR_PAD
+}
const BAR_RADIUS: f32 = 10.0;
const BAR_PAD: f32 = 7.0;
/// Seconds for the bar to unfold from the corner control (and back).
@@ -79,6 +84,14 @@ const TAB_CLOSE_MIN_W: f32 = 72.0;
const TAB_CLOSE_W: f32 = 18.0;
const PLUS_W: f32 = 26.0;
const ROW_GAP: f32 = 6.0;
+/// The favorites strip: a row of pills, each one page. Pills take their
+/// label's width up to `FAV_MAX_W`, and the strip simply stops at the bar's
+/// edge — favorites are a handful by design, not a scrolling list.
+const FAV_H: f32 = 22.0;
+const FAV_GAP: f32 = 4.0;
+const FAV_MAX_W: f32 = 150.0;
+const FAV_PAD_X: f32 = 9.0;
+const FAV_FONT: f32 = 12.0;
/// Utility-bar fill; the negative alpha marks the plate as blur-behind.
/// The blurred page is the base and this color tints it at |alpha|
/// opacity — keep |alpha| low so the frosted content shows through.
@@ -221,6 +234,8 @@ enum CtxAction {
/// Fetch through WebKit's download pipeline.
Download(String),
OpenExternal,
+ /// Add the page to the favorites strip, or take it out.
+ ToggleFavorite,
}
#[cfg(feature = "wpe")]
@@ -309,6 +324,13 @@ struct BrowserApp {
font_system: cce_ui::cosmic_text::FontSystem,
/// The open-tab set, persisted across restarts (see `session.rs`).
session: session::Session,
+ /// The favorites store, shared with the host (and so with the
+ /// `cce://favorites` page, which edits it); `favs` is the strip as last
+ /// read from it, refreshed with the rest of the page state.
+ favorites: std::sync::Arc<pages::Favorites>,
+ favs: Vec<pages::Favorite>,
+ /// Hovered pill in the favorites strip — a repaint, like the dot.
+ fav_hover: Option<usize>,
}
fn hit(r: &Rect, x: f32, y: f32) -> bool {
@@ -320,16 +342,17 @@ fn hit(r: &Rect, x: f32, y: f32) -> bool {
/// way, so nothing but the chrome geometry depends on this. Every other
/// bar-relative rect below is derived from this one — never from
/// `BAR_MARGIN` directly, or it would stay pinned to the top.
-fn bar_rect(win: (f32, f32), position: settings::BarPosition) -> Rect {
+fn bar_rect(win: (f32, f32), position: settings::BarPosition, favorites: bool) -> Rect {
+ let h = bar_h(favorites);
let y = match position {
settings::BarPosition::Top => BAR_MARGIN,
- settings::BarPosition::Bottom => (win.1 - BAR_MARGIN - BAR_H).max(BAR_MARGIN),
+ settings::BarPosition::Bottom => (win.1 - BAR_MARGIN - h).max(BAR_MARGIN),
};
Rect {
x: BAR_MARGIN,
y,
width: (win.0 - 2.0 * BAR_MARGIN).max(120.0),
- height: BAR_H,
+ height: h,
}
}
@@ -338,11 +361,39 @@ fn tabs_y(bar: &Rect) -> f32 {
bar.y + BAR_PAD
}
-/// Y of the nav-controls row.
-fn controls_y(bar: &Rect) -> f32 {
+/// Y of the favorites strip — under the tabs, where it only exists when
+/// the bar was sized for it.
+fn favs_y(bar: &Rect) -> f32 {
bar.y + BAR_PAD + TAB_H + ROW_GAP
}
+/// Y of the nav-controls row: the bar's bottom row, whether or not the
+/// favorites strip sits above it, so it is measured from the bottom edge.
+fn controls_y(bar: &Rect) -> f32 {
+ bar.y + bar.height - BAR_PAD - BTN_H
+}
+
+/// The favorites strip's pills, one rect per favorite that fits, in strip
+/// order (index into the strip = index into the result). Widths follow the
+/// labels, which is why this takes the font: draw and hit-test both read it
+/// with the same font and get the same rects.
+fn fav_rects(bar: &Rect, favs: &[pages::Favorite], sans: &str) -> Vec<Rect> {
+ let mut rects = Vec::with_capacity(favs.len());
+ let right = bar.x + bar.width - BAR_PAD;
+ let mut x = bar.x + BAR_PAD;
+ for f in favs {
+ let w = (measure_text_width(&f.label, sans, FAV_FONT) + 2.0 * FAV_PAD_X)
+ .min(FAV_MAX_W)
+ .max(FAV_H);
+ if x + w > right {
+ break;
+ }
+ rects.push(Rect { x, y: favs_y(bar), width: w, height: FAV_H });
+ x += w + FAV_GAP;
+ }
+ rects
+}
+
fn plus_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
Rect {
x: bar.x + bar.width - BAR_PAD - dot_col(position, true) - PLUS_W,
@@ -417,6 +468,9 @@ fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
if s.eq_ignore_ascii_case("about:bookmarks") {
return Url::parse("cce://bookmarks").ok();
}
+ if s.eq_ignore_ascii_case("about:favorites") {
+ return Url::parse("cce://favorites").ok();
+ }
if s.eq_ignore_ascii_case("about:downloads") {
return Url::parse("cce://downloads").ok();
}
@@ -467,7 +521,30 @@ impl BrowserApp {
/// The utility bar's rect for the current window size and configured
/// edge — the single source every chrome hit-test and draw reads.
fn bar(&self) -> Rect {
- bar_rect(self.win, self.settings.bar_position)
+ bar_rect(self.win, self.settings.bar_position, !self.favs.is_empty())
+ }
+
+ /// The favorites strip's pills for the current bar.
+ fn fav_rects(&self, bar: &Rect) -> Vec<Rect> {
+ let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+ fav_rects(bar, &self.favs, &sans)
+ }
+
+ /// Re-read the strip from the store. Called with the rest of the page
+ /// state, which is also when the `cce://favorites` page's edits — made
+ /// on the way into a navigation — become visible.
+ fn refresh_favorites(&mut self) {
+ let favs = self.favorites.snapshot();
+ if favs != self.favs {
+ self.favs = favs;
+ self.fav_hover = None;
+ }
+ }
+
+ /// Add or remove the active page from the favorites strip.
+ fn toggle_favorite(&mut self) {
+ self.host.toggle_favorite();
+ self.refresh_favorites();
}
/// Centre of the corner control: the bar's corner nearest the window
@@ -545,6 +622,7 @@ impl BrowserApp {
/// Pull delegate-observed page state into the chrome.
fn sync_page_state(&mut self) {
+ self.refresh_favorites();
self.loading = self.host.loading();
self.title = self.host.title().filter(|t| !t.is_empty());
if !self.url_focused {
@@ -656,6 +734,16 @@ impl BrowserApp {
items.push(item("Back", CtxAction::Back, self.host.can_go_back()));
items.push(item("Forward", CtxAction::Forward, self.host.can_go_forward()));
items.push(item("Reload", CtxAction::Reload, true));
+ let favorited = self.host.active_favorited();
+ let on_page = self
+ .host
+ .url()
+ .is_some_and(|u| !matches!(u.scheme(), "cce" | "about"));
+ items.push(item(
+ if favorited { "Remove from Favorites" } else { "Add to Favorites" },
+ CtxAction::ToggleFavorite,
+ favorited || on_page,
+ ));
items.push(item("Open in Other Browser", CtxAction::OpenExternal, true));
let h = CTX_PAD * 2.0 + items.len() as f32 * CTX_ROW_H;
@@ -690,6 +778,7 @@ impl BrowserApp {
}
CtxAction::Download(uri) => self.host.download_uri(uri),
CtxAction::OpenExternal => self.open_external(),
+ CtxAction::ToggleFavorite => self.toggle_favorite(),
}
}
@@ -1068,6 +1157,8 @@ impl Application for BrowserApp {
host.set_color_scheme_dark(settings.color_scheme.is_dark());
#[cfg(feature = "wpe")]
host.set_force_dark(settings.color_scheme.forces_dark());
+ let favorites = host.favorites();
+ let favs = favorites.snapshot();
Self {
host,
settings,
@@ -1089,6 +1180,9 @@ impl Application for BrowserApp {
sender,
font_system: cce_ui::create_font_system(),
session,
+ favorites,
+ favs,
+ fav_hover: None,
}
}
@@ -1248,6 +1342,16 @@ impl Application for BrowserApp {
self.dot_hover = over_dot;
*_needs_rebuild = true;
}
+ let over_fav = if self.chrome_open && !self.favs.is_empty() {
+ let bar = self.bar();
+ self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y))
+ } else {
+ None
+ };
+ if over_fav != self.fav_hover {
+ self.fav_hover = over_fav;
+ *_needs_rebuild = true;
+ }
if !self.chrome_hit(pos.x, pos.y) {
let s = self.scale as f32;
self.host.mouse_move(pos.x * s, pos.y * s);
@@ -1345,6 +1449,24 @@ impl Application for BrowserApp {
self.close_chrome();
return None;
}
+ // Favorites strip: a pill is a menu pick — load it here and fold
+ // — or, middle-clicked, a new tab, with the bar left out so
+ // several can be opened in a row.
+ if let Some(i) = self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y)) {
+ let Ok(url) = Url::parse(&self.favs[i].url) else { return None };
+ if button == MouseButton::Middle {
+ self.host.open_tab(url);
+ self.url_focused = false;
+ self.sync_page_state();
+ self.persist_session();
+ } else {
+ self.host.load(url);
+ self.loading = true;
+ self.close_chrome();
+ self.sync_page_state();
+ }
+ return None;
+ }
if button != MouseButton::Left {
return None;
}
@@ -1485,6 +1607,19 @@ impl Application for BrowserApp {
*needs_rebuild = true;
return None;
}
+ // The favorites pair sits a Shift above the bookmarks pair:
+ // Ctrl+Shift+D toggles the page in the strip, Ctrl+Shift+B
+ // opens the page that manages it.
+ Key::Character(c) if event.shift && c.eq_ignore_ascii_case("d") => {
+ self.toggle_favorite();
+ *needs_rebuild = true;
+ return None;
+ }
+ Key::Character(c) if event.shift && c.eq_ignore_ascii_case("b") => {
+ self.open_internal_page("cce://favorites");
+ *needs_rebuild = true;
+ return None;
+ }
Key::Character(c) if c == "h" || c == "b" || c == "j" => {
let page = match c.as_str() {
"h" => "cce://history",
@@ -1677,6 +1812,28 @@ impl Application for BrowserApp {
TEXT,
);
+ // Favorites strip: label pills, the hovered one lifted like an
+ // active tab. Labels are cut to the pill, never the other way.
+ let fav_rects = self.fav_rects(&bar);
+ for (i, r) in fav_rects.iter().enumerate() {
+ let hovered = self.fav_hover == Some(i);
+ pc.rounded_rect(
+ *r,
+ 7.0,
+ (true, true, true, true),
+ if hovered { TAB_ACTIVE_BG } else { TAB_BG },
+ );
+ let label =
+ Self::fit_text(&self.favs[i].label, &sans, FAV_FONT, r.width - 2.0 * FAV_PAD_X);
+ pc.text(
+ label,
+ r.x + FAV_PAD_X,
+ cce_ui::layout::align_text_y(r.y, r.height, FAV_FONT, 0.0),
+ FAV_FONT,
+ if hovered { TEXT } else { TEXT_DIM },
+ );
+ }
+
let labels = ["<", ">", "R"];
let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
for (i, label) in labels.iter().enumerate() {
diff --git a/src/pages.rs b/src/pages.rs
index abdd286..7a790e1 100644
--- a/src/pages.rs
+++ b/src/pages.rs
@@ -1,7 +1,7 @@
//! Internal `cce:` pages and their backing stores.
//!
-//! History and bookmarks live as TSV files under the XDG state dir
-//! (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
+//! History, bookmarks and favorites live as TSV files under the XDG state
+//! dir (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
//! The `cce:` protocol handler serves them back as real pages —
//! `cce://history` and `cce://bookmarks` are fetched through Servo's
//! network stack and rendered like any other page, so entries are
@@ -112,6 +112,7 @@ pub(crate) fn page(title: &str, meta: &str, body: &str, head_extra: &str) -> Str
text-overflow:ellipsis;flex:1}}\
.e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
.e a.rm:hover{{color:#d49b9b}}\
+ .e .tag{{color:#7fa3d4;font-size:12px}}\
.empty{{color:#8a8c92}}\
</style></head><body>\
<h1>{title}</h1>\
@@ -228,15 +229,33 @@ impl Bookmarks {
write_tsv(&self.path, &entries);
}
- fn html(&self) -> String {
+ /// The title a bookmark was saved with, for promoting it to a favorite
+ /// from the bookmarks page without re-fetching anything.
+ pub fn title_of(&self, url: &str) -> Option<String> {
+ self.entries
+ .lock()
+ .unwrap()
+ .iter()
+ .find(|e| e.url == url)
+ .map(|e| e.title.clone())
+ }
+
+ fn html(&self, favorites: &Favorites) -> String {
let entries = self.entries.lock().unwrap();
let mut rows = String::new();
for e in entries.iter().rev() {
let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
- let enc: String = url::form_urlencoded::byte_serialize(e.url.as_bytes()).collect();
+ let enc = url_encode(&e.url);
+ // A bookmark that is already a favorite says so instead of
+ // offering to add it twice.
+ let fav = if favorites.contains(&e.url) {
+ "<span class=tag>favorite</span>".to_string()
+ } else {
+ format!("<a class=rm href=\"cce://favorites/add?url={}\">favorite</a>", html_escape(&enc))
+ };
rows.push_str(&format!(
"<div class=e><span class=w data-ts=\"{}\"></span>\
- <a href=\"{}\">{}</a><span class=u>{}</span>\
+ <a href=\"{}\">{}</a><span class=u>{}</span>{fav}\
<a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
e.ts,
html_escape(&e.url),
@@ -245,7 +264,10 @@ impl Bookmarks {
html_escape(&enc),
));
}
- let meta = format!("{} bookmarks", entries.len());
+ let meta = format!(
+ "{} bookmarks<a href=\"cce://favorites\">favorites</a>",
+ entries.len()
+ );
let body = if entries.is_empty() {
"<p class=empty>No bookmarks yet. Star a page or press Ctrl+D.</p>".to_string()
} else {
@@ -255,10 +277,187 @@ impl Bookmarks {
}
}
+fn url_encode(s: &str) -> String {
+ url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
+}
+
+/// A favorite as the chrome shows it: the pill's label and where it goes.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Favorite {
+ pub url: String,
+ pub label: String,
+}
+
+/// The label a favorite gets when it is added: the page title, or — for an
+/// untitled page — the host with any `www.` shorn off (the file name, for
+/// a `file:` URL, which has no host), so a pill never reads as a full URL.
+fn default_label(url: &str, title: &str) -> String {
+ let title = title.trim();
+ if !title.is_empty() {
+ return title.to_string();
+ }
+ let Ok(u) = url::Url::parse(url) else { return url.to_string() };
+ let host = u
+ .host_str()
+ .map(|h| h.trim_start_matches("www.").to_string())
+ .filter(|h| !h.is_empty());
+ let file = u
+ .path_segments()
+ .and_then(|mut segs| segs.next_back().map(str::to_string))
+ .filter(|f| !f.is_empty());
+ host.or(file).unwrap_or_else(|| url.to_string())
+}
+
+/// The favorites: a short, ordered, hand-curated list of places, shown as a
+/// row of pills in the utility bar. Deliberately not the bookmarks — the
+/// star is an archive of everything worth finding again; this is the
+/// handful of sites worth a permanent one-click spot. Insertion order is
+/// strip order, and the `cce://favorites` page reorders, renames and
+/// removes.
+pub struct Favorites {
+ entries: Mutex<Vec<Entry>>,
+ path: PathBuf,
+}
+
+impl Favorites {
+ pub fn load() -> Self {
+ let path = state_dir().join("favorites.tsv");
+ Self { entries: Mutex::new(read_tsv(&path)), path }
+ }
+
+ /// The strip, in order.
+ pub fn snapshot(&self) -> Vec<Favorite> {
+ self.entries
+ .lock()
+ .unwrap()
+ .iter()
+ .map(|e| Favorite { url: e.url.clone(), label: default_label(&e.url, &e.title) })
+ .collect()
+ }
+
+ pub fn contains(&self, url: &str) -> bool {
+ self.entries.lock().unwrap().iter().any(|e| e.url == url)
+ }
+
+ /// Add `url` to the end of the strip, or do nothing if it is there.
+ /// Internal pages are refused — a favorite pointing at a blank tab
+ /// helps nobody.
+ pub fn add(&self, url: &str, title: &str) {
+ if url.starts_with("cce:") || url == "about:blank" {
+ return;
+ }
+ let mut entries = self.entries.lock().unwrap();
+ if entries.iter().any(|e| e.url == url) {
+ return;
+ }
+ entries.push(Entry {
+ ts: now(),
+ url: sanitize(url),
+ title: sanitize(&default_label(url, title)),
+ });
+ write_tsv(&self.path, &entries);
+ }
+
+ /// Add or remove `url`; returns true when it is now a favorite.
+ pub fn toggle(&self, url: &str, title: &str) -> bool {
+ if self.contains(url) {
+ self.remove(url);
+ false
+ } else {
+ self.add(url, title);
+ self.contains(url)
+ }
+ }
+
+ pub fn remove(&self, url: &str) {
+ let mut entries = self.entries.lock().unwrap();
+ entries.retain(|e| e.url != url);
+ write_tsv(&self.path, &entries);
+ }
+
+ pub fn rename(&self, url: &str, title: &str) {
+ let mut entries = self.entries.lock().unwrap();
+ if let Some(e) = entries.iter_mut().find(|e| e.url == url) {
+ e.title = sanitize(&default_label(url, title));
+ write_tsv(&self.path, &entries);
+ }
+ }
+
+ /// Move `url` one place toward the front (`-1`) or the back (`1`).
+ pub fn shift(&self, url: &str, delta: isize) {
+ let mut entries = self.entries.lock().unwrap();
+ let Some(i) = entries.iter().position(|e| e.url == url) else { return };
+ let j = i as isize + delta;
+ if j < 0 || j >= entries.len() as isize {
+ return;
+ }
+ entries.swap(i, j as usize);
+ write_tsv(&self.path, &entries);
+ }
+
+ fn html(&self) -> String {
+ let entries = self.entries.lock().unwrap();
+ let mut rows = String::new();
+ let last = entries.len().saturating_sub(1);
+ for (i, e) in entries.iter().enumerate() {
+ let enc = url_encode(&e.url);
+ let label = default_label(&e.url, &e.title);
+ // Ordering links; the end pill has nowhere further to go.
+ let up = if i > 0 {
+ format!("<a class=rm href=\"cce://favorites/up?url={}\">▲</a>", html_escape(&enc))
+ } else {
+ "<span class=rm>▲</span>".to_string()
+ };
+ let down = if i < last {
+ format!("<a class=rm href=\"cce://favorites/down?url={}\">▼</a>", html_escape(&enc))
+ } else {
+ "<span class=rm>▼</span>".to_string()
+ };
+ rows.push_str(&format!(
+ "<div class=e><span class=w>{up} {down}</span>\
+ <a href=\"{url}\">{label}</a><span class=u>{url}</span>\
+ <form action=\"cce://favorites/rename\">\
+ <input type=hidden name=url value=\"{url}\">\
+ <input name=title value=\"{label}\" size=18>\
+ <button>rename</button></form>\
+ <a class=rm href=\"cce://favorites/remove?url={enc}\">remove</a></div>\n",
+ url = html_escape(&e.url),
+ label = html_escape(&label),
+ enc = html_escape(&enc),
+ ));
+ }
+ let meta = format!(
+ "{} favorites<a href=\"cce://bookmarks\">bookmarks</a>",
+ entries.len()
+ );
+ let body = if entries.is_empty() {
+ "<p class=empty>No favorites yet. Press Ctrl+Shift+D on a page, pick \
+ \"Add to Favorites\" from its right-click menu, or promote a bookmark.</p>"
+ .to_string()
+ } else {
+ rows
+ };
+ page("Favorites", &meta, &body, FAVORITES_CSS)
+ }
+}
+
+/// The rename form's styling, on top of the shared skeleton.
+const FAVORITES_CSS: &str = "<style>\
+ .e .w{min-width:3em}\
+ .e form{display:flex;gap:6px;margin:0}\
+ .e input{background:#111214;color:#dcdce1;border:1px solid #2c2d31;border-radius:5px;\
+ padding:2px 6px;font-size:12px;width:9em}\
+ .e button{background:#232427;color:#8a8c92;border:1px solid #2c2d31;border-radius:5px;\
+ padding:2px 8px;font-size:12px;cursor:pointer}\
+ .e button:hover{color:#dcdce1}\
+ .w a{margin-right:4px}\
+ </style>";
+
/// `cce:` scheme: internal pages served straight out of the app.
pub struct CceProtocol {
pub history: Arc<History>,
pub bookmarks: Arc<Bookmarks>,
+ pub favorites: Arc<Favorites>,
pub downloads: Arc<crate::downloads::Downloads>,
/// Raised by cce://cookies/clear. The handler runs on fetch threads and
/// cannot reach Servo, so it flags the request and the app's next pump
@@ -297,20 +496,55 @@ impl CceProtocol {
pub(crate) fn route(&self, url: &str) -> Option<String> {
let full = url.trim_start_matches("cce://");
let (path, query) = full.split_once('?').unwrap_or((full, ""));
+ let param = |key: &str| -> Option<String> {
+ url::form_urlencoded::parse(query.as_bytes())
+ .find(|(k, _)| k == key)
+ .map(|(_, v)| v.into_owned())
+ };
match path.trim_end_matches('/') {
"history" => Some(self.history.html()),
"history/clear" => {
self.history.clear();
Some(self.history.html())
}
- "bookmarks" => Some(self.bookmarks.html()),
+ "bookmarks" => Some(self.bookmarks.html(&self.favorites)),
"bookmarks/remove" => {
- if let Some((_, target)) =
- url::form_urlencoded::parse(query.as_bytes()).find(|(k, _)| k == "url")
- {
+ if let Some(target) = param("url") {
self.bookmarks.remove(&target);
}
- Some(self.bookmarks.html())
+ Some(self.bookmarks.html(&self.favorites))
+ }
+ "favorites" => Some(self.favorites.html()),
+ // Adding lands on the favorites page so the new pill's place in
+ // the strip is visible right away. A bookmark promoted without a
+ // title in the query keeps the title it was starred with.
+ "favorites/add" => {
+ if let Some(target) = param("url") {
+ let title = param("title")
+ .or_else(|| self.bookmarks.title_of(&target))
+ .unwrap_or_default();
+ self.favorites.add(&target, &title);
+ }
+ Some(self.favorites.html())
+ }
+ "favorites/remove" => {
+ if let Some(target) = param("url") {
+ self.favorites.remove(&target);
+ }
+ Some(self.favorites.html())
+ }
+ "favorites/up" | "favorites/down" => {
+ if let Some(target) = param("url") {
+ let delta = if path.ends_with("up") { -1 } else { 1 };
+ self.favorites.shift(&target, delta);
+ }
+ Some(self.favorites.html())
+ }
+ "favorites/rename" => {
+ if let Some(target) = param("url") {
+ self.favorites.rename(&target, ¶m("title").unwrap_or_default());
+ }
+ Some(self.favorites.html())
}
"downloads" => Some(self.downloads.html()),
"downloads/clear" => {
@@ -327,6 +561,58 @@ impl CceProtocol {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn store() -> Favorites {
+ let dir = std::env::temp_dir().join(format!("cce-browser-favs-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ Favorites { entries: Mutex::new(Vec::new()), path: dir.join("favorites.tsv") }
+ }
+
+ #[test]
+ fn labels_fall_back_to_host_then_file_name() {
+ assert_eq!(default_label("https://www.example.com/a", "Example"), "Example");
+ assert_eq!(default_label("https://www.example.com/a", " "), "example.com");
+ assert_eq!(default_label("file:///home/me/page.html", ""), "page.html");
+ assert_eq!(default_label("about:blank", ""), "about:blank");
+ }
+
+ #[test]
+ fn strip_order_is_insertion_order_and_shifts_move_one_place() {
+ let f = store();
+ f.add("https://a.example/", "A");
+ f.add("https://b.example/", "B");
+ f.add("https://c.example/", "C");
+ f.add("https://b.example/", "again"); // already there: no duplicate
+ let labels = |f: &Favorites| f.snapshot().iter().map(|x| x.label.clone()).collect::<Vec<_>>();
+ assert_eq!(labels(&f), ["A", "B", "C"]);
+ f.shift("https://c.example/", -1);
+ assert_eq!(labels(&f), ["A", "C", "B"]);
+ f.shift("https://a.example/", -1); // already first: stays
+ assert_eq!(labels(&f), ["A", "C", "B"]);
+ f.rename("https://c.example/", "Sea");
+ assert_eq!(labels(&f), ["A", "Sea", "B"]);
+ assert!(!f.toggle("https://a.example/", "A"));
+ assert!(f.toggle("https://d.example/", "D"));
+ assert_eq!(labels(&f), ["Sea", "B", "D"]);
+
+ // Round-trips through the file.
+ let back = Favorites { entries: Mutex::new(read_tsv(&f.path)), path: f.path.clone() };
+ assert_eq!(back.snapshot(), f.snapshot());
+ let _ = fs::remove_dir_all(f.path.parent().unwrap());
+ }
+
+ #[test]
+ fn internal_pages_are_refused() {
+ let f = store();
+ f.add("cce://history", "History");
+ f.add("about:blank", "");
+ assert!(f.snapshot().is_empty());
+ }
+}
+
#[cfg(feature = "servo")]
impl ProtocolHandler for CceProtocol {
fn load(
diff --git a/src/webview.rs b/src/webview.rs
index 2a359e4..7ee03d4 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -30,7 +30,7 @@ use servo::protocol_handler::ProtocolRegistry;
use url::Url;
use crate::downloads::{is_download_url, Downloads};
-use crate::pages::{Bookmarks, CceProtocol, History};
+use crate::pages::{Bookmarks, CceProtocol, Favorites, History};
use crate::Message;
/// Delegate-observed signals for one webview, polled by the app after each
@@ -225,6 +225,7 @@ pub struct ServoHost {
delegate: Rc<Delegate>,
history: std::sync::Arc<History>,
bookmarks: std::sync::Arc<Bookmarks>,
+ favorites: std::sync::Arc<Favorites>,
tabs: Vec<Tab>,
active: usize,
size_px: (u32, u32),
@@ -317,12 +318,14 @@ impl ServoHost {
let history = std::sync::Arc::new(History::load());
let bookmarks = std::sync::Arc::new(Bookmarks::load());
+ let favorites = std::sync::Arc::new(Favorites::load());
let downloads = std::sync::Arc::new(Downloads::default());
let clear_cookies = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut protocols = ProtocolRegistry::default();
let handler = CceProtocol {
history: history.clone(),
bookmarks: bookmarks.clone(),
+ favorites: favorites.clone(),
downloads: downloads.clone(),
clear_cookies: clear_cookies.clone(),
};
@@ -390,6 +393,7 @@ impl ServoHost {
delegate,
history,
bookmarks,
+ favorites,
tabs: Vec::new(),
// Sentinel so the first open_tab's activate() does the full
// show/focus/resize dance instead of early-returning on 0 == 0.
@@ -647,6 +651,29 @@ impl ServoHost {
}
}
+ /// The favorites store, shared with the `cce://favorites` page; the
+ /// chrome reads the strip from it.
+ pub fn favorites(&self) -> std::sync::Arc<Favorites> {
+ self.favorites.clone()
+ }
+
+ /// Whether the active tab's page is in the favorites strip.
+ pub fn active_favorited(&self) -> bool {
+ self.active_tab()
+ .url
+ .as_ref()
+ .is_some_and(|u| self.favorites.contains(u.as_str()))
+ }
+
+ /// Toggle the favorite for the active tab's page.
+ pub fn toggle_favorite(&self) {
+ let tab = self.active_tab();
+ if let Some(url) = &tab.url {
+ self.favorites
+ .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+ }
+ }
+
pub fn can_go_back(&self) -> bool {
self.active_tab().webview.can_go_back()
}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index b96901b..00f3368 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -130,6 +130,7 @@ pub struct WebKitHost {
/// the backend swap unchanged.
history: std::sync::Arc<crate::pages::History>,
bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
+ favorites: std::sync::Arc<crate::pages::Favorites>,
history_enabled: bool,
force_dark: bool,
/// Serves the `cce:` pages. Boxed and leaked into the scheme callback,
@@ -203,12 +204,14 @@ impl WebKitHost {
let history = std::sync::Arc::new(crate::pages::History::load());
let bookmarks = std::sync::Arc::new(crate::pages::Bookmarks::load());
+ let favorites = std::sync::Arc::new(crate::pages::Favorites::load());
let downloads = std::sync::Arc::new(crate::downloads::Downloads::default());
let clear_cookies =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let protocol = Rc::new(crate::pages::CceProtocol {
history: history.clone(),
bookmarks: bookmarks.clone(),
+ favorites: favorites.clone(),
downloads: downloads.clone(),
clear_cookies: clear_cookies.clone(),
});
@@ -277,6 +280,7 @@ impl WebKitHost {
.ok(),
history: history.clone(),
bookmarks: bookmarks.clone(),
+ favorites,
history_enabled: true,
force_dark: false,
protocol,
@@ -676,6 +680,27 @@ impl WebKitHost {
}
}
+ /// The favorites store, shared with the `cce://favorites` page; the
+ /// chrome reads the strip from it.
+ pub fn favorites(&self) -> std::sync::Arc<crate::pages::Favorites> {
+ self.favorites.clone()
+ }
+
+ pub fn active_favorited(&self) -> bool {
+ self.active_tab()
+ .url
+ .as_ref()
+ .is_some_and(|u| self.favorites.contains(u.as_str()))
+ }
+
+ pub fn toggle_favorite(&self) {
+ let tab = self.active_tab();
+ if let Some(url) = &tab.url {
+ self.favorites
+ .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+ }
+ }
+
/// Clipboard on the page. WebKit takes these as named editing commands,
/// so unlike the Servo backend there is no separate clipboard delegate to
/// implement — it goes through the platform clipboard itself.