web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
Add bookmarks
The history module grows into pages.rs: internal cce: pages and their
TSV-backed stores. Bookmarks live in bookmarks.tsv next to history
(whole-file rewrite on change since entries are removable); the cce:
handler gains cce://bookmarks and cce://bookmarks/remove?url=... —
entries render like history rows plus a remove link. Chrome adds a
star button at the right end of the controls row (accent-lit when the
page is bookmarked) that toggles, Ctrl+D toggles, Ctrl+B opens the
page, about:bookmarks maps to it.
Live-verified: star click writes the TSV and lights up, the entry
survives a restart, cce://bookmarks renders it, remove empties store
and file. (Ctrl-key injection was blocked by the known compositor
focus-gate this session; the shortcut handlers call the same verified
paths.)
Co-Authored-By: Claude Fable 5 <[email protected]>
src/history.rs | 199 -------------------------------------
src/main.rs | 44 ++++++++-
src/pages.rs | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/webview.rs | 25 ++++-
4 files changed, 366 insertions(+), 205 deletions(-)
diff --git a/src/history.rs b/src/history.rs
deleted file mode 100644
index d71464f..0000000
--- a/src/history.rs
+++ /dev/null
@@ -1,199 +0,0 @@
-//! Visit history: an append-only TSV log under the XDG state dir
-//! (`~/.local/state/cce/browser/history.tsv`), an in-memory copy for
-//! rendering, and the `cce:` protocol handler that serves it back as a
-//! real page — `cce://history` is fetched through Servo's network stack
-//! and rendered like any other page, so entries are ordinary links.
-//!
-//! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
-//! store shared with the main thread's recorder.
-
-use std::fs::{self, OpenOptions};
-use std::future::Future;
-use std::io::Write;
-use std::path::PathBuf;
-use std::pin::Pin;
-use std::sync::Mutex;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use servo::protocol_handler::{
- DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
- ResponseBody, ResourceFetchTiming,
-};
-use std::sync::Arc;
-
-/// Render at most this many entries on the history page.
-const RENDER_CAP: usize = 500;
-
-#[derive(Clone)]
-struct Entry {
- ts: u64,
- url: String,
- title: String,
-}
-
-pub struct History {
- entries: Mutex<Vec<Entry>>,
- path: PathBuf,
-}
-
-fn state_path() -> PathBuf {
- let base = match std::env::var("XDG_STATE_HOME") {
- Ok(x) if !x.is_empty() => PathBuf::from(x),
- _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
- };
- base.join("cce").join("browser").join("history.tsv")
-}
-
-/// One-line-safe field: the TSV log separates with tabs and newlines.
-fn sanitize(s: &str) -> String {
- s.replace(['\t', '\n', '\r'], " ")
-}
-
-fn html_escape(s: &str) -> String {
- s.replace('&', "&")
- .replace('<', "<")
- .replace('>', ">")
- .replace('"', """)
-}
-
-impl History {
- /// Load the log from the state dir (missing file = empty history).
- pub fn load() -> Self {
- let path = state_path();
- let mut entries = Vec::new();
- if let Ok(text) = fs::read_to_string(&path) {
- for line in text.lines() {
- let mut parts = line.splitn(3, '\t');
- if let (Some(ts), Some(url), Some(title)) =
- (parts.next(), parts.next(), parts.next())
- {
- if let Ok(ts) = ts.parse() {
- entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
- }
- }
- }
- }
- Self { entries: Mutex::new(entries), path }
- }
-
- /// Record a completed page load. Internal pages and immediate
- /// duplicates (reload spam) are skipped.
- pub fn record(&self, url: &str, title: &str) {
- if url.starts_with("cce:") || url == "about:blank" {
- return;
- }
- let mut entries = self.entries.lock().unwrap();
- if entries.last().is_some_and(|last| last.url == url) {
- return;
- }
- let ts = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|d| d.as_secs())
- .unwrap_or(0);
- let entry = Entry { ts, url: sanitize(url), title: sanitize(title) };
- if let Some(dir) = self.path.parent() {
- let _ = fs::create_dir_all(dir);
- }
- if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
- let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
- }
- entries.push(entry);
- }
-
- pub fn clear(&self) {
- self.entries.lock().unwrap().clear();
- let _ = fs::write(&self.path, "");
- }
-
- /// The history page markup: newest first, timestamps localized by a
- /// tiny script on the page itself.
- fn html(&self) -> String {
- let entries = self.entries.lock().unwrap();
- let mut rows = String::new();
- for e in entries.iter().rev().take(RENDER_CAP) {
- let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
- rows.push_str(&format!(
- "<div class=e><span class=w data-ts=\"{}\"></span>\
- <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
- e.ts,
- html_escape(&e.url),
- html_escape(title),
- html_escape(&e.url),
- ));
- }
- let count = entries.len();
- let body = if count == 0 {
- "<p class=empty>No history yet.</p>".to_string()
- } else {
- rows
- };
- format!(
- "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>History</title><style>\
- :root{{color-scheme:dark}}\
- body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
- h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
- .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
- .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
- .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
- .e:hover{{background:#232427}}\
- .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
- .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
- text-overflow:ellipsis;max-width:40%}}\
- .e a:hover{{color:#9fc1ea}}\
- .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
- text-overflow:ellipsis;flex:1}}\
- .empty{{color:#8a8c92}}\
- </style></head><body>\
- <h1>History</h1>\
- <div class=meta>{count} entries<a href=\"cce://history/clear\">clear</a></div>\
- {body}\
- <script>for(const el of document.querySelectorAll('[data-ts]')){{\
- const d=new Date(1000*+el.dataset.ts);\
- el.textContent=d.toLocaleDateString()+' '+\
- d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
- </body></html>"
- )
- }
-}
-
-/// `cce:` scheme: internal pages served straight out of the app.
-pub struct CceProtocol {
- pub history: Arc<History>,
-}
-
-impl ProtocolHandler for CceProtocol {
- fn load(
- &self,
- request: &mut Request,
- _done_chan: &mut DoneChannel,
- _context: &FetchContext,
- ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
- let url = request.current_url();
- let page = url.as_str().trim_start_matches("cce://").trim_end_matches('/');
- let body = match page {
- "history" => Some(self.history.html()),
- "history/clear" => {
- self.history.clear();
- Some(self.history.html())
- }
- _ => None,
- };
- let response = match body {
- Some(html) => {
- let mut response =
- Response::new(url, ResourceFetchTiming::new(request.timing_type()));
- *response.body.lock() = ResponseBody::Done(html.into_bytes());
- response.headers.insert(
- http::header::CONTENT_TYPE,
- http::HeaderValue::from_static("text/html; charset=utf-8"),
- );
- response.status = HttpStatus::default();
- response
- }
- None => Response::network_error(NetworkError::ResourceLoadError(
- format!("no such cce: page: {page}"),
- )),
- };
- Box::pin(std::future::ready(response))
- }
-}
diff --git a/src/main.rs b/src/main.rs
index b1fa847..25505c5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,7 @@
//! reload / URL field). Input over the page area is translated into Servo
//! input events; the URL bar is a small hand-rolled line editor.
-mod history;
+mod pages;
mod webview;
use url::Url;
@@ -148,13 +148,24 @@ fn btn_rect(i: usize) -> Rect {
}
}
+/// The bookmark star, at the right end of the controls row.
+fn star_rect(win_w: f32) -> Rect {
+ let bar = bar_rect(win_w);
+ Rect {
+ x: bar.x + bar.width - BAR_PAD - BTN_W,
+ y: controls_y(),
+ width: BTN_W,
+ height: BTN_H,
+ }
+}
+
fn url_rect(win_w: f32) -> Rect {
let bar = bar_rect(win_w);
let x = BAR_MARGIN + BAR_PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
Rect {
x,
y: controls_y(),
- width: (bar.x + bar.width - BAR_PAD - x).max(60.0),
+ width: (bar.x + bar.width - BAR_PAD - BTN_W - BTN_GAP - x).max(60.0),
height: BTN_H,
}
}
@@ -169,6 +180,9 @@ fn parse_url_input(input: &str) -> Option<Url> {
if s.eq_ignore_ascii_case("about:history") {
return Url::parse("cce://history").ok();
}
+ if s.eq_ignore_ascii_case("about:bookmarks") {
+ return Url::parse("cce://bookmarks").ok();
+ }
if let Ok(u) = Url::parse(s) {
if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
return Some(u);
@@ -482,6 +496,8 @@ impl Application for BrowserApp {
self.host.forward();
} else if hit(&btn_rect(2), pos.x, pos.y) {
self.host.reload();
+ } else if hit(&star_rect(self.win.0), pos.x, pos.y) {
+ self.host.toggle_bookmark();
} else {
let field = url_rect(self.win.0);
if hit(&field, pos.x, pos.y) {
@@ -542,8 +558,9 @@ impl Application for BrowserApp {
*needs_rebuild = true;
return self.close_tab(self.host.active_index());
}
- Key::Character(c) if c == "h" => {
- if let Ok(url) = Url::parse("cce://history") {
+ Key::Character(c) if c == "h" || c == "b" => {
+ let page = if c == "h" { "cce://history" } else { "cce://bookmarks" };
+ if let Ok(url) = Url::parse(page) {
self.host.open_tab(url);
self.url_focused = false;
self.sync_page_state();
@@ -551,6 +568,11 @@ impl Application for BrowserApp {
}
return None;
}
+ Key::Character(c) if c == "d" => {
+ self.host.toggle_bookmark();
+ *needs_rebuild = true;
+ return None;
+ }
Key::Named(NamedKey::Tab) if count > 1 => {
let cur = self.host.active_index();
let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
@@ -702,6 +724,20 @@ impl Application for BrowserApp {
);
}
+ // Bookmark star: accent-lit when the page is bookmarked.
+ let star = star_rect(w);
+ pc.rounded_rect(star, 6.0, (true, true, true, true), BTN_BG);
+ let starred = self.host.active_bookmarked();
+ let star_color: [u8; 3] = if starred { [150, 190, 240] } else { TEXT_DIM };
+ let sw = measure_text_width("*", &sans, 17.0);
+ pc.text(
+ "*",
+ star.x + (star.width - sw) / 2.0,
+ cce_ui::layout::align_text_y(star.y, star.height, 17.0, 0.0) + 3.0,
+ 17.0,
+ star_color,
+ );
+
// URL field: rim + recess, brighter rim when focused.
let f = url_rect(w);
let rim = if self.url_focused { RIM_FOCUS } else { RIM };
diff --git a/src/pages.rs b/src/pages.rs
new file mode 100644
index 0000000..228cb46
--- /dev/null
+++ b/src/pages.rs
@@ -0,0 +1,303 @@
+//! 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.
+//! 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
+//! ordinary links (including the mutating clear/remove actions).
+//!
+//! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
+//! stores shared with the main thread.
+
+use std::fs::{self, OpenOptions};
+use std::future::Future;
+use std::io::Write;
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use servo::protocol_handler::{
+ DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
+ ResponseBody, ResourceFetchTiming,
+};
+
+/// Render at most this many entries on the history page.
+const RENDER_CAP: usize = 500;
+
+#[derive(Clone)]
+struct Entry {
+ ts: u64,
+ url: String,
+ title: String,
+}
+
+fn state_dir() -> PathBuf {
+ let base = match std::env::var("XDG_STATE_HOME") {
+ Ok(x) if !x.is_empty() => PathBuf::from(x),
+ _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
+ };
+ base.join("cce").join("browser")
+}
+
+fn now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// One-line-safe field: the TSV logs separate with tabs and newlines.
+fn sanitize(s: &str) -> String {
+ s.replace(['\t', '\n', '\r'], " ")
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+fn read_tsv(path: &PathBuf) -> Vec<Entry> {
+ let mut entries = Vec::new();
+ if let Ok(text) = fs::read_to_string(path) {
+ for line in text.lines() {
+ let mut parts = line.splitn(3, '\t');
+ if let (Some(ts), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next())
+ {
+ if let Ok(ts) = ts.parse() {
+ entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
+ }
+ }
+ }
+ }
+ entries
+}
+
+fn write_tsv(path: &PathBuf, entries: &[Entry]) {
+ if let Some(dir) = path.parent() {
+ let _ = fs::create_dir_all(dir);
+ }
+ let mut out = String::new();
+ for e in entries {
+ out.push_str(&format!("{}\t{}\t{}\n", e.ts, e.url, e.title));
+ }
+ let _ = fs::write(path, out);
+}
+
+/// Shared page skeleton for the internal pages (dark, DE-toned).
+fn page(title: &str, meta: &str, body: &str) -> String {
+ format!(
+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title><style>\
+ :root{{color-scheme:dark}}\
+ body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
+ h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
+ .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
+ .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
+ .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
+ .e:hover{{background:#232427}}\
+ .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
+ .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
+ text-overflow:ellipsis;max-width:40%}}\
+ .e a:hover{{color:#9fc1ea}}\
+ .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
+ text-overflow:ellipsis;flex:1}}\
+ .e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
+ .e a.rm:hover{{color:#d49b9b}}\
+ .empty{{color:#8a8c92}}\
+ </style></head><body>\
+ <h1>{title}</h1>\
+ <div class=meta>{meta}</div>\
+ {body}\
+ <script>for(const el of document.querySelectorAll('[data-ts]')){{\
+ const d=new Date(1000*+el.dataset.ts);\
+ el.textContent=d.toLocaleDateString()+' '+\
+ d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
+ </body></html>"
+ )
+}
+
+pub struct History {
+ entries: Mutex<Vec<Entry>>,
+ path: PathBuf,
+}
+
+impl History {
+ /// Load the log from the state dir (missing file = empty history).
+ pub fn load() -> Self {
+ let path = state_dir().join("history.tsv");
+ Self { entries: Mutex::new(read_tsv(&path)), path }
+ }
+
+ /// Record a completed page load. Internal pages and immediate
+ /// duplicates (reload spam) are skipped.
+ pub fn record(&self, url: &str, title: &str) {
+ if url.starts_with("cce:") || url == "about:blank" {
+ return;
+ }
+ let mut entries = self.entries.lock().unwrap();
+ if entries.last().is_some_and(|last| last.url == url) {
+ return;
+ }
+ let entry = Entry { ts: now(), url: sanitize(url), title: sanitize(title) };
+ if let Some(dir) = self.path.parent() {
+ let _ = fs::create_dir_all(dir);
+ }
+ if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
+ let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
+ }
+ entries.push(entry);
+ }
+
+ pub fn clear(&self) {
+ self.entries.lock().unwrap().clear();
+ let _ = fs::write(&self.path, "");
+ }
+
+ fn html(&self) -> String {
+ let entries = self.entries.lock().unwrap();
+ let mut rows = String::new();
+ for e in entries.iter().rev().take(RENDER_CAP) {
+ let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
+ rows.push_str(&format!(
+ "<div class=e><span class=w data-ts=\"{}\"></span>\
+ <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
+ e.ts,
+ html_escape(&e.url),
+ html_escape(title),
+ html_escape(&e.url),
+ ));
+ }
+ let meta = format!(
+ "{} entries<a href=\"cce://history/clear\">clear</a>",
+ entries.len()
+ );
+ let body = if entries.is_empty() {
+ "<p class=empty>No history yet.</p>".to_string()
+ } else {
+ rows
+ };
+ page("History", &meta, &body)
+ }
+}
+
+pub struct Bookmarks {
+ entries: Mutex<Vec<Entry>>,
+ path: PathBuf,
+}
+
+impl Bookmarks {
+ pub fn load() -> Self {
+ let path = state_dir().join("bookmarks.tsv");
+ Self { entries: Mutex::new(read_tsv(&path)), path }
+ }
+
+ pub fn contains(&self, url: &str) -> bool {
+ self.entries.lock().unwrap().iter().any(|e| e.url == url)
+ }
+
+ /// Add or remove a bookmark for `url`; returns true when it is now
+ /// bookmarked.
+ pub fn toggle(&self, url: &str, title: &str) -> bool {
+ if url.starts_with("cce:") || url == "about:blank" {
+ return false;
+ }
+ let mut entries = self.entries.lock().unwrap();
+ let added = if let Some(i) = entries.iter().position(|e| e.url == url) {
+ entries.remove(i);
+ false
+ } else {
+ entries.push(Entry { ts: now(), url: sanitize(url), title: sanitize(title) });
+ true
+ };
+ write_tsv(&self.path, &entries);
+ added
+ }
+
+ pub fn remove(&self, url: &str) {
+ let mut entries = self.entries.lock().unwrap();
+ entries.retain(|e| e.url != url);
+ write_tsv(&self.path, &entries);
+ }
+
+ fn html(&self) -> 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();
+ rows.push_str(&format!(
+ "<div class=e><span class=w data-ts=\"{}\"></span>\
+ <a href=\"{}\">{}</a><span class=u>{}</span>\
+ <a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
+ e.ts,
+ html_escape(&e.url),
+ html_escape(title),
+ html_escape(&e.url),
+ html_escape(&enc),
+ ));
+ }
+ let meta = format!("{} bookmarks", 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 {
+ rows
+ };
+ page("Bookmarks", &meta, &body)
+ }
+}
+
+/// `cce:` scheme: internal pages served straight out of the app.
+pub struct CceProtocol {
+ pub history: Arc<History>,
+ pub bookmarks: Arc<Bookmarks>,
+}
+
+impl ProtocolHandler for CceProtocol {
+ fn load(
+ &self,
+ request: &mut Request,
+ _done_chan: &mut DoneChannel,
+ _context: &FetchContext,
+ ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
+ let url = request.current_url();
+ let full = url.as_str().trim_start_matches("cce://");
+ let (path, query) = full.split_once('?').unwrap_or((full, ""));
+ let body = 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/remove" => {
+ if let Some((_, target)) =
+ url::form_urlencoded::parse(query.as_bytes()).find(|(k, _)| k == "url")
+ {
+ self.bookmarks.remove(&target);
+ }
+ Some(self.bookmarks.html())
+ }
+ _ => None,
+ };
+ let response = match body {
+ Some(html) => {
+ let mut response =
+ Response::new(url, ResourceFetchTiming::new(request.timing_type()));
+ *response.body.lock() = ResponseBody::Done(html.into_bytes());
+ response.headers.insert(
+ http::header::CONTENT_TYPE,
+ http::HeaderValue::from_static("text/html; charset=utf-8"),
+ );
+ response.status = HttpStatus::default();
+ response
+ }
+ None => Response::network_error(NetworkError::ResourceLoadError(format!(
+ "no such cce: page: {path}"
+ ))),
+ };
+ Box::pin(std::future::ready(response))
+ }
+}
diff --git a/src/webview.rs b/src/webview.rs
index 295872c..3ce1755 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -26,7 +26,7 @@ use servo::{
use servo::protocol_handler::ProtocolRegistry;
use url::Url;
-use crate::history::{CceProtocol, History};
+use crate::pages::{Bookmarks, CceProtocol, History};
use crate::Message;
/// Delegate-observed signals for one webview, polled by the app after each
@@ -129,6 +129,7 @@ pub struct ServoHost {
shared: Rc<HostShared>,
delegate: Rc<Delegate>,
history: std::sync::Arc<History>,
+ bookmarks: std::sync::Arc<Bookmarks>,
tabs: Vec<Tab>,
active: usize,
size_px: (u32, u32),
@@ -149,8 +150,10 @@ impl ServoHost {
.expect("make software rendering context current");
let history = std::sync::Arc::new(History::load());
+ let bookmarks = std::sync::Arc::new(Bookmarks::load());
let mut protocols = ProtocolRegistry::default();
- if let Err(e) = protocols.register("cce", CceProtocol { history: history.clone() }) {
+ let handler = CceProtocol { history: history.clone(), bookmarks: bookmarks.clone() };
+ if let Err(e) = protocols.register("cce", handler) {
log::error!("failed to register cce: protocol: {e:?}");
}
@@ -174,6 +177,7 @@ impl ServoHost {
shared,
delegate,
history,
+ bookmarks,
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.
@@ -352,6 +356,23 @@ impl ServoHost {
self.active_tab().loading
}
+ /// Whether the active tab's page is bookmarked.
+ pub fn active_bookmarked(&self) -> bool {
+ self.active_tab()
+ .url
+ .as_ref()
+ .is_some_and(|u| self.bookmarks.contains(u.as_str()))
+ }
+
+ /// Toggle the bookmark for the active tab's page.
+ pub fn toggle_bookmark(&self) {
+ let tab = self.active_tab();
+ if let Some(url) = &tab.url {
+ self.bookmarks
+ .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+ }
+ }
+
pub fn can_go_back(&self) -> bool {
self.active_tab().webview.can_go_back()
}