Stage 1: buffer model (BrowserWindow + (browser buffers) session)
ober
bd7a8ca7e93b38313d91295a4977e4557abe7f4f
--- a/.build.yml +++ b/.build.yml @@ -74,5 +74,14 @@ tasks: export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)" export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so" make test-gui - # Stage snapshots as a downloadable artifact (~/jerboa-browser/...). + - test-buffers: | + cd jerboa-browser + # Stage 1 buffer-model functional tests (offscreen, hermetic in CI). + export QT_QPA_PLATFORM=offscreen JWB_TEST_NO_NETWORK=1 + export QTWEBENGINE_DISABLE_SANDBOX=1 + export QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" + export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)" + export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so" + make test-buffers + # Stage all harness snapshots (stage0 + stage1) as a downloadable artifact. tar czf gui-snapshots.tar.gz test-artifacts --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme LIBDIRS := $(CURDIR)/scheme:$(JERBOA_HOME)/lib -.PHONY: binary test test-gui repl clean help +.PHONY: binary test test-gui test-buffers repl clean help .DEFAULT_GOAL := help # Build the self-contained native ./jerboa-browser (Chez + boot + (browser)). @@ -19,6 +19,11 @@ test-gui: QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \ $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-gui-test.ss +# Stage 1 buffer-model functional tests (headless). Writes PNGs to ./test-artifacts. +test-buffers: + QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-buffers-test.ss + # REPL with (browser) preloaded, in the interpreter. repl: JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --program scheme/browser-repl.ss --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,17 +96,28 @@ to host (building the window shell before buffers would be throwaway). - **Deferred to Stage 1**: `QMainWindow` chrome (stacked views + status + minibuffer line) and `--repl`-on-GUI-launch. -### Stage 1 — Buffer model -Multiple views in one window; nyxt buffer semantics. -- **C++**: window holds N views in the stack; `jwb_window_add_buffer`, - `_switch_buffer(i)`, `_delete_buffer(i)`, `_buffer_count`, `_current_buffer`, - per-buffer title/url already available via view API. -- **Scheme**: `(browser buffers)` — a buffer list with id/title/url/view; - `make-buffer`, `switch-buffer`, `switch-buffer-next/previous/last`, - `delete-buffer`, `current-buffer`, `buffer-list`. -- **Tests (the canonical scenario)**: open A → `make-buffer` B → open B → - `switch-buffer-previous` → assert current url == A, buffer-count == 2; - snapshot each step. +### Stage 1 — Buffer model ✅ DONE +Multiple views in one window; nyxt buffer semantics. Also lands the `QMainWindow` +chrome deferred from Stage 0 (the window now hosts the buffers + status line + +minibuffer line). `jerboa-browser <url>` launches into this window. +- **C++** (`browser_window.cpp`, `HandleKind::Window`): a `QMainWindow` hosting a + `QStackedWidget` (buffers), a status `QLabel`, and a minibuffer `QLineEdit` + (hidden until Stage 2). `jwb_window_open/close`, `_add_view`/`_remove_view` + (reparent only — never frees a view), `_view_count`, `_current_index`, + `_show_view`, `_show`/`_hide`/`_resize`, `_grab_png` (full-chrome snapshot), + `_set_title`/`_set_status`/`_set_minibuffer`. +- **Scheme**: `(browser)` window wrappers + the `(browser buffers)` session layer + — `open-browser-session`/`close-browser-session!`, `session-open-buffer` + (opens+focuses+loads, nyxt make-buffer-focus), `session-close-buffer!`, + `session-buffer-list`/`-count`, `session-current-index`/`-buffer`, + `session-switch!`/`-index!`/`-next!`/`-previous!`/`-last!`, + `session-update-status!`, `buffer-id`/`-view`/`-title`/`-url`. +- **Tests** (`browser-buffers-test.ss`, `make test-buffers`): 27 cases — open 3 + buffers, assert count/index/identity, cyclic switching, close-buffer index + shift, full-window PNG snapshots; plus the canonical networked flow (open A, + open B, switch-previous → A) proven by a per-buffer JS marker read back through + `session-current-buffer` (the current index really resolves to A's view). + Wired into CI (hermetic). ### Stage 2 — Keymap engine + emacs bindings + minibuffer (prompt-buffer) The heart of the nyxt feel. --- a/WISHLIST.md +++ b/WISHLIST.md @@ -8,7 +8,7 @@ built on the existing Qt WebEngine FFI + self-contained native binary, with offline Qt snapshot + functional tests modeled on `~/mine/jerboa-emacs`. - [x] **Offline Qt test + snapshot harness** (offscreen, grab→PNG w/ IHDR-verified size, key injection) — *Stage 0* — `make test-gui`; `--repl`-on-GUI-launch + window chrome deferred to Stage 1 -- [ ] **Buffer model** — multiple web views in one window; switch / list / delete — *Stage 1* +- [x] **Buffer model** — QMainWindow + stacked views; open/switch/next/prev/last/close; `(browser buffers)` session — *Stage 1* — `make test-buffers` (27); `jerboa-browser <url>` launches into the window - [ ] **Keymap engine + emacs bindings + minibuffer (prompt-buffer)** with fuzzy completion — *Stage 2* - [ ] **Navigation parity** — scroll, zoom, find-in-page, history, reload, copy url/title — *Stage 3* - [ ] **Hint-mode** — link hinting / follow (`f`, `M-g`, `; f`) via JS overlay — *Stage 4* --- a/include/jerboa_browser.h +++ b/include/jerboa_browser.h @@ -181,6 +181,30 @@ JWB_API int jwb_view_grab_png(JwbHandle view, const char *path); JWB_API int jwb_send_key(JwbHandle view, int qt_key, int modifiers, const char *text); +/* === Window / buffer chrome (Stage 1) ================================== + * A BrowserWindow is a QMainWindow hosting a QStackedWidget of views (the + * nyxt "buffers" model: one window, many buffers, one visible at a time) plus + * a status line and a minibuffer line. Views are reparented into the stack by + * jwb_window_add_view; the window never frees a view (free it via jwb_view_free + * after removing/closing the window). Ops return 1 on success, 0 on failure + * (jwb_last_error has the reason); index getters return -1 on failure. */ +JWB_API JwbHandle jwb_window_open(void); /* 0 on error */ +JWB_API int jwb_window_close(JwbHandle window); /* detaches views first */ +JWB_API int jwb_window_add_view(JwbHandle window, JwbHandle view); /* -> index */ +JWB_API int jwb_window_remove_view(JwbHandle window, int index); /* keeps view */ +JWB_API int jwb_window_view_count(JwbHandle window); +JWB_API int jwb_window_current_index(JwbHandle window); +JWB_API int jwb_window_show_view(JwbHandle window, int index); +JWB_API int jwb_window_show(JwbHandle window); +JWB_API int jwb_window_hide(JwbHandle window); +JWB_API int jwb_window_resize(JwbHandle window, int width, int height); +JWB_API int jwb_window_grab_png(JwbHandle window, const char *path); +JWB_API int jwb_window_set_title(JwbHandle window, const char *utf8_title); +JWB_API int jwb_window_set_status(JwbHandle window, const char *utf8_text); +/* Set the minibuffer text and show it; pass NULL/"" with show=0 to hide it. */ +JWB_API int jwb_window_set_minibuffer(JwbHandle window, const char *utf8_text, + int show); + #ifdef __cplusplus } /* extern "C" */ #endif --- a/qt-webengine/CMakeLists.txt +++ b/qt-webengine/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(jerboa_browser SHARED src/browser_policy.cpp src/browser_ffi.cpp src/browser_automation.cpp + src/browser_window.cpp src/browser_host.cpp ) target_include_directories(jerboa_browser --- a/qt-webengine/src/browser_handles.cpp +++ b/qt-webengine/src/browser_handles.cpp @@ -22,7 +22,12 @@ JwbHandle g_next = 1; // 0 is never a valid handle thread_local std::string g_last_error; const char *kind_name(jwb::HandleKind k) { - return k == jwb::HandleKind::Context ? "context" : "view"; + switch (k) { + case jwb::HandleKind::Context: return "context"; + case jwb::HandleKind::View: return "view"; + case jwb::HandleKind::Window: return "window"; + } + return "unknown"; } } // namespace --- a/qt-webengine/src/browser_internal.h +++ b/qt-webengine/src/browser_internal.h @@ -22,7 +22,7 @@ QApplication *qapp(); // --- handle registry ---------------------------------------------------- // Handles are tagged so a context can't be used where a view is expected. -enum class HandleKind { Context, View }; +enum class HandleKind { Context, View, Window }; // Allocate a handle owning `payload`. Handles are monotonic and never reused, // so a freed handle stays permanently invalid — stale/double-free is rejected new file mode 100644 --- /dev/null +++ b/qt-webengine/src/browser_window.cpp @@ -0,0 +1,235 @@ +// browser_window.cpp — BrowserWindow: the nyxt-style single window that hosts +// many buffers (views) in a QStackedWidget, plus a status line and a minibuffer +// line. One buffer is visible at a time; switching is setCurrentIndex on the +// stack. This is the chrome deferred from Stage 0, now justified by the buffer +// model (browser buffers). +// +// Lifecycle contract: a view is reparented INTO the stack by add_view, but the +// window never owns/frees views — close() detaches every view (setParent null) +// so each view handle remains valid and is freed exactly once via jwb_view_free. +// This mirrors the "free views before their context" discipline elsewhere. +#include "browser_objects.h" + +#include <QApplication> +#include <QLabel> +#include <QLineEdit> +#include <QMainWindow> +#include <QPixmap> +#include <QStackedWidget> +#include <QString> +#include <QVBoxLayout> +#include <QWidget> + +#include <cstring> +#include <vector> + +namespace jwb { + +// A window owns its Qt widget tree (QMainWindow + children); it does NOT own the +// views in `views` — those stay owned by their handles. `views` parallels the +// stack's widget order, so index i in `views` is stack widget i. +struct Window { + QMainWindow *win; + QStackedWidget *stack; + QLabel *status; + QLineEdit *minibuffer; + std::vector<JwbHandle> views; +}; + +inline bool get_window(JwbHandle h, Window **out, JwbStatus *err) { + void *p = nullptr; + if (!handle_lookup(h, HandleKind::Window, &p, err)) return false; + *out = static_cast<Window *>(p); + return true; +} + +// Detach every view from the stack without deleting it (the view handle keeps +// ownership). Used by close() and by remove_view. +static void detach_view_widget(QStackedWidget *stack, QWidget *w) { + stack->removeWidget(w); + w->setParent(nullptr); +} + +} // namespace jwb + +extern "C" { + +JWB_API JwbHandle jwb_window_open(void) { + jwb::clear_last_error(); + if (!jwb::qapp()) { + jwb::set_last_error("runtime not initialized; call jwb_runtime_init first"); + return 0; + } + auto *w = new jwb::Window(); + w->win = new QMainWindow(); + auto *central = new QWidget(w->win); + auto *layout = new QVBoxLayout(central); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + w->stack = new QStackedWidget(central); + w->status = new QLabel(central); + w->status->setObjectName("jwb_status"); + w->minibuffer = new QLineEdit(central); + w->minibuffer->setObjectName("jwb_minibuffer"); + w->minibuffer->hide(); // shown only while a prompt is active (Stage 2) + layout->addWidget(w->stack, /*stretch=*/1); + layout->addWidget(w->status, 0); + layout->addWidget(w->minibuffer, 0); + w->win->setCentralWidget(central); + w->win->resize(1024, 768); + return jwb::handle_alloc(jwb::HandleKind::Window, w); +} + +JWB_API int jwb_window_close(JwbHandle window) { + jwb::clear_last_error(); + void *obj = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::handle_release(window, jwb::HandleKind::Window, &obj, &st)) return 0; + auto *w = static_cast<jwb::Window *>(obj); + // Detach (don't delete) every view so its handle stays valid + singly-owned. + while (w->stack->count() > 0) + jwb::detach_view_widget(w->stack, w->stack->widget(0)); + delete w->win; // deletes central/stack/status/minibuffer (Qt parent tree) + delete w; + return 1; +} + +JWB_API int jwb_window_add_view(JwbHandle window, JwbHandle view) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + jwb::View *v = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return -1; + if (!jwb::get_view(view, &v, &st)) return -1; + int index = w->stack->addWidget(v->view); // reparents v->view into the stack + w->views.push_back(view); + return index; +} + +JWB_API int jwb_window_remove_view(JwbHandle window, int index) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + if (index < 0 || index >= w->stack->count()) { + jwb::set_last_error("remove_view: index out of range"); + return 0; + } + jwb::detach_view_widget(w->stack, w->stack->widget(index)); + w->views.erase(w->views.begin() + index); + return 1; +} + +JWB_API int jwb_window_view_count(JwbHandle window) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return -1; + return w->stack->count(); +} + +JWB_API int jwb_window_current_index(JwbHandle window) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return -1; + return w->stack->currentIndex(); // -1 when empty +} + +JWB_API int jwb_window_show_view(JwbHandle window, int index) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + if (index < 0 || index >= w->stack->count()) { + jwb::set_last_error("show_view: index out of range"); + return 0; + } + w->stack->setCurrentIndex(index); + return 1; +} + +JWB_API int jwb_window_show(JwbHandle window) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + w->win->show(); + return 1; +} + +JWB_API int jwb_window_hide(JwbHandle window) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + w->win->hide(); + return 1; +} + +JWB_API int jwb_window_resize(JwbHandle window, int width, int height) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + if (width <= 0 || height <= 0) { + jwb::set_last_error("resize: width/height must be positive"); + return 0; + } + w->win->resize(width, height); + return 1; +} + +JWB_API int jwb_window_grab_png(JwbHandle window, const char *path) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + if (!path) { + jwb::set_last_error("grab: path is null"); + return 0; + } + if (w->win->size().isEmpty()) w->win->resize(1024, 768); + QPixmap pm = w->win->grab(); + if (pm.isNull()) { + jwb::set_last_error("grab: null pixmap"); + return 0; + } + return pm.save(QString::fromUtf8(path), "PNG") ? 1 : 0; +} + +JWB_API int jwb_window_set_title(JwbHandle window, const char *utf8_title) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + w->win->setWindowTitle(QString::fromUtf8(utf8_title ? utf8_title : "")); + return 1; +} + +JWB_API int jwb_window_set_status(JwbHandle window, const char *utf8_text) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + w->status->setText(QString::fromUtf8(utf8_text ? utf8_text : "")); + return 1; +} + +JWB_API int jwb_window_set_minibuffer(JwbHandle window, const char *utf8_text, + int show) { + jwb::clear_last_error(); + jwb::Window *w = nullptr; + JwbStatus st = JWB_OK; + if (!jwb::get_window(window, &w, &st)) return 0; + w->minibuffer->setText(QString::fromUtf8(utf8_text ? utf8_text : "")); + if (show) { + w->minibuffer->show(); + w->minibuffer->setFocus(Qt::OtherFocusReason); + } else { + w->minibuffer->hide(); + } + return 1; +} + +} // extern "C" new file mode 100644 --- /dev/null +++ b/scheme/browser-buffers-test.ss @@ -0,0 +1,163 @@ +#!chezscheme +;;; browser-buffers-test.ss — Stage 1 functional tests for the buffer model. +;;; +;;; Offline + synchronous (QT_QPA_PLATFORM=offscreen), modeled on jerboa-emacs' +;;; tests/test-qt.ss. Two layers: +;;; - hermetic core (no network): open N buffers, assert count / current-index +;;; / identity, cyclic switch-next/previous/last, switch-to, close-buffer +;;; index shifting, window snapshots of the chrome. +;;; - networked scenario (skipped under JWB_TEST_NO_NETWORK): the canonical +;;; nyxt flow — open A, open B (focuses B), switch-previous → A — proven by +;;; a per-buffer JS marker read back through (session-current-buffer): this +;;; asserts the window's current index truly resolves to the A *view*. +;;; +;;; Run: QT_QPA_PLATFORM=offscreen make test-buffers +;;; Exits 0 on success, 1 on any failed case. + +(import (chezscheme) (browser) (browser buffers)) + +;;; ─── tiny test framework (shared shape with browser-gui-test.ss) ────────── +(define *pass* 0) +(define *fail* 0) +(define *test-name* "(none)") + +(define-syntax test-group + (syntax-rules () + [(_ name body ...) + (begin (display "\n=== ") (display name) (display " ===\n") + (flush-output-port (current-output-port)) + body ...)])) + +(define (run-test-case name thunk) + (set! *test-name* name) + (let ((ok (guard (e (#t + (set! *fail* (+ *fail* 1)) + (display " FAIL: ") (display name) (newline) + (display " error: ") + (display (if (message-condition? e) (condition-message e) + (format "~s" e))) + (newline) (flush-output-port (current-output-port)) #f)) + (thunk) #t))) + (when ok + (set! *pass* (+ *pass* 1)) + (display " pass: ") (display name) (newline) + (flush-output-port (current-output-port))))) + +(define-syntax test-case + (syntax-rules () [(_ name body ...) (run-test-case name (lambda () body ...))])) + +(define-syntax check + (syntax-rules (=> ?) + [(_ expr => expected) + (let ((got expr) (exp expected)) + (unless (equal? got exp) + (error 'check (format "~a: expected ~s, got ~s" *test-name* exp got))))] + [(_ expr ? pred) + (let ((got expr)) + (unless (pred got) + (error 'check (format "~a: predicate failed for ~s" *test-name* got))))])) + +(define (str-contains? hay needle) + (let ((hn (string-length hay)) (nn (string-length needle))) + (let loop ((i 0)) + (cond ((> (+ i nn) hn) #f) + ((string=? (substring hay i (+ i nn)) needle) #t) + (else (loop (+ i 1))))))) + +;;; ─── setup ──────────────────────────────────────────────────────────────── +(unless (getenv "QT_QPA_PLATFORM") (setenv "QT_QPA_PLATFORM" "offscreen")) +(define artifacts-dir (or (getenv "JWB_TEST_ARTIFACTS") "test-artifacts")) +(unless (file-exists? artifacts-dir) (mkdir artifacts-dir)) +(define (artifact name) (string-append artifacts-dir "/" name)) + +(define s (open-browser-session)) ; network-capable, one empty window +(test-case "window resize 900x650" (browser-ok? (browser-window-resize (session-window s) 900 650))) + +;;; ─── hermetic buffer model (no network) ─────────────────────────────────── +(define ba #f) (define bb #f) (define bc #f) +(test-group "open buffers (no load)" + (test-case "open buffer A" (set! ba (session-open-buffer s "")) (check (buffer? ba) => #t)) + (test-case "count == 1 after A" (check (session-buffer-count s) => 1)) + (test-case "A is current (index 0)" (check (session-current-index s) => 0)) + (test-case "current-buffer is A" (check (eq? (session-current-buffer s) ba) => #t)) + (test-case "open buffer B focuses it" + (set! bb (session-open-buffer s "")) + (check (session-current-index s) => 1) + (check (eq? (session-current-buffer s) bb) => #t)) + (test-case "open buffer C focuses it" + (set! bc (session-open-buffer s "")) + (check (session-buffer-count s) => 3) + (check (eq? (session-current-buffer s) bc) => #t)) + (test-case "buffer ids are distinct" + (check (= (buffer-id ba) (buffer-id bb)) => #f) + (check (= (buffer-id bb) (buffer-id bc)) => #f))) + +(test-group "switching" + (test-case "switch-previous from C -> B (index 1)" + (session-switch-previous! s) (check (session-current-index s) => 1) + (check (eq? (session-current-buffer s) bb) => #t)) + (test-case "switch-previous wraps B -> A (index 0)" + (session-switch-previous! s) (check (session-current-index s) => 0)) + (test-case "switch-previous wraps A -> C (index 2)" + (session-switch-previous! s) (check (session-current-index s) => 2)) + (test-case "switch-next wraps C -> A (index 0)" + (session-switch-next! s) (check (session-current-index s) => 0)) + (test-case "switch! to B by identity" + (check (session-switch! s bb) => #t) (check (eq? (session-current-buffer s) bb) => #t)) + (test-case "switch-last -> C (highest index)" + (session-switch-last! s) (check (eq? (session-current-buffer s) bc) => #t))) + +(test-group "window snapshot (chrome)" + (test-case "show window" (browser-ok? (browser-window-show (session-window s)))) + (test-case "status line set" (browser-ok? (browser-window-set-status (session-window s) "[3/3] buffer C"))) + (test-case "grab window PNG is 900x650" + (browser-ok? (browser-window-grab-png (session-window s) (artifact "stage1-three-buffers.png"))))) + +(test-group "close buffer shifts indices" + (test-case "close middle buffer B" + (check (session-close-buffer! s bb) => #t) (check (session-buffer-count s) => 2)) + (test-case "remaining buffers are A then C" + (check (eq? (list-ref (session-buffer-list s) 0) ba) => #t) + (check (eq? (list-ref (session-buffer-list s) 1) bc) => #t)) + (test-case "switch to C (now index 1)" + (check (session-switch! s bc) => #t) (check (session-current-index s) => 1))) + +;;; ─── canonical networked scenario ───────────────────────────────────────── +(if (getenv "JWB_TEST_NO_NETWORK") + (begin (display "\n=== canonical nyxt flow ===\n SKIP (JWB_TEST_NO_NETWORK set)\n") + (flush-output-port (current-output-port))) + (let ((s2 (open-browser-session))) + (test-group "canonical nyxt flow: open A, open B, switch-previous -> A" + (let ((a #f) (b #f)) + (test-case "open A and load example.com" + (set! a (session-open-buffer s2 "https://example.com")) + (check (browser-ok? (browser-eval (buffer-view a) "window.__buf='A'; document.title")) + => #t)) + (test-case "open B (focuses B) and load example.com" + (set! b (session-open-buffer s2 "https://example.com")) + (browser-eval (buffer-view b) "window.__buf='B'") + (check (eq? (session-current-buffer s2) b) => #t) + (check (session-buffer-count s2) => 2)) + (test-case "switch-previous -> current buffer is A" + (session-switch-previous! s2) + (check (eq? (session-current-buffer s2) a) => #t) + (check (session-current-index s2) => 0)) + (test-case "current buffer's view is truly A's (JS marker == A)" + (check (browser-value (browser-eval (buffer-view (session-current-buffer s2)) "window.__buf")) + => "A")) + (test-case "current buffer url is example.com" + (check (buffer-url (session-current-buffer s2)) ? (lambda (u) (str-contains? u "example.com")))) + (test-case "status + window snapshot of loaded buffer" + (session-update-status! s2) + (browser-window-resize (session-window s2) 900 650) + (browser-ok? (browser-window-grab-png (session-window s2) (artifact "stage1-canonical.png")))))) + (close-browser-session! s2))) + +;;; ─── teardown ─────────────────────────────────────────────────────────── +(test-group "teardown" + (test-case "close session" (close-browser-session! s) (check (session-buffer-count s) => 0))) + +(newline) +(display "browser-buffers-test: ") (display *pass*) (display " passed, ") +(display *fail*) (display " failed") (newline) +(exit (if (zero? *fail*) 0 1)) --- a/scheme/browser-main.ss +++ b/scheme/browser-main.ss @@ -14,7 +14,7 @@ ;;; jerboa-browser test run the (browser) test suite ;;; jerboa-browser run FILE run a Jerboa script with (browser) available -(import (chezscheme) (browser) (std repl)) +(import (chezscheme) (browser) (browser buffers) (std repl)) ;; Directory of argv[0], or "." when it has no slash / is unavailable. (define (exe-dir) @@ -82,37 +82,29 @@ (display " headless check: QT_QPA_PLATFORM=offscreen JWB_BROWSE_MS=1500 jerboa-browser <url>\n" p) (exit 1))) -;; Open the GUI at `url` and hand control to the Qt event loop (the window stays -;; live until closed). Set JWB_BROWSE_MS=<ms> for a headless/no-block smoke that -;; pumps for ms, prints the page title, then exits — used by tests/CI. +;; Open the GUI at `url` in a nyxt-style window (one window, buffers in a stack) +;; and hand control to the Qt event loop (the window stays live until closed). +;; Set JWB_BROWSE_MS=<ms> for a headless/no-block smoke that pumps for ms, prints +;; the page title, then exits — used by tests/CI. (define (run-browse url) (guard (e (#t (browse-fail "could not launch the browser" e))) - (let ((ir (browser-init))) - (unless (browser-ok? ir) (error 'browser-init (browser-value ir)))) - (let ((cr (browser-open-context (browser-capabilities 'network)))) - (unless (browser-ok? cr) (error 'browser-open-context (browser-value cr))) - (let* ((c (browser-value cr)) (vr (browser-open-view c))) - (unless (browser-ok? vr) (error 'browser-open-view (browser-value vr))) - (let ((v (browser-value vr))) - (browser-set-title v "Jerboa Browser") - (let ((lr (browser-load v url))) - (unless (browser-ok? lr) - (display (string-append "jerboa-browser: load failed: " - (browser-value lr) "\n") - (current-error-port)))) - (browser-show v) - (browser-focus v) - (let ((ms (and (getenv "JWB_BROWSE_MS") - (string->number (getenv "JWB_BROWSE_MS"))))) - (if ms - (begin - (browser-pump ms) - (let ((tr (browser-eval v "document.title"))) - (when (browser-ok? tr) (display (browser-value tr)) (newline))) - (browser-close-view v) (browser-close-context c) (exit 0)) - (begin - (browser-exec) ; blocks until the window is closed - (browser-close-view v) (browser-close-context c) (exit 0))))))))) + (let* ((s (open-browser-session)) ; init + network context + window + (win (session-window s))) + (browser-window-set-title win "Jerboa Browser") + (let ((buf (session-open-buffer s url))) ; open + focus + load the buffer + (session-update-status! s) + (browser-window-show win) + (let ((ms (and (getenv "JWB_BROWSE_MS") + (string->number (getenv "JWB_BROWSE_MS"))))) + (if ms + (begin + (browser-pump ms) + (let ((tr (browser-eval (buffer-view buf) "document.title"))) + (when (browser-ok? tr) (display (browser-value tr)) (newline))) + (close-browser-session! s) (exit 0)) + (begin + (browser-exec) ; blocks until the window is closed + (close-browser-session! s) (exit 0)))))))) (define (usage port code) (display "usage: jerboa-browser [<url> | browse [<url>] | repl | test | run <file>]\n" port) --- a/scheme/browser.ss +++ b/scheme/browser.ss @@ -57,7 +57,24 @@ ;; automation / testing browser-resize browser-grab-png - browser-send-key) + browser-send-key + ;; window / buffer chrome + browser-window-open + browser-window-close + browser-window-add-view + browser-window-remove-view + browser-window-view-count + browser-window-current-index + browser-window-show-view + browser-window-show + browser-window-hide + browser-window-resize + browser-window-grab-png + browser-window-set-title + browser-window-set-status + browser-window-set-minibuffer + browser-window? + browser-window-handle) (import (except (chezscheme) make-hash-table hash-table? @@ -156,6 +173,21 @@ (define-c-lambda %view-resize (uint64 int int) int "jwb_view_resize") (define-c-lambda %grab-png (uint64 char-string) int "jwb_view_grab_png") (define-c-lambda %send-key (uint64 int int char-string) int "jwb_send_key") + ;; window / buffer chrome (browser_window.cpp) + (define-c-lambda %win-open () uint64 "jwb_window_open") + (define-c-lambda %win-close (uint64) int "jwb_window_close") + (define-c-lambda %win-add-view (uint64 uint64) int "jwb_window_add_view") + (define-c-lambda %win-remove-view (uint64 int) int "jwb_window_remove_view") + (define-c-lambda %win-view-count (uint64) int "jwb_window_view_count") + (define-c-lambda %win-current (uint64) int "jwb_window_current_index") + (define-c-lambda %win-show-view (uint64 int) int "jwb_window_show_view") + (define-c-lambda %win-show (uint64) int "jwb_window_show") + (define-c-lambda %win-hide (uint64) int "jwb_window_hide") + (define-c-lambda %win-resize (uint64 int int) int "jwb_window_resize") + (define-c-lambda %win-grab-png (uint64 char-string) int "jwb_window_grab_png") + (define-c-lambda %win-set-title (uint64 char-string) int "jwb_window_set_title") + (define-c-lambda %win-set-status (uint64 char-string) int "jwb_window_set_status") + (define-c-lambda %win-set-minibuf (uint64 char-string int) int "jwb_window_set_minibuffer") ;; --- tagged results ----------------------------------------------------- (def (ok* v) (list 'ok v)) @@ -331,4 +363,88 @@ (ok* #t) (err* (string-append "send-key: " (%last-error))))) + ;; --- window / buffer chrome (browser_window.cpp) ----------------------- + ;; A browser-window is the nyxt-style single window hosting many views; the + ;; (browser buffers) module layers the buffer list/switch logic on top. + ;; These FFIs use the 1=ok / 0=fail (or index / -1) convention with the reason + ;; in jwb_last_error, so read %last-error on failure. + (defstruct browser-window (handle)) + (def (require-window who w) + (unless (browser-window? w) (error who "expected a browser-window" w))) + + ;; (ok #t) when n is 1, else (err "LABEL: REASON" from jwb_last_error). + (def (int-ok label n) + (if (= n 1) (ok* #t) (err* (string-append label ": " (%last-error))))) + + (def (browser-window-open) + (let ((h (%win-open))) + (if (= h 0) + (err* (string-append "window open: " (%last-error))) + (ok* (make-browser-window h))))) + + (def (browser-window-close w) + (require-window 'browser-window-close w) + (int-ok "window close" (%win-close (browser-window-handle w)))) + + ;; Reparent a view into the window's stack; (ok INDEX) on success. + (def (browser-window-add-view w view) + (require-window 'browser-window-add-view w) + (require-view 'browser-window-add-view view) + (let ((i (%win-add-view (browser-window-handle w) (browser-view-handle view)))) + (if (>= i 0) (ok* i) (err* (string-append "add-view: " (%last-error)))))) + + ;; Detach (does not free) the view at INDEX. + (def (browser-window-remove-view w index) + (require-window 'browser-window-remove-view w) + (int-ok "remove-view" (%win-remove-view (browser-window-handle w) index))) + + (def (browser-window-view-count w) + (require-window 'browser-window-view-count w) + (let ((n (%win-view-count (browser-window-handle w)))) + (if (>= n 0) (ok* n) (err* (string-append "view-count: " (%last-error)))))) + + ;; (ok INDEX); INDEX is -1 when the window has no buffers (not an error). + (def (browser-window-current-index w) + (require-window 'browser-window-current-index w) + (let ((i (%win-current (browser-window-handle w)))) + (if (and (= i -1) (> (string-length (%last-error)) 0)) + (err* (string-append "current-index: " (%last-error))) + (ok* i)))) + + (def (browser-window-show-view w index) + (require-window 'browser-window-show-view w) + (int-ok "show-view" (%win-show-view (browser-window-handle w) index))) + + (def (browser-window-show w) + (require-window 'browser-window-show w) + (int-ok "window show" (%win-show (browser-window-handle w)))) + + (def (browser-window-hide w) + (require-window 'browser-window-hide w) + (int-ok "window hide" (%win-hide (browser-window-handle w)))) + + (def (browser-window-resize w width height) + (require-window 'browser-window-resize w) + (int-ok "window resize" (%win-resize (browser-window-handle w) width height))) + + (def (browser-window-grab-png w path) + (require-window 'browser-window-grab-png w) + (if (= 1 (%win-grab-png (browser-window-handle w) path)) + (ok* path) + (err* (string-append "window grab-png: " (%last-error))))) + + (def (browser-window-set-title w title) + (require-window 'browser-window-set-title w) + (int-ok "window set-title" (%win-set-title (browser-window-handle w) title))) + + (def (browser-window-set-status w text) + (require-window 'browser-window-set-status w) + (int-ok "window set-status" (%win-set-status (browser-window-handle w) text))) + + ;; show? #t shows the minibuffer (and focuses it); #f hides it. + (def (browser-window-set-minibuffer w text show?) + (require-window 'browser-window-set-minibuffer w) + (int-ok "window set-minibuffer" + (%win-set-minibuf (browser-window-handle w) text (if show? 1 0)))) + ) ; library (browser) new file mode 100644 --- /dev/null +++ b/scheme/browser/buffers.ss @@ -0,0 +1,171 @@ +#!chezscheme +;;; (browser buffers) — nyxt-style buffer model over a single BrowserWindow. +;;; +;;; A "session" owns one window + one capability-scoped context and a list of +;;; buffers. A buffer is a web view living in the window's stacked widget; one +;;; buffer is visible at a time and switching is O(1) (QStackedWidget index). +;;; This mirrors nyxt: a window has many buffers, `make-buffer` opens+focuses a +;;; new one, and switch-buffer-{next,previous,last} cycle through them. +;;; +;;; The window (C side) is the source of truth for stack order + current index; +;;; `buffers` here is a parallel list (stack index i == list position i), kept in +;;; lock-step by always add/removing in both places together. +;;; +;;; Usage: +;;; (import (browser buffers)) +;;; (define s (open-browser-session)) ; network-capable by default +;;; (define a (session-open-buffer s "https://example.com")) +;;; (define b (session-open-buffer s "https://www.iana.org")) ; focuses b +;;; (session-switch-previous! s) ; back to a +;;; (session-current-buffer s) ; => a +;;; (close-browser-session! s) + +(library (browser buffers) + (export + open-browser-session close-browser-session! + session? session-window session-context + session-open-buffer session-close-buffer! + session-buffer-list session-buffer-count + session-current-index session-current-buffer + session-switch! session-switch-index! + session-switch-next! session-switch-previous! session-switch-last! + session-update-status! + buffer? buffer-id buffer-view buffer-title buffer-url) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (browser)) + + ;; --- records ------------------------------------------------------------ + ;; A buffer is a stable identity (id) over a web view; title/url are queried + ;; live from the view (browser-title / browser-url). + (defstruct buffer (id view)) + ;; A session: the window, its context, the ordered buffer list, next id. + (defstruct session (window context buffers next-id)) + + ;; --- small helpers ------------------------------------------------------ + (def (must who r) + (if (browser-ok? r) (browser-value r) + (error who (browser-value r)))) + + ;; Position of `buf` (by identity) in the session's buffer list, or #f. + (def (buffer-index s buf) + (let loop ((bs (session-buffer-list s)) (i 0)) + (cond ((null? bs) #f) + ((eq? (car bs) buf) i) + (else (loop (cdr bs) (+ i 1)))))) + + ;; Drop the element at index i from list xs (0-based; i assumed in range). + (def (list-remove-at xs i) + (let loop ((xs xs) (i i) (acc '())) + (cond ((null? xs) (reverse acc)) + ((= i 0) (append (reverse acc) (cdr xs))) + (else (loop (cdr xs) (- i 1) (cons (car xs) acc)))))) + + ;; --- buffer field readers (plain strings; "" if unavailable) ------------ + (def (buffer-title buf) + (let ((r (browser-title (buffer-view buf)))) + (if (browser-ok? r) (browser-value r) ""))) + (def (buffer-url buf) + (let ((r (browser-url (buffer-view buf)))) + (if (browser-ok? r) (browser-value r) ""))) + + ;; --- session lifecycle -------------------------------------------------- + ;; Open a session: init runtime, open a context with `caps` (default network), + ;; open the window. Returns a session with zero buffers. + (def (open-browser-session . opt) + (let ((caps (if (pair? opt) (car opt) (browser-capabilities 'network)))) + (must 'open-browser-session (browser-init)) + (let ((ctx (must 'open-browser-session (browser-open-context caps))) + (win (must 'open-browser-session (browser-window-open)))) + (make-session win ctx '() 0)))) + + ;; Tear down: detach + free every view, close the window, free the context. + (def (close-browser-session! s) + (browser-window-close (session-window s)) ; detaches views (does not free) + (for-each (lambda (b) (browser-close-view (buffer-view b))) + (session-buffer-list s)) + (browser-close-context (session-context s)) + (session-buffers-set! s '())) + + ;; --- buffers ------------------------------------------------------------ + ;; Open a new buffer, append it to the window's stack, FOCUS it (nyxt + ;; make-buffer-focus semantics), and load `url` if given. Returns the buffer. + (def (session-open-buffer s url) + (let* ((view (must 'session-open-buffer (browser-open-view (session-context s)))) + (id (session-next-id s)) + (buf (make-buffer id view)) + (idx (must 'session-open-buffer + (browser-window-add-view (session-window s) view)))) + (session-next-id-set! s (+ id 1)) + (session-buffers-set! s (append (session-buffer-list s) (list buf))) + (browser-window-show-view (session-window s) idx) + (when (and (string? url) (> (string-length url) 0)) + (browser-load view url)) + buf)) + + ;; Close a buffer: detach from the window, free its view, drop from the list. + (def (session-close-buffer! s buf) + (let ((i (buffer-index s buf))) + (when i + (browser-window-remove-view (session-window s) i) + (browser-close-view (buffer-view buf)) + (session-buffers-set! s (list-remove-at (session-buffer-list s) i)) + #t))) + + (def (session-buffer-list s) (session-buffers s)) + (def (session-buffer-count s) (length (session-buffers s))) + + ;; Current visible buffer index (-1 when none), and the buffer itself (#f). + (def (session-current-index s) + (must 'session-current-index (browser-window-current-index (session-window s)))) + (def (session-current-buffer s) + (let ((i (session-current-index s)) (bs (session-buffer-list s))) + (if (and (>= i 0) (< i (length bs))) (list-ref bs i) #f))) + + ;; --- switching ---------------------------------------------------------- + (def (session-switch-index! s i) + (must 'session-switch-index! (browser-window-show-view (session-window s) i))) + + ;; Switch to a specific buffer (by identity); #t if found, #f otherwise. + (def (session-switch! s buf) + (let ((i (buffer-index s buf))) + (and i (begin (session-switch-index! s i) #t)))) + + ;; Cyclic next / previous (wrap around); no-op when there are no buffers. + (def (session-switch-next! s) + (let ((n (session-buffer-count s))) + (when (> n 0) + (session-switch-index! s (modulo (+ (session-current-index s) 1) n))))) + (def (session-switch-previous! s) + (let ((n (session-buffer-count s))) + (when (> n 0) + (session-switch-index! s (modulo (- (session-current-index s) 1) n))))) + + ;; Switch to the last (most recently opened) buffer. + (def (session-switch-last! s) + (let ((n (session-buffer-count s))) + (when (> n 0) (session-switch-index! s (- n 1))))) + + ;; --- status line -------------------------------------------------------- + ;; Render "[i/n] TITLE — URL" of the current buffer into the window status bar. + (def (session-update-status! s) + (let ((b (session-current-buffer s)) + (n (session-buffer-count s)) + (i (session-current-index s))) + (browser-window-set-status + (session-window s) + (if b + (string-append "[" (number->string (+ i 1)) "/" (number->string n) "] " + (buffer-title b) " — " (buffer-url b)) + "(no buffers)")))) + + ) ; library (browser buffers)