Skip to content

Step 5: ie-shell — Keyboard-driven UI #8

@thomasnemer

Description

@thomasnemer

Parent: #2

Goal

Wire keyboard shortcuts and overlay state management into the winit event loop. All browser interaction is keyboard-driven: no visible menu bar, no persistent toolbars. Overlays (address bar, tab list, bookmarks) appear on demand and dismiss with Escape.

Prerequisites

  • Step 4 (navigation service, tab model, bookmarks)

Implementation

Action enum and keybinding resolver (keybindings.rs)

  • Action enum — all possible user actions:
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum Action {
        ShowAddressBar,      // Ctrl+L
        NewTab,              // Ctrl+T
        CloseTab,            // Ctrl+W
        NextTab,             // Ctrl+Tab
        PrevTab,             // Ctrl+Shift+Tab
        ShowTabList,         // Ctrl+Shift+T
        BookmarkCurrentPage, // Ctrl+D
        ShowBookmarks,       // Ctrl+Shift+B
        GoBack,              // Alt+Left
        GoForward,           // Alt+Right
        DismissOverlay,      // Escape
        Quit,                // Ctrl+Q
    }
  • fn resolve_keybinding(event: &KeyEvent, modifiers: &ModifiersState) -> Option<Action>:
    • Only trigger on ElementState::Pressed (not release)
    • Match on event.logical_key:
      • Key::Named(NamedKey::Escape)DismissOverlay
      • Key::Named(NamedKey::Tab) + Ctrl → NextTab (+ Shift → PrevTab)
      • Key::Named(NamedKey::ArrowLeft) + Alt → GoBack
      • Key::Named(NamedKey::ArrowRight) + Alt → GoForward
      • Key::Character("l") + Ctrl → ShowAddressBar
      • Key::Character("t") + Ctrl → NewTab (+ Shift → ShowTabList)
      • Key::Character("w") + Ctrl → CloseTab
      • Key::Character("d") + Ctrl → BookmarkCurrentPage
      • Key::Character("b") + Ctrl + Shift → ShowBookmarks
      • Key::Character("q") + Ctrl → Quit
    • Return None for unmapped keys

Overlay state machine (overlay.rs)

  • OverlayState enum:
    #[derive(Debug, Clone)]
    pub enum OverlayState {
        None,
        AddressBar(AddressBarState),
        TabList,
        Bookmarks,
    }
  • AddressBarState struct:
    #[derive(Debug, Clone)]
    pub struct AddressBarState {
        pub input: String,
        pub cursor: usize,
    }
  • AddressBarState::new(initial: &str) -> Self — cursor at end of initial text
  • AddressBarState::insert_char(&mut self, c: char) — insert at cursor, advance cursor
  • AddressBarState::delete_back(&mut self) — backspace: remove char before cursor (no-op if cursor at 0)
  • AddressBarState::delete_forward(&mut self) — delete: remove char at cursor (no-op if cursor at end)
  • AddressBarState::move_left(&mut self) — decrement cursor (clamp at 0)
  • AddressBarState::move_right(&mut self) — increment cursor (clamp at input.len())
  • AddressBarState::move_home(&mut self) — cursor to 0
  • AddressBarState::move_end(&mut self) — cursor to input.len()
  • AddressBarState::submit(&self) -> &str — return current input for URL parsing
  • OverlayState::is_active(&self) -> bool — true unless None

Event loop integration (app.rs)

  • Expand Browser struct fields:
    pub struct Browser {
        window: Option<Window>,
        tab_manager: TabManager,
        bookmark_store: BookmarkStore,
        overlay: OverlayState,
        navigator: Arc<dyn NavigationService + Send + Sync>,
        tokio_runtime: tokio::runtime::Runtime,
        nav_receiver: mpsc::Receiver<(TabId, Result<NavigationResult, NavigationError>)>,
        modifiers: ModifiersState,
        event_loop_proxy: EventLoopProxy<UserEvent>,
    }
  • Track modifier state via WindowEvent::ModifiersChanged → store in self.modifiers
  • WindowEvent::KeyboardInput handling:
    • If overlay is AddressBar:
      • Enter → parse input as URL, trigger navigation, dismiss overlay
      • Escape → dismiss overlay
      • Backspacedelete_back()
      • Deletedelete_forward()
      • Left/Right/Home/End → cursor movement
      • Character input → insert_char()
      • Ctrl+L → reset address bar with current URL
      • Other Ctrl+key combos → resolve as keybinding (so Ctrl+W still works while address bar is open)
    • If overlay is NOT active:
      • Pass to resolve_keybinding(), dispatch action
  • Character input: handle WindowEvent::KeyboardInput where event.text is Some(text) and no Ctrl/Alt modifier — feed characters to address bar
  • Action dispatch:
    • ShowAddressBaroverlay = AddressBar(AddressBarState::new(current_url_or_empty))
    • NewTabtab_manager.new_tab(), log
    • CloseTabtab_manager.close_tab(active_id), log. If no tabs left, exit.
    • NextTabtab_manager.next_tab()
    • PrevTabtab_manager.prev_tab()
    • ShowTabListoverlay = TabList
    • BookmarkCurrentPage → if active tab has URL: bookmark_store.add(url, title)
    • ShowBookmarksoverlay = Bookmarks
    • GoBacktab_manager.go_back()
    • GoForwardtab_manager.go_forward()
    • DismissOverlayoverlay = None
    • Quitevent_loop.exit()

