git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit30f031524c343d52112d6346f989cb471572285a
parente4865951e8
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 19:34
feat: draw page <select> lists in the chrome

WPE has no popup of its own: a <select> raises show-option-menu and,
with nobody answering, never opens, so every dropdown on every page
was dead. The host now holds the WebKitOptionMenu and hands the chrome
its options and the select's box; the chrome draws the list at the
select and answers with a pick (activate + close, so change fires) or
a plain close. The page closing it (the select removed, a navigation)
takes the chrome's copy down too.

The list owns pointer and keyboard while up, like the right-click
menu: arrows, paging and Home/End skip disabled options and optgroup
headings, Enter/Space pick, Escape/Tab close, letters find. A long
list scrolls, wheel travel accumulating so trackpad pixels move it.
examples/wpe_options.rs proves the engine side at scale 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md               |  36 ++++
 Cargo.toml              |   4 +
 examples/wpe_options.rs | 109 ++++++++++++
 src/main.rs             | 433 ++++++++++++++++++++++++++++++++++++++++++++++++
 src/wpe/host.rs         | 164 ++++++++++++++++++
 src/wpe/mod.rs          |   4 +-
 6 files changed, 749 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0e2f3ce..fdffa73 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -412,6 +412,42 @@ Menu semantics, all in `handle_mouse_input` / `handle_key_input`:
   keeps eating keystrokes. Wheel and pointer moves over the chrome stay off the
   page, gated by `chrome_hit`, not `bar_rect`.
 
+### Select lists
+
+**WPE draws no `<select>` popup of its own.** A click on a select raises
+`show-option-menu` with the options and the select's box, and a select
+nobody answers simply never opens — which is how every dropdown on every
+page was dead until 2026-10-05. `on_show_option_menu` (`src/wpe/host.rs`)
+holds the `WebKitOptionMenu` and hands the chrome an `OptionMenuInfo`;
+`OptMenu` in `main.rs` draws the list at the select and answers with
+`pick_option` (activate + close: the value changes and `change` fires) or
+`close_option_menu`. Points that are choices:
+
+- **`select_item` is never called.** WebKit's `close` activates whatever is
+  selected, so moving WebKit's selection with the highlight would make
+  Escape commit the row it was on. The highlight is the chrome's alone.
+- **The page can close it too** (the select is removed, the page
+  navigates): the menu's own `close` signal drops the held menu, and the
+  next `Spin` sees `option_menu_open()` false and takes the list down.
+- **The anchor is logical pixels** — the chrome's — at any output scale
+  (verified at 2), like the account list's field rects.
+- **`opt_layout()` is the one geometry**: under the select, or over it when
+  there is more room above, never past the window; a longer list scrolls
+  (wheel, arrows, paging), with a thumb. Wheel travel accumulates in
+  `wheel_rest` — a trackpad's few pixels an event, rounded one at a time,
+  never moved it.
+- It **owns the pointer and the keyboard** while up, like the right-click
+  menu: a click off it closes it and goes no further (so clicking the
+  select again folds it), and no key reaches the page or the chrome's
+  chords. Up/Down/PageUp/PageDown/Home/End skip disabled options and
+  optgroup headings; Enter (or Space, outside type-to-find) picks; Escape
+  and Tab close; letters find (`find_typed`: "1","0" finds 10, a repeated
+  letter cycles).
+- A tab switch, a tab close, losing window focus and a resize all close it.
+
+`examples/wpe_options.rs` proves the engine side against the real engine.
+
+### Middle-click is a background tab
 ### Key routing
 
 `handle_key_input` is a three-stage funnel and the order is load-bearing: Ctrl chords
diff --git a/Cargo.toml b/Cargo.toml
index 54b95eb..27567e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -97,3 +97,7 @@ required-features = ["wpe"]
 [[example]]
 name = "wpe_ctx"
 required-features = ["wpe"]
+
+[[example]]
+name = "wpe_options"
+required-features = ["wpe"]
diff --git a/examples/wpe_options.rs b/examples/wpe_options.rs
new file mode 100644
index 0000000..5e10cd5
--- /dev/null
+++ b/examples/wpe_options.rs
@@ -0,0 +1,109 @@
+//! A `<select>` opens a list the chrome can draw and answer, end to end.
+//!
+//! WPE has no popup of its own, so a select only opens if the embedder
+//! handles `show-option-menu`. Against the real engine, at output scale 2:
+//!
+//! * a click on the select hands over its options — labels, the optgroup
+//!   heading and child flags, the disabled one, the current value — and its
+//!   box in *logical* pixels, the chrome's own;
+//! * a pick changes the value and fires `change`, and closes the list;
+//! * closing without a pick leaves the value alone and fires nothing;
+//! * a navigation while the list is open closes it from the page's side.
+//!
+//! `cce-shadow --instance <n> run ./target/release/examples/wpe_options`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+    eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+    use cce_ui::widget::MouseButton;
+    let page = "data:text/html,<!doctype html><title>start</title>\
+<style>body{margin:0}select{position:absolute;left:100px;top:200px;width:150px;height:30px}</style>\
+<select id=q onchange=\"document.title='change:'+this.value\">\
+<option>1</option><option selected>2</option><option disabled>3</option>\
+<optgroup label=More><option>4</option><option>5</option></optgroup></select>";
+    let mut host = wpe::WebKitHost::new(url::Url::parse(page).unwrap(), (2400, 1600));
+    host.resize(2400, 1600, 2.0);
+    let settle = |h: &mut wpe::WebKitHost, n: u32| {
+        for _ in 0..n {
+            h.pump();
+            std::thread::sleep(std::time::Duration::from_millis(50));
+        }
+    };
+    // Physical pixels, as main.rs hands them over: the select's middle is
+    // (175, 215) logical.
+    let click = |h: &mut wpe::WebKitHost| {
+        let (x, y) = (175.0 * 2.0, 215.0 * 2.0);
+        h.mouse_move(x, y);
+        h.mouse_button_ui(MouseButton::Left, true, x, y);
+        h.mouse_button_ui(MouseButton::Left, false, x, y);
+    };
+    settle(&mut host, 30);
+    println!("loaded: {:?}", host.title());
+    let mut ok = true;
+    let mut check = |name: &str, pass: bool| {
+        println!("  {name:<40} {}", if pass { "OK" } else { "FAIL" });
+        ok &= pass;
+    };
+
+    click(&mut host);
+    settle(&mut host, 10);
+    let menu = host.take_option_menu();
+    println!("menu: {menu:#?}");
+    check("click opens the list", menu.is_some() && host.option_menu_open());
+    if let Some(m) = &menu {
+        let labels: Vec<_> = m.items.iter().map(|i| i.label.as_str()).collect();
+        check("  labels in order", labels == ["1", "2", "3", "More", "4", "5"]);
+        check("  current value marked", m.items[1].selected && !m.items[0].selected);
+        check("  disabled option marked", !m.items[2].enabled);
+        check("  optgroup heading marked", m.items[3].group_label);
+        check("  group children marked", m.items[4].group_child && m.items[5].group_child);
+        let (x, y, w, h) = m.anchor;
+        check(
+            "  anchor in logical pixels",
+            (x - 100.0).abs() <= 1.0 && (y - 200.0).abs() <= 1.0 && (w - 150.0).abs() <= 1.0 && (h - 30.0).abs() <= 1.0,
+        );
+    }
+
+    host.pick_option(4);
+    settle(&mut host, 10);
+    println!("after pick: {:?}", host.title());
+    check("pick changes the value", host.title().as_deref() == Some("change:4"));
+    check("  and closes the list", !host.option_menu_open());
+
+    click(&mut host);
+    settle(&mut host, 10);
+    check("opens again", host.take_option_menu().is_some());
+    host.close_option_menu();
+    settle(&mut host, 10);
+    check("close leaves the value", host.title().as_deref() == Some("change:4"));
+    check("  and closes the list", !host.option_menu_open());
+
+    click(&mut host);
+    settle(&mut host, 10);
+    check("opens a third time", host.take_option_menu().is_some());
+    host.load(url::Url::parse("data:text/html,<title>away</title>").unwrap());
+    settle(&mut host, 20);
+    println!("after navigation: {:?}", host.title());
+    check("navigation closes it from the page", !host.option_menu_open());
+
+    println!("\n{}", if ok { "OK" } else { "FAILED" });
+    std::process::exit(if ok { 0 } else { 1 });
+}
diff --git a/src/main.rs b/src/main.rs
index a3490bb..0b09932 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -340,6 +340,120 @@ impl CtxMenu {
     }
 }
 
+/// A page `<select>`'s list, drawn by the chrome at the select.
+///
+/// WPE has no popup of its own — a select nobody answers never opens — so
+/// the engine hands the options over (`take_option_menu`) and waits for a
+/// pick or a close. Like the right-click menu it owns the pointer and the
+/// keyboard while it is up, and a click off it closes it and goes no
+/// further, which is also how clicking the select again folds it.
+#[cfg(feature = "wpe")]
+struct OptMenu {
+    items: Vec<wpe::OptionItem>,
+    /// The select's box, in the chrome's logical pixels.
+    anchor: Rect,
+    /// The select's width, or the widest label's when that is wider.
+    width: f32,
+    /// The highlighted row. The pointer and the arrow keys move the same
+    /// one, as in a native list; it starts on the select's current value.
+    highlight: Option<usize>,
+    /// First visible row, when the list is longer than the room it has.
+    scroll: usize,
+    /// Wheel travel not yet worth a whole row. A trackpad sends pixels a
+    /// few at a time, and rounding each event alone never moves at all.
+    wheel_rest: f64,
+    /// Type-to-find: what has been typed, and when the last key came.
+    typed: String,
+    typed_at: std::time::Instant,
+}
+
+#[cfg(feature = "wpe")]
+const OPT_ROW_H: f32 = 24.0;
+#[cfg(feature = "wpe")]
+const OPT_FONT: f32 = 13.0;
+/// The gutter the current value's dot sits in; an optgroup's options are
+/// indented by it again.
+#[cfg(feature = "wpe")]
+const OPT_INDENT: f32 = 12.0;
+/// Type-to-find starts over after this long without a key.
+#[cfg(feature = "wpe")]
+const OPT_TYPE_RESET: std::time::Duration = std::time::Duration::from_millis(1000);
+
+/// The select list's plate and rows, from `opt_layout`: the one geometry
+/// draw and hit-test read.
+#[cfg(feature = "wpe")]
+struct OptLayout {
+    plate: Rect,
+    /// `(rect, index into items)` for each row the plate shows.
+    rows: Vec<(Rect, usize)>,
+    /// How many rows fit; more than this and the list scrolls.
+    cap: usize,
+}
+
+#[cfg(feature = "wpe")]
+impl OptMenu {
+    /// A row that can be picked: an enabled option, not a group heading.
+    fn pickable(&self, i: usize) -> bool {
+        self.items.get(i).is_some_and(|it| it.enabled && !it.group_label)
+    }
+
+    /// The pickable row `steps` pickable rows away from the highlight,
+    /// stopping at the ends rather than wrapping, as a native list does.
+    fn step(&mut self, steps: isize) {
+        let mut at = self.highlight;
+        for _ in 0..steps.unsigned_abs() {
+            let next = if steps > 0 {
+                let from = at.map_or(0, |h| h + 1);
+                (from..self.items.len()).find(|&i| self.pickable(i))
+            } else {
+                let to = at.unwrap_or(self.items.len());
+                (0..to).rev().find(|&i| self.pickable(i))
+            };
+            match next {
+                Some(i) => at = Some(i),
+                None => break,
+            }
+        }
+        self.highlight = at;
+    }
+
+    /// Type-to-find. Letters typed in quick succession are one prefix
+    /// ("1", "0" finds "10"); the same letter again cycles through the rows
+    /// starting with it.
+    fn find_typed(&mut self, text: &str) {
+        let now = std::time::Instant::now();
+        if now.duration_since(self.typed_at) > OPT_TYPE_RESET {
+            self.typed.clear();
+        }
+        self.typed_at = now;
+        self.typed.push_str(&text.to_lowercase());
+        let first = self.typed.chars().next().unwrap_or(' ');
+        let cycling = self.typed.chars().all(|c| c == first);
+        let (prefix, from) = if cycling {
+            (first.to_string(), self.highlight.map_or(0, |h| h + 1))
+        } else {
+            (self.typed.clone(), self.highlight.unwrap_or(0))
+        };
+        let n = self.items.len();
+        if let Some(i) = (0..n).map(|k| (from + k) % n).find(|&i| {
+            self.pickable(i)
+                && self.items[i].label.trim_start().to_lowercase().starts_with(&prefix)
+        }) {
+            self.highlight = Some(i);
+        }
+    }
+
+    /// Scroll just far enough that the highlighted row is in view.
+    fn reveal(&mut self, cap: usize) {
+        let Some(h) = self.highlight else { return };
+        if h < self.scroll {
+            self.scroll = h;
+        } else if h >= self.scroll + cap {
+            self.scroll = h + 1 - cap;
+        }
+    }
+}
+
 /// The bookmarks menu: the bar's list of saved pages, open under (or over)
 /// the "B" button in the controls row.
 ///
@@ -658,6 +772,8 @@ struct BrowserApp {
     /// Open right-click menu, if any.
     #[cfg(feature = "wpe")]
     ctx_menu: Option<CtxMenu>,
+    #[cfg(feature = "wpe")]
+    opt_menu: Option<OptMenu>,
     /// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
     /// wakes the loop itself through its `EventLoopWaker`.
     #[cfg(feature = "wpe")]
@@ -1886,6 +2002,131 @@ impl BrowserApp {
         }
     }
 
+    /// Put a select's list up at the select, highlighting its current value.
+    #[cfg(feature = "wpe")]
+    fn open_opt_menu(&mut self, info: wpe::OptionMenuInfo) {
+        let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+        let widest = info
+            .items
+            .iter()
+            .map(|it| {
+                let indent = if it.group_child { OPT_INDENT } else { 0.0 };
+                measure_text_width(&it.label, &sans, OPT_FONT) + indent
+            })
+            .fold(0.0, f32::max);
+        let (x, y, width, height) = info.anchor;
+        // Room for the dot's gutter, the text padding, the row inset and a
+        // scroll thumb on top of the widest label.
+        let menu_w = (widest + OPT_INDENT + 2.0 * text_pad() + 12.0).max(width).max(80.0);
+        let mut menu = OptMenu {
+            highlight: info.items.iter().position(|it| it.selected),
+            items: info.items,
+            anchor: Rect { x, y, width, height },
+            width: menu_w,
+            scroll: 0,
+            wheel_rest: 0.0,
+            typed: String::new(),
+            typed_at: std::time::Instant::now(),
+        };
+        if menu.highlight.is_some_and(|h| !menu.pickable(h)) {
+            menu.highlight = None;
+        }
+        self.opt_menu = Some(menu);
+        self.opt_reveal();
+    }
+
+    /// Take the select's list down without a pick.
+    #[cfg(feature = "wpe")]
+    fn close_opt_menu(&mut self) {
+        if self.opt_menu.take().is_some() {
+            self.host.close_option_menu();
+        }
+    }
+
+    /// Pick row `index`, if it can be picked. A heading or a disabled
+    /// option does nothing and leaves the list up.
+    #[cfg(feature = "wpe")]
+    fn pick_opt(&mut self, index: usize) {
+        if self.opt_menu.as_ref().is_some_and(|m| m.pickable(index)) {
+            self.opt_menu = None;
+            self.host.pick_option(index);
+        }
+    }
+
+    /// Keep the highlighted row in view.
+    #[cfg(feature = "wpe")]
+    fn opt_reveal(&mut self) {
+        let Some(cap) = self.opt_layout().map(|l| l.cap) else { return };
+        if let Some(m) = self.opt_menu.as_mut() {
+            m.reveal(cap);
+        }
+    }
+
+    /// The select list's plate and rows, or `None` when it is closed.
+    ///
+    /// Under the select, or over it when there is more room above — never
+    /// covering the select itself, and never past the window: a list longer
+    /// than the room scrolls.
+    #[cfg(feature = "wpe")]
+    fn opt_layout(&self) -> Option<OptLayout> {
+        let m = self.opt_menu.as_ref()?;
+        let n = m.items.len();
+        if n == 0 {
+            return None;
+        }
+        let margin = bar_margin();
+        // style: deliberate — 2px off the select, so the list reads as
+        // attached to it; the select is page content, not a plate sibling.
+        let below = self.win.1 - margin - (m.anchor.y + m.anchor.height + 2.0);
+        let above = m.anchor.y - 2.0 - margin;
+        let need = 2.0 * plate_pad() + n as f32 * OPT_ROW_H;
+        let down = need <= below || below >= above;
+        let room = if down { below } else { above };
+        let cap = (((room - 2.0 * plate_pad()) / OPT_ROW_H).floor().max(1.0) as usize).min(n);
+        let height = 2.0 * plate_pad() + cap as f32 * OPT_ROW_H;
+        let y = if down {
+            m.anchor.y + m.anchor.height + 2.0
+        } else {
+            m.anchor.y - 2.0 - height
+        }
+        .max(0.0);
+        let width = m.width.min(self.win.0 - 2.0 * margin);
+        let x = m.anchor.x.clamp(0.0, (self.win.0 - width).max(0.0));
+        let plate = Rect { x, y, width, height };
+        let first = m.scroll.min(n - cap);
+        // style: deliberate — the 2px hairline keeps a row's highlight off
+        // the plate's roll.
+        let rows = (0..cap)
+            .map(|k| {
+                let r = Rect {
+                    x: plate.x + 2.0,
+                    y: plate.y + plate_pad() + k as f32 * OPT_ROW_H,
+                    width: plate.width - 4.0,
+                    height: OPT_ROW_H,
+                };
+                (r, first + k)
+            })
+            .collect();
+        Some(OptLayout { plate, rows, cap })
+    }
+
+    /// The select list's row at a pointer position, if any.
+    #[cfg(feature = "wpe")]
+    fn opt_hit(&self, x: f32, y: f32) -> Option<usize> {
+        let l = self.opt_layout()?;
+        l.rows.iter().find(|(r, _)| r.contains(x, y)).map(|(_, i)| *i)
+    }
+
+    /// Scroll the select list by `rows`, positive down.
+    #[cfg(feature = "wpe")]
+    fn opt_scroll(&mut self, rows: isize) {
+        let Some(cap) = self.opt_layout().map(|l| l.cap) else { return };
+        if let Some(m) = self.opt_menu.as_mut() {
+            let max = m.items.len().saturating_sub(cap) as isize;
+            m.scroll = (m.scroll.min(max as usize) as isize + rows).clamp(0, max) as usize;
+        }
+    }
+
     /// Build the right-click menu from what the hit test found, placed at
     /// the pointer and clamped to the window.
     #[cfg(feature = "wpe")]
@@ -2109,6 +2350,11 @@ impl BrowserApp {
 
     /// Close a tab; returns `Message::Quit` when it was the last one.
     fn close_tab(&mut self, index: usize) -> Option<Message> {
+        // The host closes the list's engine side with the tab.
+        #[cfg(feature = "wpe")]
+        {
+            self.opt_menu = None;
+        }
         if !self.host.close_tab(index) {
             // Deliberately emptied: save the empty set so the next launch
             // starts on the homepage instead of restoring what was closed.
@@ -2130,6 +2376,8 @@ impl BrowserApp {
             self.ac_menu = None;
             self.last_field = None;
             self.host.clear_form_events();
+            // The host closes the list's engine side as it switches.
+            self.opt_menu = None;
         }
         self.host.activate(index);
         self.url_focused = false;
@@ -2584,6 +2832,61 @@ impl BrowserApp {
         );
     }
 
+    /// Draw a select's list: its options, the current value marked with a
+    /// dot, optgroup headings dim and their options indented under them.
+    #[cfg(feature = "wpe")]
+    fn paint_opt_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
+        let Some(l) = self.opt_layout() else { return };
+        let Some(m) = self.opt_menu.as_ref() else { return };
+        pc.plate(
+            l.plate,
+            (8.0, 8.0, 8.0, 8.0),
+            &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
+            cce_ui::layout::bevel_width().min(3.0),
+        );
+        let n = m.items.len();
+        let scrolls = n > l.cap;
+        for (r, i) in &l.rows {
+            let Some(it) = m.items.get(*i) else { continue };
+            if m.highlight == Some(*i) && m.pickable(*i) {
+                pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
+            }
+            let gutter = r.x + text_pad();
+            if it.selected {
+                let t = TEXT.map(|c| c as f32 / 255.0);
+                pc.circle(gutter + OPT_INDENT / 2.0 - 2.0, r.y + r.height / 2.0, 2.5, [t[0], t[1], t[2], 1.0]);
+            }
+            let x = gutter + OPT_INDENT + if it.group_child { OPT_INDENT } else { 0.0 };
+            let avail = r.x + r.width - x - text_pad() - if scrolls { 6.0 } else { 0.0 };
+            let color = if m.pickable(*i) { TEXT } else { TEXT_DIM };
+            pc.text(
+                Self::fit_text(&it.label, sans, OPT_FONT, avail),
+                x,
+                cce_ui::layout::align_text_y(r.y, r.height, OPT_FONT, 0.0),
+                OPT_FONT,
+                color,
+            );
+        }
+        // A long list says where in it the view is.
+        if scrolls {
+            let track = l.plate.height - 2.0 * plate_pad();
+            let thumb = (track * l.cap as f32 / n as f32).max(12.0);
+            let first = l.rows.first().map_or(0, |(_, i)| *i);
+            let at = (track - thumb) * first as f32 / (n - l.cap) as f32;
+            pc.rounded_rect(
+                Rect {
+                    x: l.plate.x + l.plate.width - 7.0,
+                    y: l.plate.y + plate_pad() + at,
+                    width: 3.0,
+                    height: thumb,
+                },
+                1.5,
+                (true, true, true, true),
+                [0.6, 0.62, 0.66, 0.5],
+            );
+        }
+    }
+
     /// Draw the right-click menu: a small plate at the pointer, rows with a
     /// hover highlight, disabled rows dimmed. Same primitives as everything
     /// else in this chrome.
@@ -2809,6 +3112,8 @@ impl Application for BrowserApp {
             #[cfg(feature = "wpe")]
             ctx_menu: None,
             #[cfg(feature = "wpe")]
+            opt_menu: None,
+            #[cfg(feature = "wpe")]
             sender,
             font_system: cce_ui::create_font_system(),
             session,
@@ -2905,6 +3210,18 @@ impl Application for BrowserApp {
                     self.open_ctx_menu(info);
                     *needs_rebuild = true;
                 }
+                #[cfg(feature = "wpe")]
+                if let Some(info) = self.host.take_option_menu() {
+                    self.open_opt_menu(info);
+                    *needs_rebuild = true;
+                }
+                // The page closed the list itself: the select went away, or
+                // the page navigated.
+                #[cfg(feature = "wpe")]
+                if self.opt_menu.is_some() && !self.host.option_menu_open() {
+                    self.opt_menu = None;
+                    *needs_rebuild = true;
+                }
                 if self.host.take_download_started() {
                     self.open_internal_page("cce://downloads");
                 }
@@ -3080,6 +3397,10 @@ impl Application for BrowserApp {
         // reach us; forget them rather than act on a stale Shift or drag.
         if !focused {
             self.shift_held = false;
+            // A select's list is a transient of the page, and goes with focus
+            // as a native one does.
+            #[cfg(feature = "wpe")]
+            self.close_opt_menu();
             if self.url.dragging() {
                 self.url.release();
                 self.url_entry_press = false;
@@ -3102,6 +3423,11 @@ impl Application for BrowserApp {
     }
 
     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
+        // The select the list hangs from is about to move.
+        #[cfg(feature = "wpe")]
+        if self.win != (width, height) {
+            self.close_opt_menu();
+        }
         self.win = (width, height);
         self.scale = scale;
         let (w, h) = self.content_px();
@@ -3155,6 +3481,22 @@ impl Application for BrowserApp {
             }
             return;
         }
+        // A select's list moves its highlight onto a row the pointer could
+        // pick; off the rows it stays where it was, as a native list's does.
+        // The page under it sees no moves.
+        #[cfg(feature = "wpe")]
+        if self.opt_menu.is_some() {
+            let over = self
+                .opt_hit(pos.x, pos.y)
+                .filter(|&i| self.opt_menu.as_ref().is_some_and(|m| m.pickable(i)));
+            if let (Some(i), Some(m)) = (over, self.opt_menu.as_mut()) {
+                if m.highlight != Some(i) {
+                    m.highlight = Some(i);
+                    *_needs_rebuild = true;
+                }
+            }
+            return;
+        }
         #[cfg(feature = "wpe")]
         if self.ctx_menu.is_some() {
             // Hover highlight tracks the pointer; the page underneath does
@@ -3291,6 +3633,22 @@ impl Application for BrowserApp {
             return None;
         }
 
+        // An open select list owns the next click: on a row it picks, off the
+        // plate it closes — clicking the select again included — and either
+        // way the click goes no further.
+        #[cfg(feature = "wpe")]
+        if let Some(l) = self.opt_layout() {
+            if pressed {
+                *needs_rebuild = true;
+                match self.opt_hit(pos.x, pos.y) {
+                    Some(i) if button == MouseButton::Left => self.pick_opt(i),
+                    _ if !l.plate.contains(pos.x, pos.y) => self.close_opt_menu(),
+                    _ => {}
+                }
+            }
+            return None;
+        }
+
         // An open context menu owns the next click: on an item it dispatches,
         // anywhere else it just closes — either way the click goes no further.
         #[cfg(feature = "wpe")]
@@ -3480,6 +3838,33 @@ impl Application for BrowserApp {
     }
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        // A select's list takes the wheel: over it, it scrolls the list;
+        // anywhere else it is swallowed, so the select stays under it.
+        #[cfg(feature = "wpe")]
+        if let Some(l) = self.opt_layout() {
+            if l.plate.contains(pos.x, pos.y) {
+                // Positive is up, cce-ui's winit convention; three rows a
+                // notch, a row per row's height of finger travel.
+                let travel = match delta {
+                    MouseScrollDelta::LineDelta(_, y) => -*y as f64 * 3.0,
+                    MouseScrollDelta::PixelDelta(p) => -p.y / OPT_ROW_H as f64,
+                };
+                let Some(m) = self.opt_menu.as_mut() else { return };
+                m.wheel_rest += travel;
+                let rows = m.wheel_rest.trunc();
+                m.wheel_rest -= rows;
+                self.opt_scroll(rows as isize);
+                // The rows moved under a pointer that did not.
+                let over = self.opt_hit(pos.x, pos.y);
+                if let (Some(i), Some(m)) = (over, self.opt_menu.as_mut()) {
+                    if m.pickable(i) {
+                        m.highlight = Some(i);
+                    }
+                }
+                *needs_rebuild = true;
+            }
+            return;
+        }
         // The account list moves with its field, so the page keeps the
         // wheel — but not under the plate itself.
         #[cfg(feature = "wpe")]
@@ -3555,6 +3940,52 @@ impl Application for BrowserApp {
         if matches!(event.logical_key, Key::Named(NamedKey::Shift)) {
             self.shift_held = event.state == ElementState::Pressed;
         }
+        // An open select list has the keyboard: arrows and paging move the
+        // highlight, Enter (or Space, before any type-to-find) picks it,
+        // Escape and Tab close, and letters find. Nothing reaches the page or
+        // the chrome's chords while it is up.
+        #[cfg(feature = "wpe")]
+        if self.opt_menu.is_some() && event.state == ElementState::Pressed {
+            *needs_rebuild = true;
+            let cap = self.opt_layout().map_or(1, |l| l.cap) as isize;
+            let typing = self.opt_menu.as_ref().is_some_and(|m| {
+                !m.typed.is_empty() && m.typed_at.elapsed() <= OPT_TYPE_RESET
+            });
+            let Some(m) = self.opt_menu.as_mut() else { return None };
+            match &event.logical_key {
+                Key::Named(NamedKey::ArrowDown) => m.step(1),
+                Key::Named(NamedKey::ArrowUp) => m.step(-1),
+                Key::Named(NamedKey::PageDown) => m.step(cap - 1),
+                Key::Named(NamedKey::PageUp) => m.step(1 - cap),
+                Key::Named(NamedKey::End) => m.step(isize::MAX),
+                Key::Named(NamedKey::Home) => m.step(isize::MIN + 1),
+                Key::Named(NamedKey::Escape | NamedKey::Tab) => {
+                    self.close_opt_menu();
+                    return None;
+                }
+                Key::Named(NamedKey::Space) if !typing => {
+                    if let Some(i) = m.highlight {
+                        self.pick_opt(i);
+                    }
+                    return None;
+                }
+                Key::Named(NamedKey::Enter) => {
+                    match m.highlight {
+                        Some(i) => self.pick_opt(i),
+                        None => self.close_opt_menu(),
+                    }
+                    return None;
+                }
+                _ if !event.ctrl && !event.alt => {
+                    if let Some(text) = event.text.as_deref().filter(|t| !t.is_empty()) {
+                        m.find_typed(text);
+                    }
+                }
+                _ => {}
+            }
+            self.opt_reveal();
+            return None;
+        }
         #[cfg(feature = "wpe")]
         if self.ctx_menu.is_some() && event.state == ElementState::Pressed {
             // Any key dismisses; Escape is just the one people will mean.
@@ -4021,6 +4452,8 @@ impl Application for BrowserApp {
         #[cfg(feature = "wpe")]
         self.paint_save_offer(&mut pc, &sans);
         #[cfg(feature = "wpe")]
+        self.paint_opt_menu(&mut pc, &sans);
+        #[cfg(feature = "wpe")]
         self.paint_ctx_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
         self.paint_modal(&mut pc, &sans);
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 72e9b56..24cd839 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -735,6 +735,14 @@ impl WebKitHost {
                 on_context_menu as *const () as usize,
                 &self.prompts,
             );
+            // A `<select>` asking to open. WPE draws no popup of its own: a
+            // select nobody answers here simply never opens.
+            connect_raw(
+                wv,
+                "show-option-menu",
+                on_show_option_menu as *const () as usize,
+                &self.prompts,
+            );
             let (lw, lh) = self.logical_size();
             wpe_view_resized(view, lw, lh);
             wpe_view_set_visible(view, 1);
@@ -804,6 +812,7 @@ impl WebKitHost {
         }
         let was_active = index == self.active;
         let old_active = self.active;
+        self.close_option_menu();
         // Anything still held belongs to a view that may be the one about to
         // be destroyed; hand it back while it is still safe to. Losing that
         // frame costs a repaint, which the tab change causes anyway.
@@ -847,6 +856,8 @@ impl WebKitHost {
                 wpe_view_set_visible(old.view, 0);
             }
             self.active = index;
+            // A select's list belongs to the page going away.
+            self.close_option_menu();
             // Login fields reported by, and fill asks from, the tab going
             // away: a list must not open over the next one, and a pick made
             // there must have nowhere to land.
@@ -1427,6 +1438,54 @@ impl WebKitHost {
         self.prompts.borrow_mut().context_menu.take()
     }
 
+    /// One-shot: the `<select>` list the page just asked to show, if any.
+    /// The menu itself stays held until [`Self::pick_option`] or
+    /// [`Self::close_option_menu`] answers it, or the page closes it.
+    pub fn take_option_menu(&self) -> Option<OptionMenuInfo> {
+        self.prompts.borrow_mut().option_menu_new.take()
+    }
+
+    /// Whether a select's list is still waiting on an answer. False once the
+    /// page has closed it itself — the select was removed, the page
+    /// navigated — which is how the chrome learns to take its list down.
+    pub fn option_menu_open(&self) -> bool {
+        self.prompts.borrow().option_menu.is_some()
+    }
+
+    /// Choose option `index` and close the list. The select changes value
+    /// and fires its `input`/`change` as for any pick.
+    pub fn pick_option(&self, index: usize) {
+        let Some(menu) = self.prompts.borrow_mut().option_menu.take() else { return };
+        unsafe {
+            webkit_option_menu_activate_item(menu, index as u32);
+            webkit_option_menu_close(menu);
+            g_object_unref(menu as *mut _);
+        }
+    }
+
+    /// Close the list without choosing. Nothing is selected on the way
+    /// (`select_item` is never called), so closing leaves the value as it was.
+    pub fn close_option_menu(&self) {
+        let menu = {
+            let mut p = self.prompts.borrow_mut();
+            p.option_menu_new = None;
+            p.option_menu.take()
+        };
+        // Taken out first: `close` emits the menu's own close signal, whose
+        // handler borrows the prompts too.
+        if let Some(menu) = menu {
+            unsafe {
+                webkit_option_menu_close(menu);
+                g_object_unref(menu as *mut _);
+            }
+        }
+    }
+
+    /// Links the pages asked to open in background tabs since the last call.
+    pub fn take_background_opens(&self) -> Vec<Url> {
+        std::mem::take(&mut self.prompts.borrow_mut().background_opens)
+    }
+
     /// Fetch `uri` through WebKit's download pipeline — same signals, same
     /// store, same `cce://downloads` page as a navigated download. This is
     /// what "Download Link/Image" in the context menu dispatches to.
@@ -1919,6 +1978,14 @@ pub(super) struct Prompts {
     auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
     /// The page asked for a context menu; the chrome draws its own.
     context_menu: Option<ContextMenuInfo>,
+    /// The `<select>` list waiting on an answer, reffed until it gets one or
+    /// the page closes it. Never more than one: a newer one closes the last.
+    option_menu: Option<*mut WebKitOptionMenu>,
+    /// What that list holds, until the chrome takes it to draw.
+    option_menu_new: Option<OptionMenuInfo>,
+    /// Links middle-clicked in a page, oldest first, for the chrome to open
+    /// as background tabs.
+    background_opens: Vec<Url>,
     /// Login fields the account watcher reported, oldest first. A queue and
     /// not a slot: a blur followed by a focus is two different states, and
     /// collapsing them would leave the list open over the wrong field.
@@ -1941,6 +2008,28 @@ pub struct ContextMenuInfo {
     pub is_editable: bool,
 }
 
+/// A `<select>`'s option list, for the chrome to draw at the select.
+#[derive(Debug, Clone)]
+pub struct OptionMenuInfo {
+    pub items: Vec<OptionItem>,
+    /// The select's box — `(x, y, width, height)` in the view's logical
+    /// pixels, which are the chrome's.
+    pub anchor: (f32, f32, f32, f32),
+}
+
+/// One row of a select's list: an `<option>`, or an `<optgroup>`'s label.
+#[derive(Debug, Clone)]
+pub struct OptionItem {
+    pub label: String,
+    /// An `<optgroup>` heading: drawn, never picked.
+    pub group_label: bool,
+    /// An option inside an `<optgroup>`, drawn indented under its heading.
+    pub group_child: bool,
+    pub enabled: bool,
+    /// The select's current value.
+    pub selected: bool,
+}
+
 /// A page's `alert` / `confirm` / `prompt`, waiting on the chrome.
 #[derive(Debug, Clone)]
 pub struct PendingDialog {
@@ -2144,3 +2233,78 @@ unsafe extern "C" fn on_context_menu(
     prompts.borrow_mut().context_menu = Some(info);
     1
 }
+
+/// A `<select>` asked to open. Read its options and where it is, hold the
+/// menu, and claim it: the chrome draws the list and answers with
+/// `pick_option` or `close_option_menu`. Returning FALSE would leave it to
+/// WebKit's default, which on WPE is nothing at all.
+unsafe extern "C" fn on_show_option_menu(
+    _wv: *mut WebKitWebView,
+    menu: *mut WebKitOptionMenu,
+    rect: *mut WebKitRectangle,
+    data: gpointer,
+) -> gboolean {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let items = (0..webkit_option_menu_get_n_items(menu))
+        .map(|i| {
+            let item = webkit_option_menu_get_item(menu, i);
+            OptionItem {
+                label: from_cstr(webkit_option_menu_item_get_label(item)).unwrap_or_default(),
+                group_label: webkit_option_menu_item_is_group_label(item) != 0,
+                group_child: webkit_option_menu_item_is_group_child(item) != 0,
+                enabled: webkit_option_menu_item_is_enabled(item) != 0,
+                selected: webkit_option_menu_item_is_selected(item) != 0,
+            }
+        })
+        .collect();
+    let anchor = if rect.is_null() {
+        (0.0, 0.0, 0.0, 0.0)
+    } else {
+        let r = &*rect;
+        (r.x as f32, r.y as f32, r.width as f32, r.height as f32)
+    };
+    g_object_ref(menu as *mut _);
+    // The page can close the list itself (the select goes away, the page
+    // navigates); hearing that is how the chrome's copy comes down too.
+    let name = cstr("close");
+    g_signal_connect_data(
+        menu as *mut _,
+        name.as_ptr(),
+        Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+            on_option_menu_close as *const () as usize,
+        )),
+        // The close handler's own reference, dropped with the connection.
+        {
+            Rc::increment_strong_count(data as *const RefCell<Prompts>);
+            data
+        },
+        Some(drop_prompts_ref),
+        0,
+    );
+    let previous = {
+        let mut p = prompts.borrow_mut();
+        p.option_menu_new = Some(OptionMenuInfo { items, anchor });
+        p.option_menu.replace(menu)
+    };
+    // Closed outside the borrow: its close handler borrows the prompts.
+    if let Some(old) = previous {
+        webkit_option_menu_close(old);
+        g_object_unref(old as *mut _);
+    }
+    1
+}
+
+/// A held list was closed — by the page, or by our own `close`. Only the
+/// first case finds it still held; ours took it out before closing. WebKit
+/// keeps its own reference across the emission, so dropping ours here is
+/// safe.
+unsafe extern "C" fn on_option_menu_close(menu: *mut WebKitOptionMenu, data: gpointer) {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let mut p = prompts.borrow_mut();
+    if p.option_menu == Some(menu) {
+        p.option_menu = None;
+        p.option_menu_new = None;
+        drop(p);
+        g_object_unref(menu as *mut _);
+    }
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index d55dc78..591d83f 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -20,4 +20,6 @@ pub mod formwatch;
 // Not consumed yet — main.rs still drives ServoHost.
 #[allow(unused_imports)]
 pub use formwatch::FormEvent;
-pub use host::{ContextMenuInfo, PendingAuth, PendingDialog, Tab, WebKitHost};
+pub use host::{
+    ContextMenuInfo, OptionItem, OptionMenuInfo, PendingAuth, PendingDialog, Tab, WebKitHost,
+};