Navigation async bridge

  • On address bar submit (Enter):
    • URL parsing rules: bare hostnames always get https:// prepended. User must type http:// explicitly, which will be blocked unless --allow-http was passed.
    let url = Url::parse(input).or_else(|_| Url::parse(&format!("https://{input}")))?;
    let tab_id = self.tab_manager.active_tab().unwrap().id;
    self.tab_manager.active_tab_mut().unwrap().state = TabState::Loading;
    self.tab_manager.active_tab_mut().unwrap().url = Some(url.clone());
    self.overlay = OverlayState::None;
    
    let proxy = self.event_loop_proxy.clone();
    let navigator = Arc::clone(&self.navigator);
    self.tokio_runtime.spawn(async move {
        let result = navigator.navigate(&url).await;
        let _ = proxy.send_event(UserEvent::NavigationComplete(tab_id, result));
    });

Winit event loop wake-up fix

Instead of polling try_recv() in about_to_wait, use EventLoopProxy::send_event() from the async task to wake the event loop. This avoids the polling problem where the event loop doesn't know when new data is available.

  • Define a UserEvent enum:
    pub enum UserEvent {
        NavigationComplete(TabId, Result<NavigationResult, NavigationError>),
    }
  • Use EventLoop::with_user_event() to create the event loop
  • Handle Event::UserEvent(UserEvent::NavigationComplete(tab_id, result)) in the event handler:
    • Ok(NavigationResult) → update tab: state = Loaded, store source, update title
    • Err(NavigationError) → update tab: state = Error(msg)
    • Request redraw

Tracing output

Since there is no rendering in Phase 1, all state changes should be logged via tracing::info!:

  • info!("new tab: id={}", id)
  • info!("close tab: id={}", id)
  • info!("switch tab: id={}", id)
  • info!("navigate: tab={}, url={}", id, url)
  • info!("navigation complete: tab={}, status={}", id, status)
  • info!("overlay: {:?}", overlay_state)
  • info!("bookmark added: {}", url)
  • info!("go_back: tab={}", id)
  • info!("go_forward: tab={}", id)

Tests

Keybinding tests (keybindings.rs)

  • Ctrl+L → ShowAddressBar
  • Ctrl+T → NewTab
  • Ctrl+Shift+T → ShowTabList
  • Ctrl+W → CloseTab
  • Ctrl+Tab → NextTab
  • Ctrl+Shift+Tab → PrevTab
  • Ctrl+D → BookmarkCurrentPage
  • Ctrl+Shift+B → ShowBookmarks
  • Alt+Left → GoBack
  • Alt+Right → GoForward
  • Escape → DismissOverlay
  • Ctrl+Q → Quit
  • Plain letter 'a' → None
  • Ctrl+X (unmapped) → None
  • Key release event → None (only press triggers)

OverlayState tests (overlay.rs)

  • None.is_active() → false
  • AddressBar.is_active() → true
  • TabList.is_active() → true

AddressBarState tests (overlay.rs)

  • new("https://example.com") → input matches, cursor at end
  • new("") → empty input, cursor at 0
  • insert_char('a') on empty → input is "a", cursor is 1
  • insert_char at middle: "abc" cursor=1, insert 'x' → "axbc", cursor=2
  • delete_back removes char before cursor: "abc" cursor=2 → "ac" cursor=1
  • delete_back at cursor=0 → no change
  • delete_forward at cursor=1 in "abc" → "ac" cursor=1
  • delete_forward at end → no change
  • move_left decrements cursor, clamps at 0
  • move_right increments cursor, clamps at len
  • move_home → cursor=0
  • move_end → cursor=len
  • submit returns current input string
  • Unicode: insert multibyte char (e.g., 'é'), cursor and string correct

Action dispatch tests

These test the logic in isolation (without winit):

  • ShowAddressBar sets overlay to AddressBar with current URL
  • NewTab increments tab count
  • CloseTab decrements tab count
  • NextTab/PrevTab changes active tab
  • BookmarkCurrentPage adds to bookmark store
  • GoBack calls tab_manager.go_back()
  • GoForward calls tab_manager.go_forward()
  • DismissOverlay resets overlay to None

Acceptance Criteria

  • cargo test -p ie-shell — all tests pass
  • cargo clippy -p ie-shell -- -D warnings — no warnings
  • cargo run -p ie-shell — window opens, Ctrl+L/T/W etc. produce tracing output
  • RUST_LOG=info cargo run — shows tab/navigation/overlay state transitions in terminal
  • Alt+Left/Right produce go_back/go_forward tracing output

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions