///|
/// Browser Shell - TUI browser application layer
///|
let default_image_cache_max_bytes : Int = 8 * 1024 * 1024
///|
/// Link found in the page
priv struct Link {
href : String
text : String
source_id : String
}
///|
priv struct ClickDispatchResult {
handled : Bool
allow_default : Bool
}
///|
priv struct NavigationRequest {
url : String
http_method : String
content_type : String
body : String
}
///|
priv enum SubmitDispatchResult {
NotSubmittable
Prevented
Handled
Navigate(NavigationRequest)
}
///|
priv enum FocusedKeyDispatchResult {
NotHandled
Handled
Activate
Submit
}
///|
priv struct HitTarget {
source_id : String
area : Double
}
///|
priv struct KittyPlacement {
image_id : Int
placement_id : Int
col : Int
row : Int
width : Int
height : Int
}
///|
priv struct KittyRenderSpec {
region : @tui.ImageRegion
cached_data : String
placement : KittyPlacement
}
///|
/// Browser output mode
pub(all) enum OutputMode {
Sixel
Kitty
Text
Json
Aom
Arc90
ExtractMain
Grounding
}
///|
/// Browser state
struct Browser {
/// Current URL
mut current_url : String
/// Last navigation request metadata
mut last_navigation_request : NavigationRequest?
/// Request sandbox policy
mut request_sandbox : @http.RequestSandbox
/// Viewport width in pixels
viewport_width : Int
/// Viewport height in pixels
viewport_height : Int
/// Current HTML content
mut html_content : String
/// Last raw HTML source before render-time normalization
mut source_html : String
/// DOM snapshot HTML that preserves serializable shadow roots
mut dom_snapshot_html : String
/// Parsed document (cached to avoid re-parsing)
mut parsed_doc : @html.Document?
/// External CSS content
mut external_css : Array[String]
/// Links found in current page
mut links : Array[Link]
/// Currently focused link index (legacy, kept for compatibility)
mut focused_link : Int
/// Scroll position (Y offset)
mut scroll_y : Int
/// Content height (for scroll limit)
mut content_height : Int
/// Link regions for click hit testing (updated on each render)
mut link_regions : Array[@tui.LinkRegion]
/// Paint-order hit regions for generic click hit testing
mut hit_regions : Array[@tui.HitRegion]
/// Hit-a-hint mode state
mut hint_mode : Bool
/// Current hints (generated when entering hint mode)
mut hints : Array[@tui.HintData]
/// Hint input buffer (for multi-char hints)
mut hint_input : String
/// Back history (stack of URLs for going back)
back_history : Array[String]
/// Forward history (stack of URLs for going forward)
forward_history : Array[String]
/// Focus manager for Tab/Shift+Tab navigation
mut focus_manager : @aom.FocusManager?
/// Current accessibility tree
mut a11y_tree : @aom.AccessibilityTree?
/// Selection mode (raw mode disabled for text copy)
mut selection_mode : Bool
/// Dark mode for display
mut dark_mode : Bool
/// Cached render node (for TUI text mode)
mut render_node : @layout.Node?
/// Cached layout tree (for incremental layout)
mut layout_tree : @layout.LayoutTree?
/// Previous TUI buffer for diff rendering
mut prev_buffer : @tui.CharBuffer?
/// Last render key (for diff invalidation)
mut last_render_key : String
/// Last color scheme used to build render node
mut last_color_scheme_dark : Bool
/// DOM tree for JS execution
mut dom_tree : @dom.DomTree?
/// Task scheduler
scheduler : @scheduler.Scheduler
/// Script executor for JS execution
mut script_executor : @js.ScriptExecutor?
/// Optional external JS runtime (e.g. V8 for native target)
mut js_runtime_override : &@js.JsRuntime?
/// Whether JavaScript execution is enabled (requires --enable-js flag)
mut enable_js : Bool
/// Element scroll states (keyed by element id)
/// For elements with overflow: auto/scroll that have scrollable content
element_scroll_states : Map[String, @dom.ScrollState]
/// Currently focused scrollable element id (for keyboard scrolling)
focused_scrollable : Ref[String?]
/// Currently hovered source element id
mut hovered_source_id : String?
/// Source currently pressed by pointer
mut pressed_source_id : String?
/// Text-control selection anchor source id during pointer drag
mut text_selection_anchor_source_id : String?
/// Text-control selection anchor column during pointer drag
mut text_selection_anchor_col : Int
/// Text-control selection anchor row during pointer drag
mut text_selection_anchor_row : Int
/// Source currently in drag session
mut dragging_source_id : String?
/// Current drag-over target
mut drag_over_source_id : String?
/// Current drop-allowed target (set by canceled dragover)
mut drop_allowed_source_id : String?
/// No-color mode (disable ANSI color codes)
mut no_color : Bool
/// Terminal columns (for pixel-to-column scaling)
terminal_cols : Int
/// Image regions from last render (for kitty/sixel overlay)
mut image_regions : Array[@tui.ImageRegion]
/// Cached rendered image data (src URL -> kitty graphics commands)
image_cache : Map[String, String]
/// Image cache insertion order for oldest-first eviction
image_cache_order : Array[String]
/// Total cached image payload size in bytes (approximated by String length)
mut image_cache_total_bytes : Int
/// Max cached image payload size before eviction
mut image_cache_max_bytes : Int
/// Uploaded kitty image ids that can be re-placed without re-sending payloads
kitty_uploaded_image_ids : Map[Int, Bool]
/// Visible kitty placements from the previous text render
mut kitty_visible_placements : Map[String, KittyPlacement]
/// HTTP cache for static assets (CSS, images, scripts, fonts)
http_cache : @http.MemoryCacheBackend
/// Cookie jar (disabled by default for security)
mut cookie_jar : @http.CookieJar
}
///|
/// Create a new browser instance
pub fn Browser::new(
width : Int,
height : Int,
terminal_cols? : Int = 80,
) -> Browser {
{
current_url: "",
last_navigation_request: None,
request_sandbox: @http.RequestSandbox::Open,
viewport_width: width,
viewport_height: height,
html_content: "",
source_html: "",
dom_snapshot_html: "",
parsed_doc: None,
external_css: [],
links: [],
focused_link: 0,
scroll_y: 0,
content_height: 0,
link_regions: [],
hit_regions: [],
hint_mode: false,
hints: [],
hint_input: "",
back_history: [],
forward_history: [],
focus_manager: None,
a11y_tree: None,
selection_mode: false,
dark_mode: false,
render_node: None,
layout_tree: None,
prev_buffer: None,
last_render_key: "",
last_color_scheme_dark: false,
dom_tree: None,
scheduler: @scheduler.Scheduler::new(),
script_executor: None,
js_runtime_override: None,
enable_js: false, // Disabled by default for security
element_scroll_states: {},
focused_scrollable: Ref::new(None),
hovered_source_id: None,
pressed_source_id: None,
text_selection_anchor_source_id: None,
text_selection_anchor_col: 0,
text_selection_anchor_row: 0,
dragging_source_id: None,
drag_over_source_id: None,
drop_allowed_source_id: None,
no_color: false,
terminal_cols,
image_regions: [],
image_cache: {},
image_cache_order: [],
image_cache_total_bytes: 0,
image_cache_max_bytes: default_image_cache_max_bytes,
kitty_uploaded_image_ids: {},
kitty_visible_placements: {},
http_cache: @http.MemoryCacheBackend::new(max_entries=1000),
cookie_jar: @http.CookieJar::new(), // disabled by default
}
}
///|
/// Enable or disable JavaScript execution
/// JavaScript is disabled by default for security reasons.
/// Use --enable-js flag to enable it.
pub fn Browser::set_enable_js(self : Browser, enable : Bool) -> Unit {
self.enable_js = enable
}
///|
/// Enable or disable cookie jar.
/// Cookies are disabled by default for security.
pub fn Browser::set_enable_cookies(self : Browser, enable : Bool) -> Unit {
self.cookie_jar = @http.CookieJar::new(enabled=enable)
}
///|
pub fn Browser::set_image_cache_max_bytes(
self : Browser,
max_bytes : Int,
) -> Unit {
self.image_cache_max_bytes = max_bytes.max(0)
self.evict_image_cache_if_needed()
}
///|
/// Set a custom JS runtime for script execution (e.g. V8 for native).
/// Must be called before any script execution (before navigate/load_html).
pub fn Browser::set_js_runtime(
self : Browser,
runtime : &@js.JsRuntime,
) -> Unit {
self.js_runtime_override = Some(runtime)
self.enable_js = true
}
///|
/// Configure request sandbox policy
pub fn Browser::set_request_sandbox(
self : Browser,
sandbox : @http.RequestSandbox,
) -> Unit {
self.request_sandbox = sandbox
}
///|
/// Get current URL
pub fn Browser::get_dom_tree(self : Browser) -> @dom.DomTree? {
self.dom_tree
}
///|
/// Set HTML content directly (for testing or headless rendering).
pub fn Browser::set_html_content(self : Browser, html : String) -> Unit {
self.source_html = html
match normalize_declarative_shadow_source_html(html) {
Some(normalized) => {
self.html_content = normalized.render_html
self.dom_snapshot_html = normalized.snapshot_html
self.parsed_doc = Some(normalized.parsed_doc)
}
None => {
self.html_content = html
self.dom_snapshot_html = html
self.parsed_doc = None
}
}
self.links = extract_links(self.html_content)
self.focused_link = 0
self.scroll_y = 0
self.clear_element_scroll_states()
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
self.content_height = 0
self.a11y_tree = None
self.focus_manager = None
self.dom_tree = None
self.script_executor = None
}
///|
pub fn Browser::get_current_url(self : Browser) -> String {
self.current_url
}
///|
pub fn Browser::get_last_navigation_url(self : Browser) -> String? {
match self.last_navigation_request {
Some(request) => Some(request.url)
None => None
}
}
///|
/// Get viewport height in pixels
pub fn Browser::get_viewport_height(self : Browser) -> Int {
self.viewport_height
}
///|
/// Get number of extracted links
pub fn Browser::get_link_count(self : Browser) -> Int {
self.links.length()
}
///|
/// Get currently focused link index
pub fn Browser::get_focused_link_index(self : Browser) -> Int {
self.focused_link
}
///|
/// Whether hint mode is active
pub fn Browser::is_hint_mode(self : Browser) -> Bool {
self.hint_mode
}
///|
/// Resolve relative URL to absolute
fn resolve_url(base_url : String, href : String) -> String {
// Skip empty, fragment-only, or javascript URLs
if href.length() == 0 ||
href.has_prefix("#") ||
href.has_prefix("javascript:") ||
href.has_prefix("mailto:") ||
href.has_prefix("tel:") {
return ""
}
// Already absolute
if href.has_prefix("http://") || href.has_prefix("https://") {
return href
}
if href.has_prefix("data:") ||
href.has_prefix("about:") ||
href.has_prefix("blob:") ||
href.has_prefix("file:") {
return href
}
// Protocol-relative
if href.has_prefix("//") {
let protocol = if base_url.has_prefix("https://") {
"https:"
} else {
"http:"
}
return protocol + href
}
// Extract base from URL
let protocol_end = match base_url.find("://") {
Some(i) => i
None => return href
}
let after_protocol = base_url.unsafe_substring(
start=protocol_end + 3,
end=base_url.length(),
)
let path_start = after_protocol.find("/")
let origin = match path_start {
Some(i) => base_url.unsafe_substring(start=0, end=protocol_end + 3 + i)
None => base_url
}
// Root-relative
if href.has_prefix("/") {
return origin + href
}
// Relative - append to current path
let last_slash = base_url.rev_find("/")
match last_slash {
Some(i) if i > protocol_end + 2 =>
base_url.unsafe_substring(start=0, end=i + 1) + href
_ => origin + "/" + href
}
}
///|
fn make_substr(chars : Array[Char], start : Int, end : Int) -> String {
let buf = StringBuilder::new()
for i = start; i < end; i = i + 1 {
buf.write_char(chars[i])
}
buf.to_string()
}
///|
fn hex_digit_to_int(c : Char) -> Int? {
if c >= '0' && c <= '9' {
Some(c.to_int() - '0'.to_int())
} else if c >= 'a' && c <= 'f' {
Some(c.to_int() - 'a'.to_int() + 10)
} else if c >= 'A' && c <= 'F' {
Some(c.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn percent_decode_data_url_payload(payload : String) -> String {
let chars = payload.to_array()
let buf = StringBuilder::new()
let mut i = 0
while i < chars.length() {
if chars[i] == '%' && i + 2 < chars.length() {
match (hex_digit_to_int(chars[i + 1]), hex_digit_to_int(chars[i + 2])) {
(Some(high), Some(low)) => {
buf.write_char((high * 16 + low).unsafe_to_char())
i = i + 3
}
_ => {
buf.write_char(chars[i])
i = i + 1
}
}
} else {
buf.write_char(chars[i])
i = i + 1
}
}
buf.to_string()
}
///|
fn decode_sync_navigable_html_url(url : String) -> String? {
if url == "about:blank" {
return Some("")
}
if !url.has_prefix("data:") {
return None
}
let chars = url.to_array()
let mut comma_idx = -1
for i = 5; i < chars.length(); i = i + 1 {
if chars[i] == ',' {
comma_idx = i
break
}
}
if comma_idx < 0 {
return None
}
let header = make_substr(chars, 5, comma_idx)
if header.contains(";base64") {
return None
}
let mut payload_end = chars.length()
for i = comma_idx + 1; i < chars.length(); i = i + 1 {
if chars[i] == '?' || chars[i] == '#' {
payload_end = i
break
}
}
let payload = make_substr(chars, comma_idx + 1, payload_end)
Some(percent_decode_data_url_payload(payload))
}
///|
fn default_navigation_request(url : String) -> NavigationRequest {
{ url, http_method: "GET", content_type: "", body: "" }
}
///|
fn parse_navigation_request(json_str : String) -> NavigationRequest? {
let parsed = @json.parse(json_str) catch { _ => return None }
match parsed {
Object(map) =>
match
(
map.get("url"),
map.get("http_method"),
map.get("content_type"),
map.get("body"),
) {
(
Some(String(url)),
Some(String(http_method)),
Some(String(content_type)),
Some(String(body)),
) =>
Some({ url, http_method: http_method.to_upper(), content_type, body })
_ => None
}
_ => None
}
}
///|
fn decode_js_string_result(value : String) -> String? {
let parsed = @json.parse(value) catch { _ => return None }
match parsed {
String(text) => Some(text)
_ => None
}
}
///|
fn Browser::load_sync_navigable_html_request(
self : Browser,
request : NavigationRequest,
) -> Bool {
match decode_sync_navigable_html_url(request.url) {
Some(html) => {
self.current_url = request.url
self.last_navigation_request = Some(request)
self.set_html_content(html)
self.external_css = []
let script_count = self.execute_scripts()
if script_count > 0 {
println(
"Executed " +
script_count.to_string() +
" script(s) during sync page load",
)
}
true
}
None => false
}
}
///|
fn Browser::navigate_sync_if_supported(
self : Browser,
request : NavigationRequest,
) -> Bool {
let previous_url = self.current_url
let navigated = self.load_sync_navigable_html_request(request)
if navigated {
if previous_url.length() > 0 {
self.back_history.push(previous_url)
}
self.forward_history.clear()
}
navigated
}
///|
/// Wrapper to adapt @http.fetch (which has optional params) to the
/// async fetcher signature expected by cached_fetch_async.
async fn http_fetch_adapter(
url : String,
options : @http.FetchOptions,
) -> @http.HttpResponse raise @http.HttpError {
@http.fetch(url, options~)
}
///|
/// Fetch external stylesheets and return CSS array
async fn fetch_external_css(
html : String,
base_url : String,
sandbox : @http.RequestSandbox,
cache : @http.MemoryCacheBackend,
) -> Array[String] {
// Use lightweight extraction instead of full DOM parsing
let stylesheet_links = @html.extract_stylesheet_links(html)
let link_count = stylesheet_links.length()
if link_count == 0 {
return []
}
println("Found " + link_count.to_string() + " external stylesheets")
// Fetch all external CSS
let css_contents : Array[String] = []
for link in stylesheet_links {
let css_url = resolve_url(base_url, link)
println("Fetching CSS: " + css_url)
// Try to fetch CSS with cache, ignore errors
try {
let options = {
..@http.FetchOptions::default(),
mode: @http.RequestMode::NoCors,
origin: base_url,
sandbox,
}
let response = @http.cached_fetch_async(
url=css_url,
options~,
cache~,
fetcher=http_fetch_adapter,
)
css_contents.push(response.body)
println(" -> OK (" + response.body.length().to_string() + " bytes)")
} catch {
err => println(" -> Error: " + err.to_string())
}
}
println("Loaded " + css_contents.length().to_string() + " stylesheets")
// Analyze CSS support
@browser_contract.report_css_support(css_contents)
css_contents
}
///|
/// Navigate to a URL and render (adds to back history, clears forward history)
pub async fn Browser::navigate(
self : Browser,
url : String,
) -> String raise @http.HttpError {
self.navigate_request(default_navigation_request(url))
}
///|
/// Navigate to URL in lightweight mode (for --json, --aom modes)
/// Skips CSS cascade and layout calculation to reduce memory usage
pub async fn Browser::navigate_lightweight(
self : Browser,
url : String,
) -> String raise @http.HttpError {
self.load_url_lightweight(url)
}
///|
/// Load a URL without adding to history (internal use)
async fn Browser::load_url(
self : Browser,
url : String,
) -> String raise @http.HttpError {
self.load_url_request(default_navigation_request(url))
}
///|
async fn Browser::navigate_request(
self : Browser,
request : NavigationRequest,
) -> String raise @http.HttpError {
if self.current_url.length() > 0 {
self.back_history.push(self.current_url)
}
self.forward_history.clear()
self.load_url_request(request)
}
///|
async fn Browser::load_url_request(
self : Browser,
request : NavigationRequest,
) -> String raise @http.HttpError {
let source_url = self.current_url
let request_record = {
url: request.url,
http_method: request.http_method,
content_type: request.content_type,
body: request.body,
}
let url = request_record.url
self.current_url = url
self.last_navigation_request = Some(request_record)
if url == "about:blank" || url.has_prefix("data:") {
let _ = self.load_sync_navigable_html_request(request_record)
return ""
}
let t0 = perf_now()
// Fetch the page
let page_headers : Map[String, String] = {}
let is_secure = url.has_prefix("https://")
match
self.cookie_jar.get_cookie_header(
url,
is_secure,
true,
site_origin=source_url,
is_top_level_navigation=true,
) {
Some(cookie_header) => page_headers["Cookie"] = cookie_header
None => ()
}
if request_record.content_type.length() > 0 {
page_headers["Content-Type"] = request_record.content_type
}
let page_options = {
..@http.FetchOptions::default(),
headers: page_headers,
http_method: request_record.http_method,
body: request_record.body,
mode: @http.RequestMode::Navigate,
origin: source_url,
sandbox: self.request_sandbox,
}
let response = @http.fetch(url, options=page_options)
// Process Set-Cookie from response
match response.headers.get("set-cookie") {
Some(sc) => self.cookie_jar.store_from_header(sc, url, is_secure)
None => ()
}
self.source_html = response.body
match normalize_declarative_shadow_source_html(response.body) {
Some(normalized) => {
self.html_content = normalized.render_html
self.dom_snapshot_html = normalized.snapshot_html
self.parsed_doc = Some(normalized.parsed_doc)
}
None => {
self.html_content = response.body
self.dom_snapshot_html = response.body
self.parsed_doc = None
}
}
let t1 = perf_now()
// Fetch external CSS using lightweight extraction (no full parsing)
self.external_css = fetch_external_css(
self.html_content,
url,
self.request_sandbox,
self.http_cache,
) catch {
_ => [] // Fallback to no external CSS on error
}
let t2 = perf_now()
// Extract links from HTML (lightweight, no full parsing)
self.links = extract_links(self.html_content)
self.focused_link = 0
self.scroll_y = 0
// Clear element scroll states on navigation
self.clear_element_scroll_states()
// Parse HTML using regular parser (streaming parser under investigation)
match self.parsed_doc {
Some(_) => ()
None => {
let doc = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(doc)
}
}
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
let t3 = perf_now()
// Execute scripts including external (JS execution)
let script_count = self.execute_scripts_async() catch { _ => 0 }
if script_count > 0 {
println(
"Executed " + script_count.to_string() + " script(s) during page load",
)
}
let t4 = perf_now()
// Content height is computed lazily from the layout tree in render_text()
// to avoid a duplicate full layout pass here.
self.content_height = 0
// Accessibility tree is built lazily on first focus navigation
// to avoid a duplicate full layout pass here.
self.a11y_tree = None
self.focus_manager = None
// Prefetch images for kitty graphics rendering
self.prefetch_images() catch {
_ => ()
}
let t5 = perf_now()
perf_log(
"[perf] fetch=\{(t1 - t0).to_int()}ms css=\{(t2 - t1).to_int()}ms parse=\{(t3 - t2).to_int()}ms scripts=\{(t4 - t3).to_int()}ms images=\{(t5 - t4).to_int()}ms total=\{(t5 - t0).to_int()}ms",
)
// Render to Sixel
self.render()
}
///|
/// Load a URL in lightweight mode (for --json, --aom modes)
/// Skips CSS cascade and layout calculation to reduce memory usage
async fn Browser::load_url_lightweight(
self : Browser,
url : String,
) -> String raise @http.HttpError {
let source_url = self.current_url
self.current_url = url
// Fetch the page
let page_options = {
..@http.FetchOptions::default(),
mode: @http.RequestMode::Navigate,
origin: source_url,
sandbox: self.request_sandbox,
}
let response = @http.fetch(url, options=page_options)
self.source_html = response.body
match normalize_declarative_shadow_source_html(response.body) {
Some(normalized) => {
self.html_content = normalized.render_html
self.dom_snapshot_html = normalized.snapshot_html
self.parsed_doc = Some(normalized.parsed_doc)
}
None => {
self.html_content = response.body
self.dom_snapshot_html = response.body
self.parsed_doc = None
}
}
// Skip external CSS fetching in lightweight mode
self.external_css = []
// Skip link extraction (not needed for JSON/AOM output)
self.links = []
self.focused_link = 0
self.scroll_y = 0
// Clear element scroll states on navigation
self.clear_element_scroll_states()
// Parse HTML using regular parser (streaming parser under investigation)
match self.parsed_doc {
Some(_) => ()
None => {
let doc = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(doc)
}
}
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
// Skip content height calculation in lightweight mode
self.content_height = 0
// Build lightweight accessibility tree (no CSS cascade, no layout)
self.build_accessibility_tree_lightweight()
// Return empty string (no rendering in lightweight mode)
""
}
///|
/// Go back to previous page in history and load it
pub async fn Browser::go_back(self : Browser) -> String? raise @http.HttpError {
if self.back_history.length() == 0 {
return None
}
// Save current URL to forward history
if self.current_url.length() > 0 {
self.forward_history.push(self.current_url)
}
let url = self.back_history.pop().unwrap()
let _ = self.load_url(url)
Some(url)
}
///|
/// Go forward to next page in history and load it
pub async fn Browser::go_forward(
self : Browser,
) -> String? raise @http.HttpError {
if self.forward_history.length() == 0 {
return None
}
// Save current URL to back history
if self.current_url.length() > 0 {
self.back_history.push(self.current_url)
}
let url = self.forward_history.pop().unwrap()
let _ = self.load_url(url)
Some(url)
}
///|
/// Render current content in selected output mode
pub fn Browser::render_output(self : Browser, mode : OutputMode) -> String {
self.ensure_declarative_shadow_dom_normalized()
match mode {
Sixel => self.render()
Kitty => self.render_kitty()
Text => self.render_text()
Json => self.render_json()
Aom => self.render_aom()
Arc90 => self.render_arc90()
ExtractMain => self.render_extract_main()
Grounding => self.render_grounding()
}
}
///|
fn Browser::flush_js_logs(self : Browser) -> Unit {
match self.script_executor {
Some(executor) => {
for log in executor.get_logs() {
println("[JS] " + log)
}
executor.clear_logs()
}
None => ()
}
}
///|
pub async fn[T : @io.Writer] Browser::write_output(
self : Browser,
mode : OutputMode,
writer : T,
) -> Unit {
match mode {
Kitty => self.write_kitty_output(writer)
_ => writer.write(self.render_output(mode)) catch { _ => () }
}
}
///|
/// Render current content to Sixel
fn Browser::render(self : Browser) -> String {
if self.html_content.length() == 0 {
return ""
}
let installed_provider = self.install_sixel_image_provider()
let output = @renderer.render_to_sixel_with_css(
self.html_content,
self.viewport_width,
self.viewport_height,
self.scroll_y,
self.external_css,
)
if installed_provider {
@image.clear_image_provider()
}
output
}
///|
/// Render current content to Kitty graphics protocol
fn Browser::render_kitty(self : Browser) -> String {
if self.html_content.length() == 0 {
return ""
}
let base = @renderer.render_to_kitty_with_css(
self.html_content,
self.viewport_width,
self.viewport_height,
self.scroll_y,
self.external_css,
)
self.collect_graphics_image_regions()
let overlay = self.build_kitty_image_overlay()
if overlay.length() > 0 {
base + overlay
} else {
base
}
}
///|
async fn[T : @io.Writer] Browser::write_kitty_output(
self : Browser,
writer : T,
) -> Unit {
if self.html_content.length() == 0 {
return
}
@renderer.write_kitty_with_css(
writer,
self.html_content,
self.viewport_width,
self.viewport_height,
self.scroll_y,
self.external_css,
)
self.collect_graphics_image_regions()
let overlay = self.build_kitty_image_overlay()
if overlay.length() > 0 {
writer.write(overlay) catch {
_ => ()
}
}
}
///|
/// Render current content to ANSI text (TUI mode)
fn Browser::render_text(self : Browser) -> String {
if self.html_content.length() == 0 {
return ""
}
let rt0 = perf_now()
let ctx = @browser_contract.create_render_context(
self.viewport_width,
self.viewport_height,
self.dark_mode,
)
if self.last_color_scheme_dark != self.dark_mode {
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
}
let mut force_full = false
// Build or reuse cached render node + layout tree
let render_node = match (self.render_node, self.layout_tree) {
(Some(node), Some(_tree)) => node
_ => {
force_full = true
let rn0 = perf_now()
let n = match self.parsed_doc {
Some(doc) =>
@renderer.render_to_node_with_document(doc, ctx, self.external_css)
None =>
@renderer.render_to_node_with_external_css(
self.html_content,
ctx,
self.external_css,
)
}
let rn1 = perf_now()
let tree = @layout.LayoutTree::from_node(
n,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
let rn2 = perf_now()
perf_log(
"[perf:build] css_cascade+node=\{(rn1 - rn0).to_int()}ms layout_tree_init=\{(rn2 - rn1).to_int()}ms",
)
self.render_node = Some(n)
self.layout_tree = Some(tree)
n
}
}
let rt1 = perf_now()
let (layout, dirty_rects) = match self.layout_tree {
Some(tree) => {
let l = tree.compute_incremental()
let rects = tree.collect_dirty_rects()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
(l, rects)
}
None => {
force_full = true
let tree = @layout.LayoutTree::from_node(
render_node,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
let l = tree.compute_incremental()
let rects = tree.collect_dirty_rects()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
(l, rects)
}
}
let rt2 = perf_now()
// Update content_height from layout result (avoids duplicate layout pass)
if self.content_height == 0 {
self.content_height = @tui.get_content_extent(layout).to_int()
}
// Convert pixel dimensions to character dimensions
// Buffer width = viewport / 8 (wider than terminal for proper CSS layout)
// Output is clipped to terminal_cols at ANSI rendering stage
let char_width = self.viewport_width / 8
let char_height = self.viewport_height / 16
let char_scroll_y = self.scroll_y / 16
// Get focused element name for :focus styling
let focused_element_name = self.get_focused_element_name()
// Get focused link URL for status bar
let focused_link_url = self.get_focused_link_url()
let render_key = self.current_url +
"|" +
self.scroll_y.to_string() +
"|" +
self.focused_link.to_string() +
"|" +
focused_element_name.unwrap_or("") +
"|" +
focused_link_url.unwrap_or("") +
"|" +
self.hint_mode.to_string() +
"|" +
self.hint_input +
"|" +
self.selection_mode.to_string() +
"|" +
self.dark_mode.to_string() +
"|" +
self.viewport_width.to_string() +
"|" +
self.viewport_height.to_string()
if render_key != self.last_render_key {
force_full = true
}
// Use hint mode rendering if in hint mode
let buffer_result = if self.hint_mode {
@tui.render_to_buffer_with_hints(
render_node,
layout,
char_width,
char_height,
char_scroll_y,
self.hints,
self.hint_input,
focused_element_name~,
)
} else {
@tui.render_to_buffer_with_status(
render_node,
layout,
char_width,
char_height,
char_scroll_y,
self.current_url,
self.get_visible_focusable_count(),
self.get_visible_focus_index(),
focused_element_name~,
focused_link_url~,
selection_mode=self.selection_mode,
dark_mode=self.dark_mode,
total_content_height=self.content_height,
show_scrollbar=true,
element_scroll_positions=self.get_element_scroll_positions(),
)
}
let rt3 = perf_now()
// Store link regions for click hit testing
self.link_regions = buffer_result.link_regions
self.hit_regions = buffer_result.hit_regions
self.image_regions = buffer_result.image_regions
// Initialize scroll states for scrollable elements
self.init_scrollable_elements(buffer_result.scrollable_elements)
let plain_links = self.build_plain_links(buffer_result.link_regions)
// Choose buffer-to-output based on no_color mode
// Clip output to terminal_cols (buffer may be wider than terminal)
let max_cols = self.terminal_cols
let ansi = match self.prev_buffer {
Some(prev) =>
if force_full {
if self.no_color {
@tui.clear_screen() +
@tui.buffer_to_plain_with_links(buffer_result.buffer, plain_links)
} else {
@tui.clear_screen() +
@tui.buffer_to_ansi(buffer_result.buffer, max_cols~)
}
} else if dirty_rects.length() > 0 && !self.no_color {
// Diff rendering only for color mode (plain mode always does full redraw)
let content_height = if self.hint_mode {
char_height - 1
} else {
char_height - 1
}
let row_offset = if self.hint_mode { 0 } else { 1 }
let dirty_cells = @tui.dirty_rects_to_cells(
dirty_rects,
self.scroll_y,
char_width,
content_height,
row_offset,
)
@tui.buffer_diff_to_ansi_rects(
buffer_result.buffer,
prev,
dirty_cells,
max_cols~,
)
} else if self.no_color {
@tui.clear_screen() +
@tui.buffer_to_plain_with_links(buffer_result.buffer, plain_links)
} else {
""
}
None =>
if self.no_color {
@tui.clear_screen() +
@tui.buffer_to_plain_with_links(buffer_result.buffer, plain_links)
} else {
@tui.clear_screen() +
@tui.buffer_to_ansi(buffer_result.buffer, max_cols~)
}
}
// Append kitty graphics commands for image regions
let kitty_overlay = self.build_kitty_image_overlay()
let final_output = if kitty_overlay.length() > 0 {
ansi + kitty_overlay
} else {
ansi
}
let rt4 = perf_now()
self.prev_buffer = Some(buffer_result.buffer)
self.last_render_key = render_key
self.last_color_scheme_dark = self.dark_mode
perf_log(
"[perf:render] node+layout_build=\{(rt1 - rt0).to_int()}ms layout_compute=\{(rt2 - rt1).to_int()}ms tui_buffer=\{(rt3 - rt2).to_int()}ms ansi=\{(rt4 - rt3).to_int()}ms total=\{(rt4 - rt0).to_int()}ms",
)
final_output
}
///|
fn Browser::collect_graphics_image_regions(self : Browser) -> Unit {
if self.html_content.length() == 0 {
self.image_regions = []
return
}
let ctx = @browser_contract.create_render_context(
self.viewport_width,
self.viewport_height,
self.dark_mode,
)
let render_node = match (self.render_node, self.layout_tree) {
(Some(node), Some(_tree)) => node
_ => {
let n = match self.parsed_doc {
Some(doc) =>
@renderer.render_to_node_with_document(doc, ctx, self.external_css)
None =>
@renderer.render_to_node_with_external_css(
self.html_content,
ctx,
self.external_css,
)
}
let tree = @layout.LayoutTree::from_node(
n,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
self.render_node = Some(n)
self.layout_tree = Some(tree)
n
}
}
let layout = match self.layout_tree {
Some(tree) => {
let l = tree.compute_incremental()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
l
}
None => {
let tree = @layout.LayoutTree::from_node(
render_node,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
let l = tree.compute_incremental()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
l
}
}
let char_width = self.viewport_width / 8
let char_height = self.viewport_height / 16
let char_scroll_y = self.scroll_y / 16
let focused_element_name = self.get_focused_element_name()
let buffer_result = @tui.render_to_buffer(
render_node,
layout,
char_width,
char_height,
char_scroll_y,
focused_element_name~,
)
self.image_regions = buffer_result.image_regions
}
///|
fn Browser::build_plain_links(
self : Browser,
link_regions : Array[@tui.LinkRegion],
) -> Array[@tui.PlainLink] {
let plain_links : Array[@tui.PlainLink] = []
if !self.no_color {
return plain_links
}
for region in link_regions {
match self.get_link_href_for_region(region) {
Some(href) => {
let url = resolve_url(self.current_url, href)
if url.length() > 0 {
plain_links.push({
col: region.col,
row: region.row,
width: region.width,
url,
})
}
}
None => ()
}
}
plain_links
}
///|
pub fn Browser::render_text_full_page(self : Browser) -> String {
if self.html_content.length() == 0 {
return ""
}
let ctx = @browser_contract.create_render_context(
self.viewport_width,
self.viewport_height,
self.dark_mode,
)
if self.last_color_scheme_dark != self.dark_mode {
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
}
let render_node = match (self.render_node, self.layout_tree) {
(Some(node), Some(_tree)) => node
_ => {
let n = match self.parsed_doc {
Some(doc) =>
@renderer.render_to_node_with_document(doc, ctx, self.external_css)
None =>
@renderer.render_to_node_with_external_css(
self.html_content,
ctx,
self.external_css,
)
}
let tree = @layout.LayoutTree::from_node(
n,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
self.render_node = Some(n)
self.layout_tree = Some(tree)
n
}
}
let layout = match self.layout_tree {
Some(tree) => {
let l = tree.compute_incremental()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
l
}
None => {
let tree = @layout.LayoutTree::from_node(
render_node,
self.viewport_width.to_double(),
self.viewport_height.to_double(),
)
let l = tree.compute_incremental()
tree.mark_all_layouts_seen()
self.layout_tree = Some(tree)
l
}
}
if self.content_height == 0 {
self.content_height = @tui.get_content_extent(layout).to_int()
}
let char_width = self.viewport_width / 8
let content_height = @tui.get_content_height_chars(layout).max(1)
let focused_element_name = self.get_focused_element_name()
let buffer_result = @tui.render_to_buffer(
render_node,
layout,
char_width,
content_height,
0,
focused_element_name~,
)
self.link_regions = buffer_result.link_regions
self.hit_regions = buffer_result.hit_regions
self.image_regions = buffer_result.image_regions
self.init_scrollable_elements(buffer_result.scrollable_elements)
self.prev_buffer = None
self.last_render_key = ""
self.last_color_scheme_dark = self.dark_mode
let plain_links = self.build_plain_links(buffer_result.link_regions)
if self.no_color {
@tui.buffer_to_plain_with_links(buffer_result.buffer, plain_links)
} else {
@tui.buffer_to_ansi(buffer_result.buffer, max_cols=self.terminal_cols)
}
}
///|
fn stable_kitty_id(seed : String) -> Int {
let mut hash = 5381
let modulus = 16777213
for c in seed {
hash = (hash * 131 + c.to_int()) % modulus
}
(hash + 1).max(1)
}
///|
fn kitty_image_id_for_src(src : String) -> Int {
stable_kitty_id("image:" + src)
}
///|
fn kitty_placement_id_for_source(source_id : String) -> Int {
stable_kitty_id("placement:" + source_id)
}
///|
fn current_kitty_placement(region : @tui.ImageRegion) -> KittyPlacement {
let image_id = kitty_image_id_for_src(region.src)
let placement_id = kitty_placement_id_for_source(region.source_id)
{
image_id,
placement_id,
col: region.col,
row: region.row,
width: region.width,
height: region.height,
}
}
///|
fn same_kitty_placement(lhs : KittyPlacement, rhs : KittyPlacement) -> Bool {
lhs.image_id == rhs.image_id &&
lhs.placement_id == rhs.placement_id &&
lhs.col == rhs.col &&
lhs.row == rhs.row &&
lhs.width == rhs.width &&
lhs.height == rhs.height
}
///|
/// Build kitty graphics overlay commands for image regions
fn Browser::build_kitty_image_overlay(self : Browser) -> String {
let next_visible : Map[String, KittyPlacement] = {}
let render_specs : Array[KittyRenderSpec] = []
for region in self.image_regions {
match self.image_cache.get(region.src) {
Some(cached_data) => {
let placement = current_kitty_placement(region)
next_visible.set(region.source_id, placement)
render_specs.push({ region, cached_data, placement })
}
None => ()
}
}
if next_visible.is_empty() && self.kitty_visible_placements.is_empty() {
return ""
}
let buf = StringBuilder::new()
for source_id, previous in self.kitty_visible_placements {
match next_visible.get(source_id) {
Some(current) if current.image_id == previous.image_id => ()
_ =>
buf.write_string(
write_kitty_delete_placement(previous.image_id, previous.placement_id),
)
}
}
for spec in render_specs {
let placement_key = spec.region.source_id
let uploaded = self.kitty_uploaded_image_ids.contains(
spec.placement.image_id,
)
let previous = self.kitty_visible_placements.get(placement_key)
let unchanged = match previous {
Some(prev) => same_kitty_placement(prev, spec.placement)
None => false
}
if uploaded && unchanged {
continue
}
buf.write_string(
"\u001b[" +
(spec.region.row + 1).to_string() +
";" +
(spec.region.col + 1).to_string() +
"H",
)
let kitty_data = if uploaded {
write_kitty_put_placement(
spec.placement.image_id,
spec.placement.placement_id,
spec.placement.width,
spec.placement.height,
)
} else {
render_cached_kitty_image_with_placement(
spec.cached_data,
spec.region,
spec.placement.image_id,
spec.placement.placement_id,
)
}
if kitty_data.length() > 0 {
buf.write_string(kitty_data)
self.kitty_uploaded_image_ids.set(spec.placement.image_id, true)
}
}
self.kitty_visible_placements = next_visible
buf.to_string()
}
///|
fn remove_image_cache_order_key(order : Array[String], src : String) -> Unit {
let mut found_idx : Int? = None
for i, key in order {
if key == src {
found_idx = Some(i)
break
}
}
match found_idx {
Some(idx) => {
let _ = order.remove(idx)
}
None => ()
}
}
///|
fn Browser::evict_image_cache_if_needed(self : Browser) -> Unit {
while self.image_cache_total_bytes > self.image_cache_max_bytes &&
!self.image_cache_order.is_empty() {
let evicted_src = self.image_cache_order.remove(0)
match self.image_cache.get(evicted_src) {
Some(cached_data) => {
self.image_cache.remove(evicted_src)
self.image_cache_total_bytes = (self.image_cache_total_bytes -
cached_data.length()).max(0)
}
None => ()
}
}
}
///|
fn Browser::cache_image_data(
self : Browser,
src : String,
data : String,
) -> Unit {
if self.image_cache.contains(src) {
match self.image_cache.get(src) {
Some(existing) =>
self.image_cache_total_bytes = (self.image_cache_total_bytes -
existing.length()).max(0)
None => ()
}
self.image_cache.remove(src)
remove_image_cache_order_key(self.image_cache_order, src)
}
self.image_cache.set(src, data)
self.image_cache_order.push(src)
self.image_cache_total_bytes += data.length()
self.evict_image_cache_if_needed()
}
///|
/// Prefetch images from HTML and cache rendered kitty graphics data
async fn Browser::prefetch_images(self : Browser) -> Unit {
if self.html_content.length() == 0 {
return
}
let srcs = extract_img_srcs(self.html_content)
for src in srcs {
if self.image_cache.contains(src) {
continue
}
if self.cache_data_image_src(src) {
continue
}
if src.to_lower().has_prefix("data:") {
continue
}
let abs_url = resolve_url(self.current_url, src)
if abs_url.length() == 0 {
continue
}
let body = try {
let options = {
..@http.FetchOptions::default(),
sandbox: self.request_sandbox,
}
let response = @http.cached_fetch_async(
url=abs_url,
options~,
cache=self.http_cache,
fetcher=http_fetch_adapter,
)
response.body
} catch {
_ => continue
}
if body.length() == 0 {
continue
}
let is_svg = abs_url.to_lower().has_suffix(".svg") ||
abs_url.to_lower().contains(".svg?")
if is_svg {
self.cache_image_data(src, "svg:" + body)
} else if body.has_prefix("base64:") {
let b64 = body.unsafe_substring(start=7, end=body.length())
self.cache_image_data(src, prepare_raster_image_for_kitty_cache(b64))
}
}
}
///|
fn Browser::cache_data_image_src(self : Browser, src : String) -> Bool {
match data_image_src_to_kitty_cache(src) {
Some(cached_data) => {
self.cache_image_data(src, cached_data)
true
}
None => false
}
}
///|
fn data_url_payload_end(chars : Array[Char], start : Int) -> Int {
let mut end = chars.length()
for i = start; i < chars.length(); i = i + 1 {
if chars[i] == '?' || chars[i] == '#' {
end = i
break
}
}
end
}
///|
fn remove_ascii_whitespace(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
if !c.is_ascii_whitespace() {
buf.write_char(c)
}
}
buf.to_string()
}
///|
fn data_image_src_to_kitty_cache(src : String) -> String? {
if !src.to_lower().has_prefix("data:") {
return None
}
let chars = src.to_array()
let mut comma_idx = -1
for i = 5; i < chars.length(); i = i + 1 {
if chars[i] == ',' {
comma_idx = i
break
}
}
if comma_idx < 0 {
return None
}
let header = make_substr(chars, 5, comma_idx).to_lower()
if !header.has_prefix("image/") {
return None
}
let payload_end = data_url_payload_end(chars, comma_idx + 1)
let payload = make_substr(chars, comma_idx + 1, payload_end)
if header.has_prefix("image/svg+xml") {
if header.contains(";base64") {
return None
}
return Some("svg:" + percent_decode_data_url_payload(payload))
}
if !header.contains(";base64") {
return None
}
if header.has_prefix("image/png") ||
header.has_prefix("image/jpeg") ||
header.has_prefix("image/jpg") ||
header.has_prefix("image/gif") ||
header.has_prefix("image/webp") {
Some(prepare_raster_image_for_kitty_cache(remove_ascii_whitespace(payload)))
} else {
None
}
}
///|
fn decode_base64_char(c : Char) -> Int? {
let cp = c.to_int()
if cp >= 'A'.to_int() && cp <= 'Z'.to_int() {
Some(cp - 'A'.to_int())
} else if cp >= 'a'.to_int() && cp <= 'z'.to_int() {
Some(cp - 'a'.to_int() + 26)
} else if cp >= '0'.to_int() && cp <= '9'.to_int() {
Some(cp - '0'.to_int() + 52)
} else if c == '+' {
Some(62)
} else if c == '/' {
Some(63)
} else {
None
}
}
///|
fn decode_base64_bytes(encoded : String) -> Array[Int]? {
let bytes : Array[Int] = []
let quartet : Array[Int] = []
for c in encoded {
if c.is_ascii_whitespace() {
continue
}
if c == '=' {
quartet.push(-1)
} else {
match decode_base64_char(c) {
Some(v) => quartet.push(v)
None => return None
}
}
if quartet.length() == 4 {
let v0 = quartet[0]
let v1 = quartet[1]
let v2 = quartet[2]
let v3 = quartet[3]
if v0 < 0 || v1 < 0 {
return None
}
bytes.push(((v0 << 2) | (v1 >> 4)) & 0xFF)
if v2 >= 0 {
bytes.push((((v1 & 0x0F) << 4) | (v2 >> 2)) & 0xFF)
}
if v2 >= 0 && v3 >= 0 {
bytes.push((((v2 & 0x03) << 6) | v3) & 0xFF)
}
if v2 < 0 || v3 < 0 {
break
}
quartet.clear()
}
}
Some(bytes)
}
///|
fn rgba_cache_entry_to_resolved_image(entry : String) -> @image.ResolvedImage? {
match parse_kitty_rgba_cache_entry(entry) {
Some((width, height, rgba_b64)) =>
match decode_base64_bytes(rgba_b64) {
Some(rgba) =>
if rgba.length() >= width * height * 4 {
Some(@image.ResolvedImage::Raster({ width, height, rgba }))
} else {
None
}
None => None
}
None => None
}
}
///|
fn raster_base64_to_resolved_image(
image_format : String,
b64_data : String,
) -> @image.ResolvedImage? {
match decode_raster_image_to_rgba_base64(image_format, b64_data) {
Some(entry) => rgba_cache_entry_to_resolved_image(entry)
None => None
}
}
///|
fn cached_image_data_to_resolved_image(
cached_data : String,
) -> @image.ResolvedImage? {
if cached_data.has_prefix("svg:") {
let svg_text = cached_data.unsafe_substring(
start=4,
end=cached_data.length(),
)
Some(@image.ResolvedImage::Svg(svg_text))
} else if cached_data.has_prefix("img_rgba:") {
rgba_cache_entry_to_resolved_image(
cached_data.unsafe_substring(start=9, end=cached_data.length()),
)
} else if cached_data.has_prefix("img_png:") {
raster_base64_to_resolved_image(
"png",
cached_data.unsafe_substring(start=8, end=cached_data.length()),
)
} else if cached_data.has_prefix("img:") {
let b64_data = cached_data.unsafe_substring(
start=4,
end=cached_data.length(),
)
raster_base64_to_resolved_image(
detect_raster_image_format_from_base64(b64_data),
b64_data,
)
} else {
None
}
}
///|
fn Browser::install_sixel_image_provider(self : Browser) -> Bool {
if self.image_cache.is_empty() {
return false
}
@image.set_image_provider({
resolve: fn(src) {
match self.image_cache.get(src) {
Some(cached_data) => cached_image_data_to_resolved_image(cached_data)
None => None
}
},
})
true
}
///|
fn ascii_eq_ignore_case(lhs : Char, rhs : Char) -> Bool {
lhs.to_ascii_lowercase() == rhs.to_ascii_lowercase()
}
///|
fn is_img_tag_start(chars : Array[Char], i : Int) -> Bool {
if i + 4 >= chars.length() {
return false
}
chars[i] == '<' &&
ascii_eq_ignore_case(chars[i + 1], 'i') &&
ascii_eq_ignore_case(chars[i + 2], 'm') &&
ascii_eq_ignore_case(chars[i + 3], 'g') &&
(
chars[i + 4].is_ascii_whitespace() ||
chars[i + 4] == '>' ||
chars[i + 4] == '/'
)
}
///|
fn is_html_attr_name_char(c : Char) -> Bool {
!(c.is_ascii_whitespace() || c == '=' || c == '>' || c == '/')
}
///|
fn is_src_attr_name(chars : Array[Char], start : Int, end : Int) -> Bool {
end - start == 3 &&
ascii_eq_ignore_case(chars[start], 's') &&
ascii_eq_ignore_case(chars[start + 1], 'r') &&
ascii_eq_ignore_case(chars[start + 2], 'c')
}
///|
fn read_html_attr_value(chars : Array[Char], start : Int) -> (String, Int)? {
if start >= chars.length() {
return None
}
let quote = chars[start]
if quote == '"' || quote == '\'' {
let value_start = start + 1
for i = value_start; i < chars.length(); i = i + 1 {
if chars[i] == quote {
return Some((make_substr(chars, value_start, i), i + 1))
}
}
None
} else {
let mut value_end = start
while value_end < chars.length() &&
!chars[value_end].is_ascii_whitespace() &&
chars[value_end] != '>' {
value_end = value_end + 1
}
if value_end > start {
Some((make_substr(chars, start, value_end), value_end))
} else {
None
}
}
}
///|
/// Extract img src attributes from HTML (lightweight, no full parsing)
fn extract_img_srcs(html : String) -> Array[String] {
let srcs : Array[String] = []
let chars = html.to_array()
let len = chars.length()
let mut i = 0
while i < len {
if is_img_tag_start(chars, i) {
let mut j = i + 4
while j < len && chars[j] != '>' {
while j < len && chars[j].is_ascii_whitespace() {
j = j + 1
}
if j >= len || chars[j] == '>' {
break
}
if chars[j] == '/' {
j = j + 1
continue
}
let name_start = j
while j < len && is_html_attr_name_char(chars[j]) {
j = j + 1
}
let name_end = j
while j < len && chars[j].is_ascii_whitespace() {
j = j + 1
}
if j < len && chars[j] == '=' {
j = j + 1
while j < len && chars[j].is_ascii_whitespace() {
j = j + 1
}
match read_html_attr_value(chars, j) {
Some((value, next_idx)) => {
if is_src_attr_name(chars, name_start, name_end) {
srcs.push(value)
}
j = next_idx
}
None => ()
}
}
}
i = j
} else {
i = i + 1
}
}
srcs
}
///|
/// Render SVG to kitty graphics with cell-size display parameters (c=cols, r=rows)
fn render_svg_to_kitty_with_cells(
svg_text : String,
render_width : Int,
render_height : Int,
display_cols : Int,
display_rows : Int,
image_id? : Int = 0,
placement_id? : Int = 0,
) -> String {
let svg_node = match @svg.parse_svg(svg_text) {
Some(n) => n
None => return ""
}
let scene = @svg.Scene::new(svg_node)
let fb = @image.Framebuffer::new(render_width, render_height)
let palette = @image.DynamicPalette::new()
fb.fill_rect(0, 0, render_width, render_height, 0)
let viewbox = extract_svg_viewbox(svg_text)
let (vx, vy, vw, vh) = match viewbox {
Some(v) => v
None => {
let bounds = scene.get_bounds()
(0.0, 0.0, bounds.width(), bounds.height())
}
}
if vw <= 0.0 || vh <= 0.0 {
return ""
}
let camera = @svg.Camera::new(render_width, render_height)
camera.set_position(vx + vw / 2.0, vy + vh / 2.0)
let zoom = (render_width.to_double() / vw).min(render_height.to_double() / vh)
camera.set_zoom(zoom)
@image.render_svg_scene_with_camera(scene, fb, palette, camera)
to_kitty_with_cells(
fb,
palette,
display_cols,
display_rows,
image_id~,
placement_id~,
)
}
///|
/// Encode framebuffer to kitty graphics with c (columns) and r (rows) display size
fn to_kitty_with_cells(
fb : @image.Framebuffer,
palette : @image.DynamicPalette,
cols : Int,
rows : Int,
image_id? : Int = 0,
placement_id? : Int = 0,
) -> String {
let buf = StringBuilder::new()
let mut chunk = StringBuilder::new()
let mut chunk_len = 0
let mut first_chunk = true
let pixel_count = fb.width * fb.height
let chunk_size = 4096
for pixel_index in 0.. chunk_size {
// Write chunk header
if first_chunk {
buf.write_string("\u001b_Ga=T,f=24,q=2,c=")
buf.write_string(cols.to_string())
buf.write_string(",r=")
buf.write_string(rows.to_string())
if image_id > 0 {
buf.write_string(",i=")
buf.write_string(image_id.to_string())
if placement_id > 0 {
buf.write_string(",p=")
buf.write_string(placement_id.to_string())
}
}
buf.write_string(",s=")
buf.write_string(fb.width.to_string())
buf.write_string(",v=")
buf.write_string(fb.height.to_string())
buf.write_string(",m=1;")
} else {
buf.write_string("\u001b_Gm=1;")
}
buf.write_string(chunk.to_string())
buf.write_string("\u001b\\")
chunk = StringBuilder::new()
chunk_len = 0
first_chunk = false
}
let color_idx = fb.pixels[pixel_index]
let color = if color_idx >= 0 && color_idx < palette.colors.length() {
palette.colors[color_idx]
} else {
@image.Color::white()
}
// Base64 encode RGB triplet
let b0 = color.r
let b1 = color.g
let b2 = color.b
let base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
let c0 = (b0 >> 2) & 0x3F
let c1 = ((b0 << 4) | (b1 >> 4)) & 0x3F
let c2 = ((b1 << 2) | (b2 >> 6)) & 0x3F
let c3 = b2 & 0x3F
chunk.write_char(base64_chars[c0].unsafe_to_char())
chunk.write_char(base64_chars[c1].unsafe_to_char())
chunk.write_char(base64_chars[c2].unsafe_to_char())
chunk.write_char(base64_chars[c3].unsafe_to_char())
chunk_len = chunk_len + 4
}
// Final chunk
if chunk_len > 0 {
if first_chunk {
buf.write_string("\u001b_Ga=T,f=24,q=2,c=")
buf.write_string(cols.to_string())
buf.write_string(",r=")
buf.write_string(rows.to_string())
if image_id > 0 {
buf.write_string(",i=")
buf.write_string(image_id.to_string())
if placement_id > 0 {
buf.write_string(",p=")
buf.write_string(placement_id.to_string())
}
}
buf.write_string(",s=")
buf.write_string(fb.width.to_string())
buf.write_string(",v=")
buf.write_string(fb.height.to_string())
buf.write_string(",m=0;")
} else {
buf.write_string("\u001b_Gm=0;")
}
buf.write_string(chunk.to_string())
buf.write_string("\u001b\\")
}
buf.to_string()
}
///|
fn detect_raster_image_format_from_base64(b64_data : String) -> String {
if b64_data.has_prefix("iVBORw0KGgo") {
"png"
} else if b64_data.has_prefix("/9j/") {
"jpeg"
} else if b64_data.has_prefix("R0lGOD") {
"gif"
} else {
"unknown"
}
}
///|
#cfg(target="js")
extern "js" fn js_decode_raster_image_to_rgba_base64(
image_format : String,
b64_data : String,
) -> String =
#| (imageFormat, b64Data) => {
#| try {
#| if (typeof process === 'undefined' || process.platform !== 'darwin') {
#| return '';
#| }
#| const format = String(imageFormat || '').toLowerCase();
#| if (format !== 'png' && format !== 'jpeg' && format !== 'gif') {
#| return '';
#| }
#| const fs = require('node:fs');
#| const os = require('node:os');
#| const path = require('node:path');
#| const { execFileSync } = require('node:child_process');
#| function loadPng() {
#| try {
#| return require('pngjs').PNG;
#| } catch (_) {
#| const candidates = [
#| path.join(process.cwd(), 'node_modules', 'pngjs'),
#| path.join(process.cwd(), 'browser', 'node_modules', 'pngjs'),
#| ];
#| for (const candidate of candidates) {
#| try {
#| return require(candidate).PNG;
#| } catch (_) {}
#| }
#| throw new Error('pngjs not found');
#| }
#| }
#| const PNG = loadPng();
#| if (format === 'png') {
#| const decoded = PNG.sync.read(Buffer.from(String(b64Data || ''), 'base64'));
#| return (
#| String(decoded.width) +
#| ':' +
#| String(decoded.height) +
#| ':' +
#| Buffer.from(decoded.data).toString('base64')
#| );
#| }
#| const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crater-kitty-'));
#| const inputExt = format === 'jpeg' ? 'jpg' : 'gif';
#| const inputPath = path.join(dir, 'input.' + inputExt);
#| const outputPath = path.join(dir, 'output.png');
#| try {
#| fs.writeFileSync(inputPath, Buffer.from(String(b64Data || ''), 'base64'));
#| execFileSync(
#| 'sips',
#| ['-s', 'format', 'png', inputPath, '--out', outputPath],
#| { stdio: 'pipe' },
#| );
#| const pngBuffer = fs.readFileSync(outputPath);
#| const decoded = PNG.sync.read(pngBuffer);
#| return (
#| String(decoded.width) +
#| ':' +
#| String(decoded.height) +
#| ':' +
#| Buffer.from(decoded.data).toString('base64')
#| );
#| } finally {
#| try {
#| fs.rmSync(dir, { recursive: true, force: true });
#| } catch (_) {}
#| }
#| } catch (_) {
#| return '';
#| }
#| }
///|
#cfg(target="js")
fn decode_raster_image_to_rgba_base64(
image_format : String,
b64_data : String,
) -> String? {
let decoded = js_decode_raster_image_to_rgba_base64(image_format, b64_data)
if decoded.length() > 0 {
Some(decoded)
} else {
None
}
}
///|
#cfg(target="native")
fn decode_raster_image_to_rgba_base64(
_image_format : String,
_b64_data : String,
) -> String? {
None
}
///|
#cfg(target="js")
extern "js" fn js_transcode_raster_image_to_png_base64(
image_format : String,
b64_data : String,
) -> String =
#| (imageFormat, b64Data) => {
#| try {
#| if (typeof process === 'undefined' || process.platform !== 'darwin') {
#| return '';
#| }
#| const format = String(imageFormat || '').toLowerCase();
#| if (format !== 'jpeg' && format !== 'gif') {
#| return '';
#| }
#| const fs = require('node:fs');
#| const os = require('node:os');
#| const path = require('node:path');
#| const { execFileSync } = require('node:child_process');
#| const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crater-kitty-'));
#| const inputExt = format === 'jpeg' ? 'jpg' : 'gif';
#| const inputPath = path.join(dir, 'input.' + inputExt);
#| const outputPath = path.join(dir, 'output.png');
#| try {
#| fs.writeFileSync(inputPath, Buffer.from(String(b64Data || ''), 'base64'));
#| execFileSync(
#| 'sips',
#| ['-s', 'format', 'png', inputPath, '--out', outputPath],
#| { stdio: 'pipe' },
#| );
#| return fs.readFileSync(outputPath).toString('base64');
#| } finally {
#| try {
#| fs.rmSync(dir, { recursive: true, force: true });
#| } catch (_) {}
#| }
#| } catch (_) {
#| return '';
#| }
#| }
///|
#cfg(target="js")
fn transcode_raster_image_to_png_base64(
image_format : String,
b64_data : String,
) -> String? {
let transcoded = js_transcode_raster_image_to_png_base64(
image_format, b64_data,
)
if transcoded.length() > 0 {
Some(transcoded)
} else {
None
}
}
///|
#cfg(target="native")
fn transcode_raster_image_to_png_base64(
_image_format : String,
_b64_data : String,
) -> String? {
None
}
///|
fn normalize_raster_image_for_kitty(b64_data : String) -> String {
let image_format = detect_raster_image_format_from_base64(b64_data)
if image_format == "png" {
return b64_data
}
match transcode_raster_image_to_png_base64(image_format, b64_data) {
Some(transcoded) => transcoded
None => b64_data
}
}
///|
fn parse_int_ascii(s : String) -> Int {
let mut result = 0
for c in s {
if c >= '0' && c <= '9' {
result = result * 10 + (c.to_int() - '0'.to_int())
}
}
result
}
///|
fn parse_kitty_rgba_cache_entry(entry : String) -> (Int, Int, String)? {
match entry.find(":") {
Some(width_end) => {
let width_str = entry.unsafe_substring(start=0, end=width_end)
let rest = entry.unsafe_substring(start=width_end + 1, end=entry.length())
match rest.find(":") {
Some(height_end) => {
let height_str = rest.unsafe_substring(start=0, end=height_end)
let rgba_b64 = rest.unsafe_substring(
start=height_end + 1,
end=rest.length(),
)
let width = parse_int_ascii(width_str)
let height = parse_int_ascii(height_str)
if width > 0 && height > 0 && rgba_b64.length() > 0 {
Some((width, height, rgba_b64))
} else {
None
}
}
None => None
}
}
None => None
}
}
///|
fn prepare_raster_image_for_kitty_cache(b64_data : String) -> String {
let image_format = detect_raster_image_format_from_base64(b64_data)
if image_format == "png" {
return "img_png:" + b64_data
}
match decode_raster_image_to_rgba_base64(image_format, b64_data) {
Some(decoded) => "img_rgba:" + decoded
None => {
let normalized = normalize_raster_image_for_kitty(b64_data)
if detect_raster_image_format_from_base64(normalized) == "png" {
"img_png:" + normalized
} else {
"img:" + b64_data
}
}
}
}
///|
fn write_kitty_direct_image_data(
b64_data : String,
display_cols : Int,
display_rows : Int,
image_id? : Int = 0,
placement_id? : Int = 0,
) -> String {
if b64_data.length() == 0 {
return ""
}
let buf = StringBuilder::new()
let chunk_size = 4096
let total_len = b64_data.length()
let b64_chars = b64_data.to_array()
for offset = 0; offset < total_len; offset = offset + chunk_size {
let end = (offset + chunk_size).min(total_len)
let is_first = offset == 0
let is_last = end >= total_len
let m_val = if is_last { 0 } else { 1 }
if is_first {
buf.write_string("\u001b_Ga=T,f=100,q=2,c=")
buf.write_string(display_cols.to_string())
buf.write_string(",r=")
buf.write_string(display_rows.to_string())
if image_id > 0 {
buf.write_string(",i=")
buf.write_string(image_id.to_string())
if placement_id > 0 {
buf.write_string(",p=")
buf.write_string(placement_id.to_string())
}
}
buf.write_string(",m=")
buf.write_string(m_val.to_string())
buf.write_string(";")
} else {
buf.write_string("\u001b_Gm=")
buf.write_string(m_val.to_string())
buf.write_string(";")
}
for i = offset; i < end; i = i + 1 {
buf.write_char(b64_chars[i])
}
buf.write_string("\u001b\\")
}
buf.to_string()
}
///|
fn write_kitty_rgba_image_data(
b64_data : String,
pixel_width : Int,
pixel_height : Int,
display_cols : Int,
display_rows : Int,
image_id? : Int = 0,
placement_id? : Int = 0,
) -> String {
if b64_data.length() == 0 || pixel_width <= 0 || pixel_height <= 0 {
return ""
}
let buf = StringBuilder::new()
let chunk_size = 4096
let total_len = b64_data.length()
let b64_chars = b64_data.to_array()
for offset = 0; offset < total_len; offset = offset + chunk_size {
let end = (offset + chunk_size).min(total_len)
let is_first = offset == 0
let is_last = end >= total_len
let m_val = if is_last { 0 } else { 1 }
if is_first {
buf.write_string("\u001b_Ga=T,f=32,q=2,c=")
buf.write_string(display_cols.to_string())
buf.write_string(",r=")
buf.write_string(display_rows.to_string())
if image_id > 0 {
buf.write_string(",i=")
buf.write_string(image_id.to_string())
if placement_id > 0 {
buf.write_string(",p=")
buf.write_string(placement_id.to_string())
}
}
buf.write_string(",s=")
buf.write_string(pixel_width.to_string())
buf.write_string(",v=")
buf.write_string(pixel_height.to_string())
buf.write_string(",m=")
buf.write_string(m_val.to_string())
buf.write_string(";")
} else {
buf.write_string("\u001b_Gm=")
buf.write_string(m_val.to_string())
buf.write_string(";")
}
for i = offset; i < end; i = i + 1 {
buf.write_char(b64_chars[i])
}
buf.write_string("\u001b\\")
}
buf.to_string()
}
///|
fn write_kitty_put_placement(
image_id : Int,
placement_id : Int,
display_cols : Int,
display_rows : Int,
) -> String {
if image_id <= 0 || placement_id <= 0 {
return ""
}
"\u001b_Ga=p,q=2,i=" +
image_id.to_string() +
",p=" +
placement_id.to_string() +
",c=" +
display_cols.to_string() +
",r=" +
display_rows.to_string() +
";\u001b\\"
}
///|
fn write_kitty_delete_placement(image_id : Int, placement_id : Int) -> String {
if image_id <= 0 || placement_id <= 0 {
return ""
}
"\u001b_Ga=d,q=2,d=i,i=" +
image_id.to_string() +
",p=" +
placement_id.to_string() +
";\u001b\\"
}
///|
fn render_cached_kitty_image_with_placement(
cached_data : String,
region : @tui.ImageRegion,
image_id : Int,
placement_id : Int,
) -> String {
if cached_data.has_prefix("svg:") {
let svg_text = cached_data.unsafe_substring(
start=4,
end=cached_data.length(),
)
let render_w = region.width * 16
let render_h = region.height * 32
render_svg_to_kitty_with_cells(
svg_text,
render_w,
render_h,
region.width,
region.height,
image_id~,
placement_id~,
)
} else if cached_data.has_prefix("img_png:") {
let b64_data = cached_data.unsafe_substring(
start=8,
end=cached_data.length(),
)
write_kitty_direct_image_data(
b64_data,
region.width,
region.height,
image_id~,
placement_id~,
)
} else if cached_data.has_prefix("img_rgba:") {
let rgba_entry = cached_data.unsafe_substring(
start=9,
end=cached_data.length(),
)
match parse_kitty_rgba_cache_entry(rgba_entry) {
Some((pixel_width, pixel_height, rgba_b64)) =>
render_rgba_raster_to_kitty_with_cells(
pixel_width,
pixel_height,
rgba_b64,
region.width,
region.height,
image_id~,
placement_id~,
)
None => ""
}
} else if cached_data.has_prefix("img:") {
let b64_data = cached_data.unsafe_substring(
start=4,
end=cached_data.length(),
)
write_kitty_direct_image_data(
normalize_raster_image_for_kitty(b64_data),
region.width,
region.height,
image_id~,
placement_id~,
)
} else {
""
}
}
///|
fn render_rgba_raster_to_kitty_with_cells(
pixel_width : Int,
pixel_height : Int,
rgba_b64_data : String,
display_cols : Int,
display_rows : Int,
image_id? : Int = 0,
placement_id? : Int = 0,
) -> String {
write_kitty_rgba_image_data(
rgba_b64_data,
pixel_width,
pixel_height,
display_cols,
display_rows,
image_id~,
placement_id~,
)
}
///|
/// Build a String from a slice of a Char array
fn chars_to_string(chars : Array[Char], start : Int, end : Int) -> String {
let buf = StringBuilder::new()
for i = start; i < end; i = i + 1 {
buf.write_char(chars[i])
}
buf.to_string()
}
///|
/// Extract viewBox from SVG root element
fn extract_svg_viewbox(svg_text : String) -> (Double, Double, Double, Double)? {
let search = "viewBox=\""
let chars = svg_text.to_array()
let search_chars = search.to_array()
let len = chars.length()
let slen = search_chars.length()
for i = 0; i < len - slen; i = i + 1 {
let mut found = true
for j = 0; j < slen; j = j + 1 {
if chars[i + j] != search_chars[j] {
found = false
break
}
}
if found {
let start = i + slen
let mut end = start
while end < len && chars[end] != '"' {
end = end + 1
}
let vb_str = chars_to_string(chars, start, end)
// Parse "minX minY width height"
let parts = split_whitespace(vb_str)
if parts.length() == 4 {
let vx = parse_double(parts[0])
let vy = parse_double(parts[1])
let vw = parse_double(parts[2])
let vh = parse_double(parts[3])
return Some((vx, vy, vw, vh))
}
}
}
None
}
///|
fn split_whitespace(s : String) -> Array[String] {
let result : Array[String] = []
let chars = s.to_array()
let len = chars.length()
let mut start = 0
let mut in_word = false
for i = 0; i < len; i = i + 1 {
if chars[i] == ' ' || chars[i] == '\t' || chars[i] == '\n' {
if in_word {
result.push(chars_to_string(chars, start, i))
in_word = false
}
} else if !in_word {
start = i
in_word = true
}
}
if in_word {
result.push(chars_to_string(chars, start, len))
}
result
}
///|
fn parse_double(s : String) -> Double {
// Simple double parser for SVG attributes
let chars = s.to_array()
let len = chars.length()
let mut result = 0.0
let mut frac = 0.0
let mut frac_div = 1.0
let mut in_frac = false
let mut negative = false
let mut start = 0
if len > 0 && chars[0] == '-' {
negative = true
start = 1
}
for i = start; i < len; i = i + 1 {
if chars[i] == '.' {
in_frac = true
} else if chars[i] >= '0' && chars[i] <= '9' {
let digit = (chars[i].to_int() - '0'.to_int()).to_double()
if in_frac {
frac_div = frac_div * 10.0
frac = frac + digit / frac_div
} else {
result = result * 10.0 + digit
}
}
}
result = result + frac
if negative {
-result
} else {
result
}
}
///|
/// Get maximum scroll position
fn Browser::max_scroll(self : Browser) -> Int {
(self.content_height - self.viewport_height).max(0)
}
///|
/// Scroll down
pub fn Browser::scroll_down(self : Browser, amount : Int) -> Unit {
self.scroll_y = (self.scroll_y + amount).min(self.max_scroll())
}
///|
/// Scroll up
pub fn Browser::scroll_up(self : Browser, amount : Int) -> Unit {
self.scroll_y = (self.scroll_y - amount).max(0)
}
///|
/// Initialize scroll state for an element
/// Call this when a scrollable element is detected
fn Browser::init_element_scroll(
self : Browser,
element_id : String,
scroll_width : Double,
scroll_height : Double,
client_width : Double,
client_height : Double,
) -> Unit {
let state = @dom.ScrollState::new(
scroll_width, scroll_height, client_width, client_height,
)
self.element_scroll_states.set(element_id, state)
}
///|
/// Clear all element scroll states (call on page navigation)
fn Browser::clear_element_scroll_states(self : Browser) -> Unit {
self.element_scroll_states.clear()
self.focused_scrollable.val = None
}
///|
/// Initialize scroll states from detected scrollable elements
/// Called during render to set up scroll states for elements with overflow
fn Browser::init_scrollable_elements(
self : Browser,
elements : Array[@tui_paint_runtime.ScrollableElement],
) -> Unit {
for elem in elements {
// Only initialize if not already tracked (preserve scroll position)
if !self.element_scroll_states.contains(elem.id) {
self.init_element_scroll(
elem.id,
elem.scroll_width,
elem.scroll_height,
elem.client_width,
elem.client_height,
)
}
}
}
///|
/// Convert element scroll states to TUI format for rendering
fn Browser::get_element_scroll_positions(
self : Browser,
) -> Map[String, @tui.ElementScrollPos] {
let result : Map[String, @tui.ElementScrollPos] = {}
self.element_scroll_states.each(fn(id, state) {
result.set(id, {
scroll_top: state.scroll_top,
scroll_height: state.scroll_height,
client_height: state.client_height,
})
})
result
}
///|
fn Browser::is_bounds_visible(self : Browser, bounds : @aom.Bounds?) -> Bool {
match bounds {
Some(b) => {
let view_left = 0.0
let view_right = self.viewport_width.to_double()
let view_top = self.scroll_y.to_double()
let view_bottom = view_top + self.viewport_height.to_double()
b.x + b.width > view_left &&
b.x < view_right &&
b.y + b.height > view_top &&
b.y < view_bottom
}
None => false
}
}
///|
fn Browser::is_focus_node_visible(
self : Browser,
node : @aom.AccessibilityNode,
) -> Bool {
self.is_bounds_visible(node.bounds)
}
///|
/// Focus next visible focusable element (Tab)
pub fn Browser::next_link(self : Browser) -> Unit {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) => {
let count = fm.focusable_count()
if count == 0 {
return
}
let previous_source_id = match fm.current() {
Some(node) => node.source_id
None => None
}
for attempt = 0; attempt < count; attempt = attempt + 1 {
let _ = fm.focus_next()
match fm.current() {
Some(node) =>
if self.is_focus_node_visible(node) {
self.dispatch_focus_transition(previous_source_id, node.source_id)
return
}
None => return
}
}
}
None =>
// Fallback to legacy link navigation
if self.links.length() > 0 {
self.focused_link = (self.focused_link + 1) % self.links.length()
}
}
}
///|
/// Focus previous visible focusable element (Shift+Tab)
pub fn Browser::prev_link(self : Browser) -> Unit {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) => {
let count = fm.focusable_count()
if count == 0 {
return
}
let previous_source_id = match fm.current() {
Some(node) => node.source_id
None => None
}
for attempt = 0; attempt < count; attempt = attempt + 1 {
let _ = fm.focus_prev()
match fm.current() {
Some(node) =>
if self.is_focus_node_visible(node) {
self.dispatch_focus_transition(previous_source_id, node.source_id)
return
}
None => return
}
}
}
None =>
// Fallback to legacy link navigation
if self.links.length() > 0 {
self.focused_link = (self.focused_link - 1 + self.links.length()) %
self.links.length()
}
}
}
///|
fn Browser::ensure_declarative_shadow_dom_normalized(self : Browser) -> Unit {
match self.dom_tree {
Some(_) => return
None => ()
}
if !html_source_has_declarative_shadow_dom(self.html_content) {
return
}
match build_dom_tree_from_source_html(self.html_content) {
Some(dom) => {
self.dom_tree = Some(dom)
let _ = self.sync_render_state_from_dom_tree()
}
None => ()
}
}
///|
/// Build accessibility tree from current HTML content
fn Browser::build_accessibility_tree(self : Browser) -> Unit {
self.ensure_declarative_shadow_dom_normalized()
let previous_focus_source_id = match self.focus_manager {
Some(fm) =>
match fm.current() {
Some(node) => node.source_id
None => None
}
None => None
}
// Use cached parsed document if available
let doc = match self.parsed_doc {
Some(d) => d
None => {
if self.html_content.length() == 0 {
self.a11y_tree = None
self.focus_manager = None
return
}
// Fallback: parse if not cached (shouldn't happen in normal flow)
let d = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(d)
d
}
}
// Use the renderer's layout which applies CSS properly
let ctx = @browser_contract.create_render_context(
self.viewport_width,
self.viewport_height,
false,
)
// Render with CSS to get proper layout bounds
// Use the document with synthetic IDs for consistent ID matching
let layout = @renderer.render_document_with_external_css(
doc,
ctx,
self.external_css,
)
// Build accessibility tree with renderer's layout bounds
let tree = @aom.build_accessibility_tree_with_node_layout(doc, layout)
self.a11y_tree = Some(tree)
// Create focus manager
self.focus_manager = Some(@aom.FocusManager::new(tree))
match (self.focus_manager, previous_focus_source_id) {
(Some(fm), Some(source_id)) => {
let _ = fm.focus_by_source_id(source_id)
}
_ => ()
}
}
///|
/// Build lightweight accessibility tree (no CSS cascade, no layout)
/// Used for --json and --aom modes to reduce memory usage
fn Browser::build_accessibility_tree_lightweight(self : Browser) -> Unit {
self.ensure_declarative_shadow_dom_normalized()
// Use cached parsed document if available
let doc = match self.parsed_doc {
Some(d) => d
None => {
if self.html_content.length() == 0 {
self.a11y_tree = None
self.focus_manager = None
return
}
// Parse with regular parser
let d = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(d)
d
}
}
// Build lightweight accessibility tree (skips CSS cascade and layout)
let tree = @aom.build_accessibility_tree_lightweight(doc)
self.a11y_tree = Some(tree)
// No focus manager needed for JSON/AOM output
self.focus_manager = None
}
///|
/// Get the accessible name of the currently focused element
/// Used for :focus styling in TUI rendering
fn Browser::get_focused_element_name(self : Browser) -> String? {
match self.focus_manager {
Some(fm) =>
match fm.current() {
Some(node) => node.name
None => None
}
None => None
}
}
///|
/// Get currently focused link URL
pub fn Browser::get_focused_link_url(self : Browser) -> String? {
// Try to get URL from FocusManager first
match self.focus_manager {
Some(fm) =>
match fm.current() {
Some(node) =>
match node.role {
@aom.Link =>
match node.source_id {
Some(source_id) =>
match self.get_link_href_for_source_id(source_id) {
Some(href) => Some(resolve_url(self.current_url, href))
None =>
match node.name {
Some(name) => {
for link in self.links {
if link.text == name {
return Some(
resolve_url(self.current_url, link.href),
)
}
}
None
}
None => None
}
}
None =>
match node.name {
Some(name) => {
for link in self.links {
if link.text == name {
return Some(resolve_url(self.current_url, link.href))
}
}
None
}
None => None
}
}
_ => None
}
None => None
}
None =>
// Fallback to legacy
if self.links.length() > 0 && self.focused_link < self.links.length() {
Some(self.links[self.focused_link].href)
} else {
None
}
}
}
///|
fn Browser::get_focused_source_id(self : Browser) -> String? {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) =>
match fm.current() {
Some(node) => node.source_id
None =>
match self.a11y_tree {
Some(tree) => {
let mut link_index = 0
for node in tree.find_focusable() {
if node.role == @aom.Link {
if link_index == self.focused_link {
return node.source_id
}
link_index = link_index + 1
}
}
None
}
None => None
}
}
None =>
match self.a11y_tree {
Some(tree) => {
let mut link_index = 0
for node in tree.find_focusable() {
if node.role == @aom.Link {
if link_index == self.focused_link {
return node.source_id
}
link_index = link_index + 1
}
}
None
}
None => None
}
}
}
///|
fn Browser::get_link_source_id_at(
self : Browser,
col : Int,
row : Int,
) -> String? {
for i = self.link_regions.length() - 1; i >= 0; i = i - 1 {
let region = self.link_regions[i]
if region.contains(col, row) {
return Some(region.source_id)
}
}
None
}
///|
fn Browser::get_extracted_link_href_for_source_id(
self : Browser,
source_id : String,
) -> String? {
for link in self.links {
if link.source_id == source_id {
return Some(link.href)
}
}
None
}
///|
fn Browser::get_a11y_link_href_for_source_id(
self : Browser,
source_id : String,
) -> String? {
if self.a11y_tree is None {
self.build_accessibility_tree()
}
match self.a11y_tree {
Some(tree) =>
match tree.find_by_source_id(source_id) {
Some(node) => node.href
None => None
}
None => None
}
}
///|
fn Browser::get_link_href_for_source_id(
self : Browser,
source_id : String,
) -> String? {
match self.get_href_for_source_id(source_id) {
Some(href) => Some(href)
None =>
match self.get_a11y_link_href_for_source_id(source_id) {
Some(href) => Some(href)
None => self.get_extracted_link_href_for_source_id(source_id)
}
}
}
///|
fn Browser::get_link_href_for_region(
self : Browser,
region : @tui.LinkRegion,
) -> String? {
match self.get_link_href_for_source_id(region.source_id) {
Some(href) => Some(href)
None => {
for link in self.links {
if region.text == link.text {
return Some(link.href)
}
}
None
}
}
}
///|
fn Browser::is_clickable_source_id(self : Browser, source_id : String) -> Bool {
match self.get_href_for_source_id(source_id) {
Some(href) if href.length() > 0 => true
_ => {
if !self.enable_js {
return false
}
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return false;}if(target.hasAttribute&&(target.hasAttribute('onclick')||target.hasAttribute('data-crater-listeners-click'))){return true;}const tag=(target.tagName||'').toLowerCase();if(tag==='button'||tag==='input'||tag==='select'||tag==='textarea'||tag==='summary'){return true;}const role=target.getAttribute?target.getAttribute('role'):null;return role==='button'||role==='link';})()"
match self.execute_inline_js(source) {
Some("true") => true
_ => false
}
}
}
}
///|
fn Browser::get_paint_source_id_at(
self : Browser,
col : Int,
row : Int,
) -> String? {
if self.hit_regions.is_empty() {
return None
}
let mut i = self.hit_regions.length() - 1
while i >= 0 {
let region = self.hit_regions[i]
if region.contains(col, row) &&
self.is_clickable_source_id(region.source_id) {
return Some(region.source_id)
}
i = i - 1
}
None
}
///|
fn Browser::get_any_paint_source_id_at(
self : Browser,
col : Int,
row : Int,
) -> String? {
match @tui.find_hit_region_at(self.hit_regions, col, row) {
Some(region) => Some(region.source_id)
None => None
}
}
///|
fn bounds_contains_click(
bounds : @aom.Bounds,
px_x : Double,
px_y : Double,
) -> Bool {
px_x >= bounds.x &&
px_x < bounds.x + bounds.width &&
px_y >= bounds.y &&
px_y < bounds.y + bounds.height
}
///|
fn find_smallest_a11y_hit_target(
node : @aom.AccessibilityNode,
px_x : Double,
px_y : Double,
current_best : HitTarget?,
) -> HitTarget? {
let mut best = current_best
match (node.source_id, node.bounds) {
(Some(source_id), Some(bounds)) =>
if bounds.width > 0.0 &&
bounds.height > 0.0 &&
bounds_contains_click(bounds, px_x, px_y) {
let area = bounds.width * bounds.height
match best {
Some(existing) =>
if area < existing.area {
best = Some({ source_id, area })
}
None => best = Some({ source_id, area })
}
}
_ => ()
}
for child in node.children {
best = find_smallest_a11y_hit_target(child, px_x, px_y, best)
}
best
}
///|
fn Browser::get_a11y_source_id_at(
self : Browser,
col : Int,
row : Int,
) -> String? {
if row <= 0 {
return None
}
if self.a11y_tree is None {
self.build_accessibility_tree()
}
let px_x = col.to_double() * 8.0 + 4.0
let px_y = (row - 1 + self.scroll_y).to_double() * 16.0 + 8.0
match self.a11y_tree {
Some(tree) =>
match find_smallest_a11y_hit_target(tree.root, px_x, px_y, None) {
Some(target) => Some(target.source_id)
None => None
}
None => None
}
}
///|
fn Browser::get_source_id_at(self : Browser, col : Int, row : Int) -> String? {
match self.get_link_source_id_at(col, row) {
Some(source_id) => Some(source_id)
None =>
match self.get_paint_source_id_at(col, row) {
Some(source_id) => Some(source_id)
None => self.get_a11y_source_id_at(col, row)
}
}
}
///|
fn Browser::get_hover_source_id_at(
self : Browser,
col : Int,
row : Int,
) -> String? {
match self.get_link_source_id_at(col, row) {
Some(source_id) => Some(source_id)
None =>
match self.get_any_paint_source_id_at(col, row) {
Some(source_id) => Some(source_id)
None => self.get_a11y_source_id_at(col, row)
}
}
}
///|
fn Browser::focus_source_id(self : Browser, source_id : String) -> Unit {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) => {
let previous_source_id = match fm.current() {
Some(node) => node.source_id
None => None
}
let _ = fm.focus_by_source_id(source_id)
self.dispatch_focus_transition(previous_source_id, Some(source_id))
}
None => ()
}
}
///|
fn Browser::dispatch_focus_transition(
self : Browser,
previous_source_id : String?,
next_source_id : String?,
) -> Unit {
if !self.enable_js {
return
}
if previous_source_id == next_source_id {
return
}
let previous_js = match previous_source_id {
Some(id) => "'" + escape_js_string(id) + "'"
None => "null"
}
let next_js = match next_source_id {
Some(id) => "'" + escape_js_string(id) + "'"
None => "null"
}
let source = "(function(){const prevId=" +
previous_js +
";const nextId=" +
next_js +
";const prev=prevId?document.getElementById(prevId):null;const next=nextId?document.getElementById(nextId):null;function isChangeOnBlurTarget(target){if(!target){return false;}const tag=(target.tagName||'').toLowerCase();const type=(target.getAttribute?((target.getAttribute('type')||'').toLowerCase()):'');if(tag==='textarea'){return true;}if(tag!=='input'){return false;}return type===''||type==='text'||type==='search'||type==='url'||type==='tel'||type==='email'||type==='password'||type==='number';}function currentValue(target){if(!target){return '';}if(target.value!==undefined&&target.value!==null){return String(target.value);}if(target.getAttribute){return String(target.getAttribute('value')||'');}return '';}function maybeDispatchChangeOnBlur(target){if(!isChangeOnBlurTarget(target)||!target.getAttribute||!target.setAttribute){return;}const base=target.getAttribute('data-crater-change-base');if(base===null){return;}const current=currentValue(target);if(current!==String(base)){target.dispatchEvent(new Event('change',{bubbles:true}));}target.setAttribute('data-crater-change-base',current);}function markFocusBase(target){if(!isChangeOnBlurTarget(target)||!target.setAttribute){return;}target.setAttribute('data-crater-change-base',currentValue(target));}if(prev&&prev!==next){maybeDispatchChangeOnBlur(prev);prev.dispatchEvent(new Event('blur'));prev.dispatchEvent(new Event('focusout',{bubbles:true}));}if(next&&prev!==next){markFocusBase(next);next.dispatchEvent(new Event('focus'));next.dispatchEvent(new Event('focusin',{bubbles:true}));}return 'ok';})()"
let _ = self.execute_inline_js(source)
let _ = self.sync_render_state_from_dom_tree()
}
///|
fn Browser::dispatch_activation_default_to_source_id(
self : Browser,
source_id : String,
) -> Bool {
if !self.enable_js {
return false
}
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'false';}const tag=(target.tagName||'').toLowerCase();const type=(target.getAttribute?((target.getAttribute('type')||'').toLowerCase()):'');function resetForm(form){function resetNode(node){if(!node||!node.childNodes){return;}for(let i=0;i0){child.options[0].selected=true;selectedIndex=0;}child.selectedIndex=selectedIndex;}resetNode(child);}}resetNode(form);}if(tag==='summary'){const details=target.closest?target.closest('details'):null;if(!details||!details.setAttribute){return 'false';}if(details.hasAttribute('open')){details.removeAttribute('open');}else{details.setAttribute('open','');}return 'true';}if((tag==='button'&&type==='reset')||(tag==='input'&&type==='reset')){const form=target.closest?target.closest('form'):null;if(!form){return 'false';}const resetEvent=new Event('reset',{bubbles:true,cancelable:true});if(!form.dispatchEvent(resetEvent)){return 'true';}resetForm(form);return 'true';}return 'false';})()"
match self.execute_inline_js(source) {
Some("true") => {
let _ = self.sync_render_state_from_dom_tree()
true
}
_ => false
}
}
///|
fn Browser::get_href_for_source_id(
self : Browser,
source_id : String,
) -> String? {
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target||typeof target.getAttribute!=='function'){return '';}const href=target.getAttribute('href');return typeof href==='string'?href:'';})()"
match self.execute_inline_js(source) {
Some(href) if href.length() > 0 => Some(href)
_ => None
}
}
///|
/// Get number of visible focusable elements currently on screen
fn Browser::get_visible_focusable_count(self : Browser) -> Int {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) => {
let mut count = 0
for item in fm.get_tab_order() {
if self.is_focus_node_visible(item.node) {
count += 1
}
}
count
}
None => self.link_regions.length()
}
}
///|
/// Get current focus index among visible focusable elements (-1 if not visible)
fn Browser::get_visible_focus_index(self : Browser) -> Int {
if self.a11y_tree is None || self.focus_manager is None {
self.build_accessibility_tree()
}
match self.focus_manager {
Some(fm) =>
match fm.current() {
Some(node) => {
let focused_source_id = node.source_id
let mut visible_index = 0
for item in fm.get_tab_order() {
if self.is_focus_node_visible(item.node) {
if item.node.source_id == focused_source_id {
return visible_index
}
visible_index += 1
}
}
-1
}
None => -1
}
None => -1
}
}
///|
/// Find link URL at screen coordinates (for mouse click)
/// Returns the resolved absolute URL if a link is found
pub fn Browser::get_link_at(self : Browser, col : Int, row : Int) -> String? {
match self.get_link_source_id_at(col, row) {
Some(source_id) =>
match self.get_link_href_for_source_id(source_id) {
Some(href) => Some(href)
None =>
match @tui.find_link_at(self.link_regions, col, row) {
Some(link_text) => {
for link in self.links {
if link.text == link_text {
return Some(link.href)
}
}
None
}
None => None
}
}
None => None
}
}
///|
fn escape_js_string(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'\\' => buf.write_string("\\\\")
'\'' => buf.write_string("\\'")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
fn Browser::execute_inline_js(self : Browser, source : String) -> String? {
if !self.enable_js {
return None
}
if self.dom_tree is None {
self.init_js_execution()
}
match self.script_executor {
Some(executor) =>
try {
let result = executor.execute_source(source)
self.flush_js_logs()
let _ = self.drain_pending_form_submission_navigation_sync()
Some(result.value)
} catch {
err => {
println("[JS] Error: " + err.to_string())
self.flush_js_logs()
None
}
}
None => None
}
}
///|
async fn Browser::drain_pending_form_submission_navigation(
self : Browser,
) -> Bool raise @http.HttpError {
let value = self.peek_pending_form_submission_navigation()
match value {
Some(value) =>
if value.length() == 0 {
false
} else if value.has_prefix("request:") {
match
parse_navigation_request(
value.unsafe_substring(start=8, end=value.length()),
) {
Some(request) => {
let _ = self.shift_pending_form_submission_navigation()
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
let _ = self.navigate_request(resolved_request)
true
}
None => {
let _ = self.shift_pending_form_submission_navigation()
self.drain_pending_form_submission_navigation()
}
}
} else {
let _ = self.shift_pending_form_submission_navigation()
self.drain_pending_form_submission_navigation()
}
None => false
}
}
///|
fn Browser::drain_pending_form_submission_navigation_sync(
self : Browser,
) -> Bool {
let value = self.peek_pending_form_submission_navigation()
match value {
Some(value) =>
if value.length() == 0 {
false
} else if value.has_prefix("request:") {
match
parse_navigation_request(
value.unsafe_substring(start=8, end=value.length()),
) {
Some(request) => {
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
if self.navigate_sync_if_supported(resolved_request) {
let _ = self.shift_pending_form_submission_navigation()
true
} else {
false
}
}
None => {
let _ = self.shift_pending_form_submission_navigation()
self.drain_pending_form_submission_navigation_sync()
}
}
} else {
let _ = self.shift_pending_form_submission_navigation()
self.drain_pending_form_submission_navigation_sync()
}
None => false
}
}
///|
pub async fn Browser::execute_inline_js_async(
self : Browser,
source : String,
) -> String? raise @http.HttpError {
let result = self.execute_inline_js(source)
let navigated = self.drain_pending_form_submission_navigation()
if !navigated {
let _ = self.sync_render_state_from_dom_tree()
}
result
}
///|
fn cell_to_client_x(col : Int) -> Int {
col * 8 + 4
}
///|
fn cell_to_client_y(row : Int) -> Int {
let viewport_row = if row > 0 { row } else { 0 }
viewport_row * 16 + 8
}
///|
fn Browser::get_client_coords_for_source_id(
self : Browser,
source_id : String,
) -> (Int, Int)? {
if self.hit_regions.is_empty() {
return None
}
let mut i = self.hit_regions.length() - 1
while i >= 0 {
let region = self.hit_regions[i]
if region.source_id == source_id {
let center_col = region.col + region.width / 2
let center_row = region.row + region.height / 2
return Some((cell_to_client_x(center_col), cell_to_client_y(center_row)))
}
i = i - 1
}
None
}
///|
fn Browser::get_hit_region_for_source_id(
self : Browser,
source_id : String,
) -> @tui.HitRegion? {
if self.hit_regions.is_empty() {
return None
}
let mut i = self.hit_regions.length() - 1
while i >= 0 {
let region = self.hit_regions[i]
if region.source_id == source_id {
return Some(region)
}
i = i - 1
}
None
}
///|
fn Browser::set_text_control_selection_from_cells(
self : Browser,
source_id : String,
anchor_col : Int,
anchor_row : Int,
focus_col : Int,
focus_row : Int,
) -> Bool {
if !self.enable_js {
return false
}
match self.get_hit_region_for_source_id(source_id) {
Some(region) => {
let anchor_local_col = if anchor_col <= region.col {
0
} else if anchor_col >= region.col + region.width {
region.width
} else {
anchor_col - region.col
}
let anchor_local_row = if anchor_row <= region.row {
0
} else if anchor_row >= region.row + region.height {
region.height - 1
} else {
anchor_row - region.row
}
let focus_local_col = if focus_col <= region.col {
0
} else if focus_col >= region.col + region.width {
region.width
} else {
focus_col - region.col
}
let focus_local_row = if focus_row <= region.row {
0
} else if focus_row >= region.row + region.height {
region.height - 1
} else {
focus_row - region.row
}
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'missing';}const tag=(target.tagName||'').toLowerCase();const type=(target.getAttribute?((target.getAttribute('type')||'').toLowerCase()):'');function isTextLikeInput(){if(tag!=='input'){return false;}return type===''||type==='text'||type==='search'||type==='url'||type==='tel'||type==='email'||type==='password'||type==='number';}function currentValue(el){if(!el){return '';}if(el.value!==undefined&&el.value!==null){return String(el.value);}if(el.getAttribute){return String(el.getAttribute('value')||'');}return '';}function setCaret(index){const value=currentValue(target);const clamped=Math.max(0,Math.min(value.length,Number(index)||0));if(typeof target.setSelectionRange==='function'){target.setSelectionRange(clamped,clamped);}else{target.selectionStart=clamped;target.selectionEnd=clamped;}return 'ok';}if(tag==='textarea'){const value=currentValue(target);const lines=value.split('\\n');const localRow=" +
anchor_local_row.to_string() +
";const anchorCol=" +
anchor_local_col.to_string() +
";const focusRow=" +
focus_local_row.to_string() +
";const focusCol=" +
focus_local_col.to_string() +
";const anchorRow=localRow;function indexForPosition(row,col){let offset=0;for(let i=0;i=row||i===lines.length-1){return offset+Math.max(0,Math.min(line.length,col));}offset+=line.length+1;}return value.length;}const anchor=indexForPosition(anchorRow,anchorCol);const focus=indexForPosition(focusRow,focusCol);const start=Math.min(anchor,focus);const end=Math.max(anchor,focus);if(typeof target.setSelectionRange==='function'){target.setSelectionRange(start,end);}else{target.selectionStart=start;target.selectionEnd=end;}return 'ok';}if(isTextLikeInput()){const value=currentValue(target);const anchor=Math.max(0,Math.min(value.length," +
anchor_local_col.to_string() +
"));const focus=Math.max(0,Math.min(value.length," +
focus_local_col.to_string() +
"));const start=Math.min(anchor,focus);const end=Math.max(anchor,focus);if(typeof target.setSelectionRange==='function'){target.setSelectionRange(start,end);}else{target.selectionStart=start;target.selectionEnd=end;}return 'ok';}return 'none';})()"
self.execute_inline_js(source) == Some("ok")
}
None => false
}
}
///|
fn Browser::set_text_control_caret_from_cell(
self : Browser,
source_id : String,
col : Int,
row : Int,
) -> Bool {
self.set_text_control_selection_from_cells(source_id, col, row, col, row)
}
///|
fn Browser::process_pending_script_tasks(self : Browser) -> Int {
let ready_tasks = self.scheduler.poll_ready()
let mut processed = 0
for task in ready_tasks {
match task.action {
@scheduler.ExecuteScript(_) =>
match self.script_executor {
Some(executor) => {
let result = executor.execute(task)
let _ = self.scheduler.complete(task.id, result)
processed = processed + 1
}
None => ()
}
_ => ()
}
}
processed
}
///|
fn Browser::dispatch_click_to_source_id(
self : Browser,
source_id : String,
client_x? : Int = 0,
client_y? : Int = 0,
) -> ClickDispatchResult {
let escaped = escape_js_string(source_id)
let client_x_source = client_x.to_string()
let client_y_source = client_y.to_string()
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'missing';}const clientX=" +
client_x_source +
";const clientY=" +
client_y_source +
";function dispatch(type, ctor){const event=new ctor(type,{bubbles:true,cancelable:true,clientX,clientY,pointerId:1,pointerType:'mouse',isPrimary:true});return target.dispatchEvent(event);}dispatch('pointerdown',PointerEvent);dispatch('mousedown',MouseEvent);dispatch('pointerup',PointerEvent);dispatch('mouseup',MouseEvent);return dispatch('click',MouseEvent)?'allow':'prevent';})()"
let result = self.execute_inline_js(source)
let _ = self.sync_render_state_from_dom_tree()
match result {
Some("prevent") => { handled: true, allow_default: false }
Some("allow") => { handled: true, allow_default: true }
Some(_) => { handled: false, allow_default: true }
None => { handled: false, allow_default: true }
}
}
///|
fn Browser::dispatch_pointer_mouse_event_to_source_id(
self : Browser,
source_id : String,
pointer_type : String,
mouse_type : String,
client_x : Int,
client_y : Int,
) -> Bool {
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'missing';}const clientX=" +
client_x.to_string() +
";const clientY=" +
client_y.to_string() +
";target.dispatchEvent(new PointerEvent('" +
escape_js_string(pointer_type) +
"',{bubbles:true,cancelable:true,clientX,clientY,pointerId:1,pointerType:'mouse',isPrimary:true}));target.dispatchEvent(new MouseEvent('" +
escape_js_string(mouse_type) +
"',{bubbles:true,cancelable:true,clientX,clientY}));return 'ok';})()"
let result = self.execute_inline_js(source)
let _ = self.sync_render_state_from_dom_tree()
result == Some("ok")
}
///|
fn Browser::dispatch_click_only_to_source_id(
self : Browser,
source_id : String,
client_x : Int,
client_y : Int,
) -> ClickDispatchResult {
let escaped = escape_js_string(source_id)
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'missing';}const clientX=" +
client_x.to_string() +
";const clientY=" +
client_y.to_string() +
";return target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,clientX,clientY}))?'allow':'prevent';})()"
let result = self.execute_inline_js(source)
let _ = self.sync_render_state_from_dom_tree()
match result {
Some("prevent") => { handled: true, allow_default: false }
Some("allow") => { handled: true, allow_default: true }
Some(_) => { handled: false, allow_default: true }
None => { handled: false, allow_default: true }
}
}
///|
fn Browser::is_draggable_source_id(self : Browser, source_id : String) -> Bool {
if !self.enable_js {
return false
}
let escaped = escape_js_string(source_id)
match
self.execute_inline_js(
"(function(){const target=document.getElementById('" +
escaped +
"');return target&&target.draggable===true?'true':'false';})()",
) {
Some("true") => true
_ => false
}
}
///|
fn Browser::dispatch_drag_event_status_to_source_id(
self : Browser,
source_id : String,
event_type : String,
client_x : Int,
client_y : Int,
related_source_id? : String? = None,
) -> (Bool, Bool) {
let escaped = escape_js_string(source_id)
let related_js = match related_source_id {
Some(id) => "document.getElementById('" + escape_js_string(id) + "')"
None => "null"
}
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'missing';}const clientX=" +
client_x.to_string() +
";const clientY=" +
client_y.to_string() +
";const relatedTarget=" +
related_js +
";let dataTransfer=globalThis.__craterDragDataTransfer||null;if('" +
escape_js_string(event_type) +
"'==='dragstart'){dataTransfer=new DataTransfer();globalThis.__craterDragDataTransfer=dataTransfer;}const dispatched=target.dispatchEvent(new DragEvent('" +
escape_js_string(event_type) +
"',{bubbles:true,cancelable:true,clientX,clientY,relatedTarget,dataTransfer}));if('" +
escape_js_string(event_type) +
"'==='dragend'){globalThis.__craterDragDataTransfer=null;}return dispatched?'ok':'canceled';})()"
let result = self.execute_inline_js(source)
let _ = self.sync_render_state_from_dom_tree()
match result {
Some("ok") => (true, false)
Some("canceled") => (true, true)
_ => (false, false)
}
}
///|
fn Browser::dispatch_drag_event_to_source_id(
self : Browser,
source_id : String,
event_type : String,
client_x : Int,
client_y : Int,
related_source_id? : String? = None,
) -> Bool {
let (handled, _) = self.dispatch_drag_event_status_to_source_id(
source_id,
event_type,
client_x,
client_y,
related_source_id~,
)
handled
}
///|
fn Browser::is_current_drop_allowed(self : Browser) -> Bool {
match
self.execute_inline_js(
"(function(){const dt=globalThis.__craterDragDataTransfer||null;if(!dt){return 'none/none';}const allowed=String(dt.effectAllowed||'all').toLowerCase();let drop=String(dt.dropEffect||'none').toLowerCase();function fallbackDropEffect(v){switch(v){case 'copy':case 'copylink':case 'copymove':return 'copy';case 'link':case 'linkmove':return 'link';case 'move':return 'move';case 'all':case 'uninitialized':return 'copy';default:return 'none';}}if(drop==='none'){const fallback=fallbackDropEffect(allowed);if(fallback!=='none'){dt.dropEffect=fallback;drop=fallback;}}return allowed + '/' + drop;})()",
) {
Some("none/none") => false
Some(state) => {
let slash = state.find("/")
match slash {
Some(idx) => {
let effect_allowed = state
.unsafe_substring(start=0, end=idx)
.to_lower()
let drop_effect = state
.unsafe_substring(start=idx + 1, end=state.length())
.to_lower()
@interaction_policy.is_drop_effect_compatible(
effect_allowed, drop_effect,
)
}
None => false
}
}
None => false
}
}
///|
fn Browser::dispatch_hover_transition(
self : Browser,
previous_source_id : String?,
next_source_id : String?,
client_x : Int,
client_y : Int,
) -> Bool {
if !self.enable_js {
return false
}
match (previous_source_id, next_source_id) {
(None, None) => return false
_ => ()
}
let previous_js = match previous_source_id {
Some(id) => "document.getElementById('" + escape_js_string(id) + "')"
None => "null"
}
let next_js = match next_source_id {
Some(id) => "document.getElementById('" + escape_js_string(id) + "')"
None => "null"
}
let source = "(function(){const previous=" +
previous_js +
";const next=" +
next_js +
";const clientX=" +
client_x.to_string() +
";const clientY=" +
client_y.to_string() +
";function dispatch(target,type,ctor,related,bubbles){if(!target){return;}const event=new ctor(type,{bubbles:bubbles,cancelable:false,clientX,clientY,pointerId:1,pointerType:'mouse',isPrimary:true,relatedTarget:related});target.dispatchEvent(event);}if(previous&&previous!==next){dispatch(previous,'pointerout',PointerEvent,next,true);dispatch(previous,'mouseout',MouseEvent,next,true);dispatch(previous,'pointerleave',PointerEvent,next,false);dispatch(previous,'mouseleave',MouseEvent,next,false);}if(next&&previous!==next){dispatch(next,'pointerover',PointerEvent,previous,true);dispatch(next,'mouseover',MouseEvent,previous,true);dispatch(next,'pointerenter',PointerEvent,previous,false);dispatch(next,'mouseenter',MouseEvent,previous,false);}if(next){dispatch(next,'pointermove',PointerEvent,previous,true);dispatch(next,'mousemove',MouseEvent,previous,true);}return 'ok';})()"
let _ = self.execute_inline_js(source)
self.sync_render_state_from_dom_tree()
}
///|
fn browser_form_submit_bridge_source() -> String {
let source =
#|(() => {
#| const queue = (globalThis.__craterPendingFormSubmissions = []);
#| function isSubmitButtonElement(element) {
#| if (!element || !element.tagName || !element.getAttribute) return false;
#| const tag = String(element.tagName || '').toLowerCase();
#| const type = String(element.getAttribute('type') || '').toLowerCase();
#| if (tag === 'button') return type === '' || type === 'submit';
#| return tag === 'input' && (type === 'submit' || type === 'image');
#| }
#| function resolveFormOwnerForSubmitter(element) {
#| if (!element) return null;
#| if (element.form !== undefined && element.form !== null) return element.form;
#| return element.closest ? element.closest('form') : null;
#| }
#| function hasSubmitListenersInPath(target) {
#| let current = target;
#| while (current) {
#| const store = current._listeners && current._listeners.submit;
#| if (store && store.length > 0) return true;
#| current = current.parentNode || null;
#| }
#| const docStore = document && document._listeners && document._listeners.submit;
#| if (docStore && docStore.length > 0) return true;
#| const winStore = window && window._listeners && window._listeners.submit;
#| return !!(winStore && winStore.length > 0);
#| }
#| function buildFormNavigationRequest(form, submitter, clientX, clientY) {
#| if (!form) return 'none';
#| const submitMethodAttr = submitter && submitter.getAttribute ? submitter.getAttribute('formmethod') : null;
#| const methodSource = submitMethodAttr === null ? ((form.getAttribute && form.getAttribute('method')) || 'get') : String(submitMethodAttr);
#| const method = methodSource ? String(methodSource).toLowerCase() : 'get';
#| const submitTargetAttr = submitter && submitter.getAttribute ? submitter.getAttribute('formtarget') : null;
#| const targetSource = submitTargetAttr === null ? ((form.getAttribute && form.getAttribute('target')) || '') : String(submitTargetAttr);
#| const normalizedTarget = targetSource ? String(targetSource).toLowerCase() : '';
#| if (normalizedTarget !== '' && normalizedTarget !== '_self' && normalizedTarget !== '_top' && normalizedTarget !== '_parent') return 'handled';
#| const submitActionAttr = submitter && submitter.getAttribute ? submitter.getAttribute('formaction') : null;
#| let action = submitActionAttr === null ? ((form.getAttribute && form.getAttribute('action')) || '') : String(submitActionAttr);
#| if (!action) action = (document && document.URL) || ((window && window.location && window.location.href) || '');
#| if (!action) return 'handled';
#| const submitEnctypeAttr = submitter && submitter.getAttribute ? submitter.getAttribute('formenctype') : null;
#| const enctypeSource = submitEnctypeAttr === null ? ((form.getAttribute && form.getAttribute('enctype')) || 'application/x-www-form-urlencoded') : String(submitEnctypeAttr);
#| const enctype = enctypeSource ? String(enctypeSource).toLowerCase() : 'application/x-www-form-urlencoded';
#| const encodedPairs = [];
#| const plainPairs = [];
#| function pushPair(name, value) {
#| const stringName = String(name);
#| const stringValue = String(value);
#| encodedPairs.push(encodeURIComponent(stringName) + '=' + encodeURIComponent(stringValue));
#| plainPairs.push(stringName + '=' + stringValue);
#| }
#| function pushField(element) {
#| if (!element || !element.getAttribute) return;
#| const name = element.getAttribute('name');
#| if (!name) return;
#| const tag = String(element.tagName || '').toLowerCase();
#| const type = String(element.getAttribute('type') || '').toLowerCase();
#| if (tag === 'input' && (type === 'submit' || type === 'button' || type === 'image' || type === 'reset' || type === 'file')) return;
#| if ((type === 'checkbox' || type === 'radio') && !element.checked) return;
#| const value = element.value !== undefined && element.value !== null ? String(element.value) : String(element.getAttribute('value') || '');
#| pushPair(name, value);
#| }
#| function walk(node) {
#| if (!node || !node.childNodes) return;
#| for (let i = 0; i < node.childNodes.length; i++) {
#| const child = node.childNodes[i];
#| if (!child || child.nodeType !== 1) continue;
#| const childTag = String(child.tagName || '').toLowerCase();
#| if (childTag === 'input' || childTag === 'select' || childTag === 'textarea') pushField(child);
#| walk(child);
#| }
#| }
#| walk(form);
#| if (submitter && submitter.getAttribute) {
#| const submitTag = String(submitter.tagName || '').toLowerCase();
#| const submitType = String(submitter.getAttribute('type') || '').toLowerCase();
#| const submitName = submitter.getAttribute('name');
#| if (submitTag === 'input' && submitType === 'image') {
#| const rect = submitter.getBoundingClientRect ? submitter.getBoundingClientRect() : { left: 0, top: 0 };
#| const imageX = Math.max(0, Math.floor(Number(clientX) - (Number(rect.left) || 0)));
#| const imageY = Math.max(0, Math.floor(Number(clientY) - (Number(rect.top) || 0)));
#| const prefix = submitName ? String(submitName) + '.' : '';
#| pushPair(prefix + 'x', imageX);
#| pushPair(prefix + 'y', imageY);
#| } else if (submitName) {
#| const submitValue = submitter.value !== undefined && submitter.value !== null ? String(submitter.value) : String(submitter.getAttribute('value') || '');
#| pushPair(submitName, submitValue);
#| }
#| }
#| if (method === 'get') {
#| if (encodedPairs.length > 0) action += (action.indexOf('?') >= 0 ? '&' : '?') + encodedPairs.join('&');
#| return 'request:' + JSON.stringify({ url: action, http_method: 'GET', content_type: '', body: '' });
#| }
#| let contentType = 'application/x-www-form-urlencoded;charset=UTF-8';
#| let body = encodedPairs.join('&');
#| if (enctype === 'text/plain') {
#| contentType = 'text/plain;charset=UTF-8';
#| body = plainPairs.join('\r\n');
#| } else if (enctype === 'multipart/form-data') {
#| const boundary = '----CraterFormBoundary';
#| contentType = 'multipart/form-data; boundary=' + boundary;
#| body = '';
#| for (const pair of plainPairs) {
#| const idx = pair.indexOf('=');
#| const partName = idx >= 0 ? pair.slice(0, idx) : pair;
#| const partValue = idx >= 0 ? pair.slice(idx + 1) : '';
#| body += '--' + boundary + '\r\n';
#| body += 'Content-Disposition: form-data; name="' + partName.replace(/"/g, '%22') + '"\r\n\r\n';
#| body += partValue + '\r\n';
#| }
#| body += '--' + boundary + '--\r\n';
#| }
#| return 'request:' + JSON.stringify({ url: action, http_method: method.toUpperCase(), content_type: contentType, body });
#| }
#| function shouldSkipValidation(form, submitter) {
#| if (submitter && submitter.hasAttribute && submitter.hasAttribute('formnovalidate')) return true;
#| return !!(form && form.hasAttribute && form.hasAttribute('novalidate'));
#| }
#| function dispatchFormSubmitThroughBridge(form, submitter, clientX, clientY) {
#| if (!form) return 'none';
#| if (!shouldSkipValidation(form, submitter) &&
#| typeof form.reportValidity === 'function' &&
#| !form.reportValidity()) {
#| return 'invalid';
#| }
#| const event = new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter });
#| if (!form.dispatchEvent(event) && hasSubmitListenersInPath(form)) return 'prevent';
#| return buildFormNavigationRequest(form, submitter, clientX, clientY);
#| }
#| function submitThroughBridge() {
#| const result = buildFormNavigationRequest(this, null, 0, 0);
#| if (result !== 'none' && result !== 'prevent') queue.push(result);
#| return undefined;
#| }
#| function requestSubmitThroughBridge(submitter) {
#| const normalizedSubmitter = submitter === undefined ? null : submitter;
#| if (normalizedSubmitter !== null) {
#| if (!isSubmitButtonElement(normalizedSubmitter)) {
#| throw new TypeError("Failed to execute 'requestSubmit' on 'HTMLFormElement': parameter 1 is not a submit button");
#| }
#| if (resolveFormOwnerForSubmitter(normalizedSubmitter) !== this) {
#| throw new DOMException("Failed to execute 'requestSubmit' on 'HTMLFormElement': the specified element is not owned by this form element", 'NotFoundError');
#| }
#| }
#| const result = dispatchFormSubmitThroughBridge(this, normalizedSubmitter, 0, 0);
#| if (result !== 'none' && result !== 'prevent' && result !== 'invalid') queue.push(result);
#| return undefined;
#| }
#| globalThis.__craterBuildFormNavigationRequest = buildFormNavigationRequest;
#| globalThis.__craterDispatchFormSubmit = dispatchFormSubmitThroughBridge;
#| Object.defineProperty(HTMLFormElement.prototype, 'submit', {
#| value: submitThroughBridge,
#| configurable: true,
#| writable: true,
#| });
#| Object.defineProperty(HTMLFormElement.prototype, 'requestSubmit', {
#| value: requestSubmitThroughBridge,
#| configurable: true,
#| writable: true,
#| });
#| if (document && typeof document.querySelectorAll === 'function') {
#| const forms = document.querySelectorAll('form');
#| for (let i = 0; i < forms.length; i++) {
#| const form = forms[i];
#| if (!form) continue;
#| Object.defineProperty(form, 'submit', {
#| value: submitThroughBridge,
#| configurable: true,
#| writable: true,
#| });
#| Object.defineProperty(form, 'requestSubmit', {
#| value: requestSubmitThroughBridge,
#| configurable: true,
#| writable: true,
#| });
#| }
#| }
#| globalThis.__craterHasSubmitListenersInPath = hasSubmitListenersInPath;
#| globalThis.__craterBrowserFormSubmitBridgeInstalled = true;
#| return 'ok';
#|})()
source
}
///|
fn browser_pending_form_submission_source() -> String {
let source =
#|(() => {
#| const queue = globalThis.__craterPendingFormSubmissions || [];
#| if (queue.length === 0) return JSON.stringify('');
#| const next = queue.shift();
#| return JSON.stringify(next === undefined || next === null ? '' : String(next));
#|})()
source
}
///|
fn browser_peek_pending_form_submission_source() -> String {
let source =
#|(() => {
#| const queue = globalThis.__craterPendingFormSubmissions || [];
#| if (queue.length === 0) return JSON.stringify('');
#| const next = queue[0];
#| return JSON.stringify(next === undefined || next === null ? '' : String(next));
#|})()
source
}
///|
fn Browser::peek_pending_form_submission_navigation(self : Browser) -> String? {
match self.script_executor {
Some(executor) =>
decode_js_string_result(
executor.execute_source(browser_peek_pending_form_submission_source()).value,
) catch {
err => {
println("[JS] Error: " + err.to_string())
None
}
}
None => None
}
}
///|
fn Browser::shift_pending_form_submission_navigation(self : Browser) -> String? {
match self.script_executor {
Some(executor) =>
decode_js_string_result(
executor.execute_source(browser_pending_form_submission_source()).value,
) catch {
err => {
println("[JS] Error: " + err.to_string())
None
}
}
None => None
}
}
///|
fn Browser::dispatch_submit_to_source_id(
self : Browser,
source_id : String,
client_x? : Int = 0,
client_y? : Int = 0,
) -> SubmitDispatchResult {
if !self.enable_js {
return NotSubmittable
}
let escaped = escape_js_string(source_id)
let source_client_x = client_x.to_string()
let source_client_y = client_y.to_string()
let source = "(function(){const target=document.getElementById('" +
escaped +
"');if(!target){return 'none';}const clientX=" +
source_client_x +
";const clientY=" +
source_client_y +
";const tag=(target.tagName||'').toLowerCase();const type=(target.getAttribute?((target.getAttribute('type')||'').toLowerCase()):'');let form=null;let submitter=null;if(tag==='form'){form=target;}else{if(tag==='button'){if(type!==''&&type!=='submit'){return 'none';}submitter=target;}else if(tag==='input'){if(type==='submit'||type==='image'){submitter=target;}else if(type==='button'||type==='reset'||type==='checkbox'||type==='radio'||type==='file'){return 'none';}}else if(tag==='textarea'||tag==='select'){return 'none';}form=target.closest?target.closest('form'):null;}if(!form){return 'none';}const dispatchSubmit=globalThis.__craterDispatchFormSubmit;if(typeof dispatchSubmit==='function'){return dispatchSubmit(form,submitter,clientX,clientY);}const build=globalThis.__craterBuildFormNavigationRequest;if(typeof build!=='function'){return 'handled';}return build(form,submitter,clientX,clientY);})()"
match self.execute_inline_js(source) {
Some(result) => {
let _ = self.sync_render_state_from_dom_tree()
if result == "none" {
NotSubmittable
} else if result == "prevent" {
Prevented
} else if result == "invalid" {
Handled
} else if result == "handled" {
Handled
} else if result.has_prefix("request:") {
match
parse_navigation_request(
result.unsafe_substring(start=8, end=result.length()),
) {
Some(request) => Navigate(request)
None => NotSubmittable
}
} else {
NotSubmittable
}
}
None => NotSubmittable
}
}
///|
fn Browser::dispatch_focused_key_to_source_id(
self : Browser,
source_id : String,
key : String,
) -> FocusedKeyDispatchResult {
if !self.enable_js {
return NotHandled
}
let escaped_id = escape_js_string(source_id)
let escaped_key = escape_js_string(key)
let source = "(function(){const target=document.getElementById('" +
escaped_id +
"');const key='" +
escaped_key +
"';if(!target){return 'none';}const tag=(target.tagName||'').toLowerCase();const type=(target.getAttribute?((target.getAttribute('type')||'').toLowerCase()):'');function dispatchKeyboard(typeName,cancelable){const ev=new KeyboardEvent(typeName,{bubbles:true,cancelable,key:key});return target.dispatchEvent(ev);}function finishHandled(){dispatchKeyboard('keyup',false);return 'handled';}function finish(result){dispatchKeyboard('keyup',false);return result;}function dispatchBeforeInput(inputType,data){let event;if(typeof InputEvent==='function'){event=new InputEvent('beforeinput',{bubbles:true,cancelable:true,data,inputType});}else{event=new Event('beforeinput',{bubbles:true,cancelable:true});event.data=data;event.inputType=inputType;}return target.dispatchEvent(event);}function dispatchInput(){target.dispatchEvent(new Event('input',{bubbles:true}));}function dispatchChange(){target.dispatchEvent(new Event('change',{bubbles:true}));markChangeBase(target);}function currentValue(el){if(!el){return '';}if(el.value!==undefined&&el.value!==null){return String(el.value);}if(el.getAttribute){return String(el.getAttribute('value')||'');}return '';}function markChangeBase(el){if(!el||!el.setAttribute){return;}el.setAttribute('data-crater-change-base',currentValue(el));}function ensureDefaultValue(el){if(!el||!el.getAttribute||!el.setAttribute){return;}if(el.getAttribute('data-crater-default-value')!==null){return;}el.setAttribute('data-crater-default-value',currentValue(el));}function ensureDefaultChecked(el){if(!el||!el.getAttribute||!el.setAttribute){return;}if(el.getAttribute('data-crater-default-checked')!==null){return;}el.setAttribute('data-crater-default-checked',el.checked?'true':'false');}function syncValueAttr(el){if(el&&el.setAttribute){el.setAttribute('value',currentValue(el));}}function syncCheckedAttr(el){if(!el||!el.setAttribute){return;}if(el.checked){el.setAttribute('checked','');}else if(el.removeAttribute){el.removeAttribute('checked');}}function currentSelection(){const value=currentValue(target);const length=value.length;let start=target.selectionStart;let end=target.selectionEnd;if(start===undefined||start===null){start=length;}if(end===undefined||end===null){end=start;}start=Math.max(0,Math.min(length,Number(start)||0));end=Math.max(0,Math.min(length,Number(end)||0));if(end {
let _ = self.sync_render_state_from_dom_tree()
match result {
"handled" => Handled
"activate" => Activate
"submit" => Submit
_ => NotHandled
}
}
None => NotHandled
}
}
///|
///|
pub fn Browser::tick_js(self : Browser) -> Bool {
if !self.enable_js {
return false
}
if self.dom_tree is None {
self.init_js_execution()
}
let processed = self.process_pending_script_tasks()
if processed > 0 {
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
return self.sync_render_state_from_dom_tree()
}
let ticked = match self.script_executor {
Some(executor) =>
executor.tick() catch {
err => {
println("[JS] Error: " + err.to_string())
false
}
}
None => false
}
if !ticked {
return false
}
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
self.sync_render_state_from_dom_tree()
}
///|
pub async fn Browser::handle_focused_key(
self : Browser,
key : String,
) -> Bool raise @http.HttpError {
let source_id = self.get_focused_source_id()
match source_id {
Some(id) => {
self.focus_source_id(id)
match self.dispatch_focused_key_to_source_id(id, key) {
Handled => true
Activate => self.activate_focused_link()
Submit =>
match self.dispatch_submit_to_source_id(id) {
Prevented => true
Handled => true
Navigate(request) => {
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
let _ = self.navigate_request(resolved_request)
true
}
NotSubmittable => true
}
NotHandled => false
}
}
None => false
}
}
///|
pub async fn Browser::activate_focused_link(
self : Browser,
) -> Bool raise @http.HttpError {
let source_id = self.get_focused_source_id()
let href = self.get_focused_link_url()
match source_id {
Some(id) => {
self.focus_source_id(id)
let (client_x, client_y) = match
self.get_client_coords_for_source_id(id) {
Some(coords) => coords
None => (0, 0)
}
let dispatch = self.dispatch_click_to_source_id(id, client_x~, client_y~)
if !dispatch.allow_default {
return true
}
if self.dispatch_activation_default_to_source_id(id) {
return true
}
match self.dispatch_submit_to_source_id(id, client_x~, client_y~) {
Prevented => return true
Handled => return true
Navigate(request) => {
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
let _ = self.navigate_request(resolved_request)
return true
}
NotSubmittable => ()
}
if href is None && dispatch.handled {
return true
}
}
None => ()
}
match href {
Some(url) if url.length() > 0 => {
let _ = self.navigate(url)
true
}
_ => false
}
}
///|
pub fn Browser::hover_at(self : Browser, col : Int, row : Int) -> Bool {
let next_source_id = self.get_hover_source_id_at(col, row)
let changed = self.dispatch_hover_transition(
self.hovered_source_id,
next_source_id,
cell_to_client_x(col),
cell_to_client_y(row),
)
self.hovered_source_id = next_source_id
changed
}
///|
pub fn Browser::pointer_down_at(self : Browser, col : Int, row : Int) -> Bool {
let source_id = self.get_hover_source_id_at(col, row)
self.hovered_source_id = source_id
self.dragging_source_id = None
self.drag_over_source_id = None
self.drop_allowed_source_id = None
match source_id {
Some(id) => {
self.focus_source_id(id)
if self.set_text_control_caret_from_cell(id, col, row) {
self.text_selection_anchor_source_id = Some(id)
self.text_selection_anchor_col = col
self.text_selection_anchor_row = row
} else {
self.text_selection_anchor_source_id = None
}
self.pressed_source_id = Some(id)
self.dispatch_pointer_mouse_event_to_source_id(
id,
"pointerdown",
"mousedown",
cell_to_client_x(col),
cell_to_client_y(row),
)
}
None => {
self.pressed_source_id = None
self.text_selection_anchor_source_id = None
false
}
}
}
///|
pub fn Browser::pointer_move_at(self : Browser, col : Int, row : Int) -> Bool {
let client_x = cell_to_client_x(col)
let client_y = cell_to_client_y(row)
let mut changed = self.hover_at(col, row)
match self.pressed_source_id {
Some(pressed_id) => {
match self.text_selection_anchor_source_id {
Some(anchor_source_id) if anchor_source_id == pressed_id &&
self.dragging_source_id is None =>
if self.set_text_control_selection_from_cells(
pressed_id,
self.text_selection_anchor_col,
self.text_selection_anchor_row,
col,
row,
) {
changed = true
}
_ => ()
}
if self.dragging_source_id is None &&
self.is_draggable_source_id(pressed_id) {
if self.dispatch_drag_event_to_source_id(
pressed_id,
"dragstart",
client_x,
client_y,
related_source_id=None,
) {
self.dragging_source_id = Some(pressed_id)
changed = true
}
}
match self.dragging_source_id {
Some(drag_id) => {
let plan = @interaction_policy.drag_move_target_plan(
drag_id,
self.drag_over_source_id.unwrap_or(""),
self.get_hover_source_id_at(col, row).unwrap_or(""),
)
let next_target = if plan.over_target != "" {
Some(plan.over_target)
} else {
None
}
if self.dispatch_drag_event_to_source_id(
drag_id,
"drag",
client_x,
client_y,
related_source_id=next_target,
) {
changed = true
}
if self.drag_over_source_id != next_target {
self.drop_allowed_source_id = None
match self.drag_over_source_id {
Some(previous_target) if plan.emit_dragleave =>
if self.dispatch_drag_event_to_source_id(
previous_target,
"dragleave",
client_x,
client_y,
related_source_id=next_target,
) {
changed = true
}
_ => ()
}
match next_target {
Some(next_id) if plan.emit_dragenter =>
if self.dispatch_drag_event_to_source_id(
next_id,
"dragenter",
client_x,
client_y,
related_source_id=self.drag_over_source_id,
) {
changed = true
}
_ => ()
}
self.drag_over_source_id = next_target
}
match self.drag_over_source_id {
Some(target_id) if plan.emit_dragover => {
let (handled_drag_over, canceled_drag_over) = self.dispatch_drag_event_status_to_source_id(
target_id,
"dragover",
client_x,
client_y,
related_source_id=Some(drag_id),
)
if handled_drag_over {
changed = true
}
let accepted_target = @interaction_policy.drag_accept_target(
drag_id, target_id, canceled_drag_over,
)
self.drop_allowed_source_id = if accepted_target != "" {
Some(accepted_target)
} else {
None
}
}
_ => ()
}
}
None => ()
}
changed
}
None => changed
}
}
///|
pub async fn Browser::pointer_up_at(
self : Browser,
col : Int,
row : Int,
) -> Bool raise @http.HttpError {
let client_x = cell_to_client_x(col)
let client_y = cell_to_client_y(row)
let up_source_id = self.get_hover_source_id_at(col, row)
let pressed_source_id = self.pressed_source_id
let dragging_source_id = self.dragging_source_id
let drag_over_source_id = self.drag_over_source_id
let drop_allowed_source_id = self.drop_allowed_source_id
let mut handled = false
match up_source_id {
Some(id) =>
if self.dispatch_pointer_mouse_event_to_source_id(
id, "pointerup", "mouseup", client_x, client_y,
) {
handled = true
}
None => ()
}
self.pressed_source_id = None
self.text_selection_anchor_source_id = None
self.dragging_source_id = None
self.drag_over_source_id = None
self.drop_allowed_source_id = None
match dragging_source_id {
Some(drag_id) => {
let drop_target = @interaction_policy.drag_drop_target(
drag_id,
drag_over_source_id.unwrap_or(""),
drop_allowed_source_id.unwrap_or(""),
)
match drag_over_source_id {
Some(target_id) if drop_target == target_id &&
self.is_current_drop_allowed() =>
if self.dispatch_drag_event_to_source_id(
target_id,
"drop",
client_x,
client_y,
related_source_id=Some(drag_id),
) {
handled = true
}
_ => ()
}
if self.dispatch_drag_event_to_source_id(
drag_id,
"dragend",
client_x,
client_y,
related_source_id=drag_over_source_id,
) {
handled = true
}
return handled
}
None => ()
}
match (pressed_source_id, up_source_id) {
(Some(down_id), Some(up_id)) if down_id == up_id => {
let dispatch = self.dispatch_click_only_to_source_id(
down_id, client_x, client_y,
)
if !dispatch.allow_default {
return true
}
if self.dispatch_activation_default_to_source_id(down_id) {
return true
}
match self.dispatch_submit_to_source_id(down_id, client_x~, client_y~) {
Prevented => return true
Handled => return true
Navigate(request) => {
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
let _ = self.navigate_request(resolved_request)
return true
}
NotSubmittable => ()
}
match self.get_href_for_source_id(down_id) {
Some(url) if url.length() > 0 => {
let _ = self.navigate(resolve_url(self.current_url, url))
true
}
_ => handled || dispatch.handled
}
}
_ => handled
}
}
///|
pub async fn Browser::activate_at(
self : Browser,
col : Int,
row : Int,
) -> Bool raise @http.HttpError {
let source_id = self.get_source_id_at(col, row)
let href = match self.get_link_at(col, row) {
Some(url) => Some(resolve_url(self.current_url, url))
None =>
match source_id {
Some(id) => self.get_href_for_source_id(id)
None => None
}
}
match source_id {
Some(id) => {
self.focus_source_id(id)
let _ = self.set_text_control_caret_from_cell(id, col, row)
let dispatch = self.dispatch_click_to_source_id(
id,
client_x=cell_to_client_x(col),
client_y=cell_to_client_y(row),
)
if !dispatch.allow_default {
return true
}
if self.dispatch_activation_default_to_source_id(id) {
return true
}
match
self.dispatch_submit_to_source_id(
id,
client_x=cell_to_client_x(col),
client_y=cell_to_client_y(row),
) {
Prevented => return true
Handled => return true
Navigate(request) => {
let resolved_request = {
..request,
url: resolve_url(self.current_url, request.url),
}
let _ = self.navigate_request(resolved_request)
return true
}
NotSubmittable => ()
}
match href {
Some(url) if url.length() > 0 => {
let _ = self.navigate(url)
true
}
_ => dispatch.handled
}
}
None =>
match href {
Some(url) if url.length() > 0 => {
let _ = self.navigate(url)
true
}
_ => false
}
}
}
///|
pub async fn Browser::activate_link_at(
self : Browser,
col : Int,
row : Int,
) -> Bool raise @http.HttpError {
self.activate_at(col, row)
}
///|
/// Debug: print layout tree
pub fn Browser::debug_layout(self : Browser) -> Unit {
if self.html_content.length() == 0 {
println("No content loaded")
return
}
let ctx = @browser_contract.create_render_context(
self.viewport_width,
self.viewport_height,
false,
)
let layout = @renderer.render_with_external_css(
self.html_content,
ctx,
self.external_css,
)
println("=== Layout Tree ===")
@renderer.print_layout_tree_with_options(layout, 0, true)
println("===================")
println("Total content height: " + layout.height.to_string())
}
///|
/// Render accessibility tree as JSON (for AI scraping)
fn Browser::render_json(self : Browser) -> String {
match self.a11y_tree {
Some(tree) => tree.to_aria_json()
None => "{}"
}
}
///|
/// Render accessibility tree as YAML (Playwright-compatible)
fn Browser::render_aom(self : Browser) -> String {
match self.a11y_tree {
Some(tree) => tree.to_aria_snapshot()
None => ""
}
}
///|
/// Run Arc90 content extraction and return result
fn Browser::render_arc90(self : Browser) -> String {
match self.a11y_tree {
Some(tree) =>
@browser_contract.render_arc90_summary(
tree,
self.viewport_width,
self.viewport_height,
)
None => "No accessibility tree available."
}
}
///|
/// Extract main content text using Arc90 algorithm
fn Browser::render_extract_main(self : Browser) -> String {
match self.a11y_tree {
Some(tree) =>
@browser_contract.extract_main_text(
tree,
self.viewport_width,
self.viewport_height,
)
None => "No accessibility tree available."
}
}
///|
/// Run visual grounding demo and return results
fn Browser::render_grounding(self : Browser) -> String {
match self.a11y_tree {
Some(tree) =>
@browser_contract.render_grounding_demo(
tree,
self.viewport_width,
self.viewport_height,
)
None => "No accessibility tree available."
}
}
///|
/// Get character at index from string (returns '\0' if out of bounds)
fn char_at(s : String, i : Int) -> Char {
if i < 0 || i >= s.length() {
'\u0000'
} else {
s[i].to_int().unsafe_to_char()
}
}
///|
/// Extract links from HTML content (memory-efficient version)
fn extract_links(html : String) -> Array[Link] {
let links : Array[Link] = []
let len = html.length()
let mut i = 0
while i < len {
// Look for ' {
if j + 6 < len &&
(char_at(html, j) == 'h' || char_at(html, j) == 'H') &&
(char_at(html, j + 1) == 'r' || char_at(html, j + 1) == 'R') &&
(char_at(html, j + 2) == 'e' || char_at(html, j + 2) == 'E') &&
(char_at(html, j + 3) == 'f' || char_at(html, j + 3) == 'F') &&
char_at(html, j + 4) == '=' {
let quote = char_at(html, j + 5)
if quote == '"' || quote == '\'' {
let mut k = j + 6
while k < len && char_at(html, k) != quote {
k = k + 1
}
href = html.unsafe_substring(start=j + 6, end=k)
}
} else if j + 4 < len &&
(char_at(html, j) == 'i' || char_at(html, j) == 'I') &&
(char_at(html, j + 1) == 'd' || char_at(html, j + 1) == 'D') &&
char_at(html, j + 2) == '=' {
let quote = char_at(html, j + 3)
if quote == '"' || quote == '\'' {
let mut k = j + 4
while k < len && char_at(html, k) != quote {
k = k + 1
}
source_id = html.unsafe_substring(start=j + 4, end=k)
}
}
j = j + 1
}
// Find link text (between > and )
if j < len && char_at(html, j) == '>' {
let text_start = j + 1
let mut text_end = text_start
while text_end + 3 < len {
if char_at(html, text_end) == '<' &&
char_at(html, text_end + 1) == '/' &&
(
char_at(html, text_end + 2) == 'a' ||
char_at(html, text_end + 2) == 'A'
) {
break
}
text_end = text_end + 1
}
let text = html
.unsafe_substring(start=text_start, end=text_end)
.trim()
.to_string()
if href.length() > 0 {
links.push({ href, text, source_id })
}
i = text_end
}
}
i = i + 1
}
links
}
///|
/// Enter hint mode - generate hints for visible links only
pub fn Browser::enter_hint_mode(self : Browser) -> Unit {
if self.link_regions.length() == 0 {
return // No visible links to hint
}
self.hint_mode = true
self.hint_input = ""
// Generate hints from visible link regions only
self.hints = []
let mut label_idx = 0
for region in self.link_regions {
match self.get_link_href_for_region(region) {
Some(link_href) => {
let href = resolve_url(self.current_url, link_href)
if href.length() == 0 {
continue
}
self.hints.push({
label: generate_single_label(label_idx),
href,
text: region.text,
col: region.col,
row: region.row,
})
label_idx = label_idx + 1
}
None => ()
}
}
}
///|
/// Generate a single hint label for index
fn generate_single_label(idx : Int) -> String {
if idx < 26 {
// a-z
(97 + idx).unsafe_to_char().to_string()
} else {
// aa, ab, ac, ...
let first = 97 + idx / 26
let second = 97 + idx % 26
first.unsafe_to_char().to_string() + second.unsafe_to_char().to_string()
}
}
///|
/// Exit hint mode
pub fn Browser::exit_hint_mode(self : Browser) -> Unit {
self.hint_mode = false
self.hints = []
self.hint_input = ""
}
///|
/// Toggle selection mode (for text copy with mouse)
/// Returns true if selection mode is now enabled, false if disabled
pub fn Browser::toggle_selection_mode(self : Browser) -> Bool {
self.selection_mode = !self.selection_mode
self.selection_mode
}
///|
/// Toggle dark mode
pub fn Browser::toggle_dark_mode(self : Browser) -> Unit {
self.dark_mode = !self.dark_mode
}
///|
/// Set dark mode explicitly
pub fn Browser::set_dark_mode(self : Browser, enabled : Bool) -> Unit {
self.dark_mode = enabled
}
///|
/// Set no-color mode (disable ANSI color codes)
pub fn Browser::set_no_color(self : Browser, enabled : Bool) -> Unit {
self.no_color = enabled
}
///|
/// Process hint character input
/// Returns Some(url) if a hint is matched, None otherwise
pub fn Browser::process_hint_char(self : Browser, char : String) -> String? {
self.hint_input = self.hint_input + char
// Check for exact match
for hint in self.hints {
if hint.label == self.hint_input {
let href = hint.href
self.exit_hint_mode()
return Some(href)
}
}
// Check if any hints start with current input
let mut has_prefix_match = false
for hint in self.hints {
if hint.label.has_prefix(self.hint_input) {
has_prefix_match = true
break
}
}
// If no prefix matches, exit hint mode
if !has_prefix_match {
self.exit_hint_mode()
}
None
}
///|
/// Script info extracted from HTML
priv struct ScriptInfo {
/// Inline script source (empty if external)
source : String
/// External script URL (empty if inline)
src : String
/// Whether script is async
is_async : Bool
/// Whether script is deferred
is_defer : Bool
/// Script type attribute (empty for default JavaScript)
script_type : String
}
///|
/// Check if a script type is executable as JavaScript
fn is_executable_script_type(script_type : String) -> Bool {
// Empty type or module/javascript types are executable
if script_type.length() == 0 {
return true
}
let lower = script_type.to_lower()
// Standard JavaScript MIME types
lower == "text/javascript" ||
lower == "application/javascript" ||
lower == "module"
}
///|
/// Extract script tags from HTML
fn extract_scripts(html : String) -> Array[ScriptInfo] {
let scripts : Array[ScriptInfo] = []
let len = html.length()
let mut i = 0
while i < len {
// Look for
let mut content_end = content_start
while content_end + 8 < len {
if char_at(html, content_end) == '<' &&
char_at(html, content_end + 1) == '/' &&
(
char_at(html, content_end + 2) == 's' ||
char_at(html, content_end + 2) == 'S'
) &&
(
char_at(html, content_end + 3) == 'c' ||
char_at(html, content_end + 3) == 'C'
) &&
(
char_at(html, content_end + 4) == 'r' ||
char_at(html, content_end + 4) == 'R'
) &&
(
char_at(html, content_end + 5) == 'i' ||
char_at(html, content_end + 5) == 'I'
) &&
(
char_at(html, content_end + 6) == 'p' ||
char_at(html, content_end + 6) == 'P'
) &&
(
char_at(html, content_end + 7) == 't' ||
char_at(html, content_end + 7) == 'T'
) {
break
}
content_end = content_end + 1
}
source = html
.unsafe_substring(start=content_start, end=content_end)
.trim()
.to_string()
i = content_end
}
// Add script info if we have source or src
if source.length() > 0 || src.length() > 0 {
scripts.push({ source, src, is_async, is_defer, script_type })
}
}
i = i + 1
}
scripts
}
///|
/// Initialize DOM tree and script executor
fn Browser::init_js_execution(self : Browser) -> Unit {
// Build from parsed_doc/html_content when available so scripts can mutate
// existing DOM and those mutations can be reflected back to rendering.
let shadow_source_html = if html_source_requires_runtime_rebuild(
self.source_html,
) {
self.source_html
} else if html_source_requires_runtime_rebuild(self.dom_snapshot_html) {
self.dom_snapshot_html
} else if html_source_requires_runtime_rebuild(self.html_content) {
self.html_content
} else {
""
}
let dom = if shadow_source_html.length() > 0 {
match build_dom_tree_from_source_html(shadow_source_html) {
Some(dom) => dom
None =>
match self.parsed_doc {
Some(doc) => build_dom_tree_from_document(doc)
None =>
if self.html_content.length() > 0 {
let doc = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(doc)
build_dom_tree_from_document(doc)
} else {
create_empty_html_dom_tree()
}
}
}
} else {
match self.parsed_doc {
Some(doc) => build_dom_tree_from_document(doc)
None =>
if self.html_content.length() > 0 {
let doc = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(doc)
build_dom_tree_from_document(doc)
} else {
create_empty_html_dom_tree()
}
}
}
self.dom_tree = Some(dom)
self.script_executor = Some(
match self.js_runtime_override {
Some(runtime) => @js.ScriptExecutor::new_with_runtime(dom, runtime)
None => @js.ScriptExecutor::new_deferred(dom)
},
)
match self.script_executor {
Some(executor) =>
ignore(executor.execute_source(browser_form_submit_bridge_source())) catch {
err => println("[JS] Error: " + err.to_string())
}
None => ()
}
}
///|
/// Execute scripts from the page (inline only, sync version)
/// Requires enable_js to be set to true (use --enable-js flag)
pub fn Browser::execute_scripts(self : Browser) -> Int {
// Check if JS execution is enabled
if !self.enable_js {
return 0
}
// Ensure JS execution is initialized
if self.dom_tree is None {
self.init_js_execution()
}
// Extract scripts from HTML
let scripts = extract_scripts(self.html_content)
if scripts.length() == 0 {
return 0
}
println("Found " + scripts.length().to_string() + " script(s)")
let mut executed = 0
for script in scripts {
// Skip external scripts (use execute_scripts_async for external)
if script.src.length() > 0 {
println(" [SKIP] External script: " + script.src)
continue
}
// Queue script for execution
let blocking = !script.is_async && !script.is_defer
let task_id = @js.enqueue_script(self.scheduler, script.source, blocking)
println(
" [QUEUED] Inline script (task " +
task_id.to_string() +
", blocking=" +
blocking.to_string() +
")",
)
}
// Process queued scripts
executed = self.process_pending_script_tasks()
if executed > 0 {
if self.drain_pending_form_submission_navigation_sync() {
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
return executed
}
let _ = self.sync_render_state_from_dom_tree()
}
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
executed
}
///|
/// Execute scripts from the page (including external scripts, async version)
/// Requires enable_js to be set to true (use --enable-js flag)
async fn Browser::execute_scripts_async(self : Browser) -> Int {
// Check if JS execution is enabled
if !self.enable_js {
return 0
}
// Ensure JS execution is initialized
if self.dom_tree is None {
self.init_js_execution()
}
// Extract scripts from HTML
let scripts = extract_scripts(self.html_content)
if scripts.length() == 0 {
return 0
}
println("Found " + scripts.length().to_string() + " script(s)")
let mut executed = 0
for script in scripts {
// Skip non-JavaScript scripts (e.g., type="application/json")
if !is_executable_script_type(script.script_type) {
println(" [SKIP] Non-JS script type: " + script.script_type)
continue
}
// Determine script source
let mut source = ""
let mut script_kind = "Inline"
if script.src.length() > 0 {
// Fetch external script
let script_url = resolve_url(self.current_url, script.src)
if script_url.length() == 0 {
println(" [SKIP] Invalid script URL: " + script.src)
continue
}
println(" [FETCH] External script: " + script_url)
try {
let script_options = {
..@http.FetchOptions::default(),
mode: @http.RequestMode::NoCors,
origin: self.current_url,
sandbox: self.request_sandbox,
}
let response = @http.cached_fetch_async(
url=script_url,
options=script_options,
cache=self.http_cache,
fetcher=http_fetch_adapter,
)
println(" -> OK (" + response.body.length().to_string() + " bytes)")
source = response.body
script_kind = "External"
} catch {
err => {
println(" -> Error: " + err.to_string())
continue
}
}
} else {
source = script.source
}
// Skip empty scripts
if source.length() == 0 {
continue
}
// Queue script for execution
let blocking = !script.is_async && !script.is_defer
let task_id = @js.enqueue_script(self.scheduler, source, blocking)
println(
" [QUEUED] " +
script_kind +
" script (task " +
task_id.to_string() +
", blocking=" +
blocking.to_string() +
")",
)
}
// Process queued scripts
executed = self.process_pending_script_tasks()
if executed > 0 {
let navigated = self.drain_pending_form_submission_navigation() catch {
_ => false
}
if !navigated {
let _ = self.sync_render_state_from_dom_tree()
}
}
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
executed
}
///|
/// Apply JS-mutated DomTree back to renderer inputs and invalidate caches
fn Browser::sync_render_state_from_dom_tree(self : Browser) -> Bool {
match self.dom_tree {
Some(dom) => {
let next_html = @js.serialize_dom_to_html(dom)
let next_snapshot_html = @js.serialize_dom_to_snapshot_html(dom)
if next_html.length() == 0 {
return false
}
let render_changed = next_html != self.html_content
let snapshot_changed = next_snapshot_html != self.dom_snapshot_html
if !render_changed && !snapshot_changed {
return false
}
self.dom_snapshot_html = next_snapshot_html
self.source_html = next_snapshot_html
if !render_changed {
return false
}
self.html_content = next_html
self.links = extract_links(self.html_content)
if self.links.length() == 0 {
self.focused_link = 0
} else if self.focused_link >= self.links.length() {
self.focused_link = self.links.length() - 1
}
let next_doc = @html.assign_synthetic_ids(
@html.parse_document(self.html_content),
)
self.parsed_doc = Some(next_doc)
self.render_node = None
self.layout_tree = None
self.prev_buffer = None
self.last_render_key = ""
self.content_height = @renderer.get_content_height_with_document(
next_doc,
self.viewport_width,
self.viewport_height,
self.external_css,
)
self.build_accessibility_tree()
true
}
None => false
}
}
///|
fn build_dom_tree_from_document(doc : @html.Document) -> @dom.DomTree {
let dom = @dom.DomTree::new()
let doc_id = dom.get_document()
let root_id = build_dom_element(dom, doc.root)
let _ = dom.append_child(doc_id, root_id)
dom
}
///|
fn html_source_has_declarative_shadow_dom(html : String) -> Bool {
html.to_lower().contains("shadowrootmode")
}
///|
priv struct SourceHtmlFragment {
attrs : Array[(String, String)]
inner_html : String
}
///|
priv struct NormalizedShadowSourceHtml {
render_html : String
snapshot_html : String
parsed_doc : @html.Document
}
///|
fn is_html_attr_whitespace(c : Char) -> Bool {
c == ' ' || c == '\n' || c == '\r' || c == '\t'
}
///|
fn parse_html_attributes(raw : String) -> Array[(String, String)] {
let attrs : Array[(String, String)] = []
let mut i = 0
while i < raw.length() {
while i < raw.length() && is_html_attr_whitespace(char_at(raw, i)) {
i = i + 1
}
if i >= raw.length() {
break
}
if char_at(raw, i) == '/' {
i = i + 1
continue
}
let name_start = i
while i < raw.length() {
let c = char_at(raw, i)
if is_html_attr_whitespace(c) || c == '=' || c == '>' || c == '/' {
break
}
i = i + 1
}
let name = raw.unsafe_substring(start=name_start, end=i).trim().to_string()
while i < raw.length() && is_html_attr_whitespace(char_at(raw, i)) {
i = i + 1
}
let mut value = ""
if i < raw.length() && char_at(raw, i) == '=' {
i = i + 1
while i < raw.length() && is_html_attr_whitespace(char_at(raw, i)) {
i = i + 1
}
if i < raw.length() {
let quote = char_at(raw, i)
if quote == '"' || quote == '\'' {
i = i + 1
let value_start = i
while i < raw.length() && char_at(raw, i) != quote {
i = i + 1
}
value = raw.unsafe_substring(start=value_start, end=i)
if i < raw.length() {
i = i + 1
}
} else {
let value_start = i
while i < raw.length() {
let c = char_at(raw, i)
if is_html_attr_whitespace(c) || c == '>' {
break
}
i = i + 1
}
value = raw.unsafe_substring(start=value_start, end=i)
}
}
}
if name.length() > 0 {
attrs.push((name, value))
}
}
attrs
}
///|
fn extract_source_html_fragment(
html : String,
tag : String,
) -> SourceHtmlFragment? {
let lower = html.to_lower()
let open_needle = "<" + tag
match lower.find(open_needle) {
Some(start) => {
let mut open_end = start
while open_end < html.length() && char_at(html, open_end) != '>' {
open_end = open_end + 1
}
if open_end >= html.length() {
return None
}
let attr_source = html
.unsafe_substring(start=start + open_needle.length(), end=open_end)
.trim()
.to_string()
let close_needle = "" + tag + ">"
let inner_html = match lower.find(close_needle) {
Some(close_start) if close_start > open_end =>
html.unsafe_substring(start=open_end + 1, end=close_start)
_ => ""
}
Some({ attrs: parse_html_attributes(attr_source), inner_html })
}
None => None
}
}
///|
fn write_set_attributes_js(
buf : StringBuilder,
target_expr : String,
attrs : Array[(String, String)],
) -> Unit {
for pair in attrs {
let (name, value) = pair
buf.write_string(target_expr)
buf.write_string(".setAttribute('")
buf.write_string(escape_js_string(name))
buf.write_string("', '")
buf.write_string(escape_js_string(value))
buf.write_string("');")
}
}
///|
fn create_empty_html_dom_tree() -> @dom.DomTree {
let dom = @dom.DomTree::new()
let html = dom.create_element("html")
let head = dom.create_element("head")
let body = dom.create_element("body")
let doc_id = dom.get_document()
let _ = dom.append_child(doc_id, html)
let _ = dom.append_child(html, head)
let _ = dom.append_child(html, body)
dom
}
///|
fn normalize_declarative_shadow_source_html(
html : String,
) -> NormalizedShadowSourceHtml? {
if !html_source_has_declarative_shadow_dom(html) {
return None
}
match build_dom_tree_from_source_html(html) {
Some(dom) => {
let render_html = @js.serialize_dom_to_html(dom)
if render_html.length() == 0 {
return None
}
let snapshot_html = @js.serialize_dom_to_snapshot_html(dom)
let parsed_doc = @html.assign_synthetic_ids(
@html.parse_document(render_html),
)
Some({ render_html, snapshot_html, parsed_doc })
}
None => None
}
}
///|
let crater_form_value_attr_name = "data-crater-form-value"
///|
let crater_form_state_attr_name = "data-crater-form-state-value"
///|
fn html_source_has_internal_form_restore_state(html : String) -> Bool {
html.contains(crater_form_value_attr_name) ||
html.contains(crater_form_state_attr_name)
}
///|
fn html_source_requires_runtime_rebuild(html : String) -> Bool {
html_source_has_declarative_shadow_dom(html) ||
html_source_has_internal_form_restore_state(html)
}
///|
fn consume_internal_form_restore_state_recursive(
dom : @dom.DomTree,
node_id : @dom.NodeId,
) -> Unit {
let info = dom.get_node_info(node_id)
guard info is Ok(info) else { return }
if info.node_type == @dom.Element {
let form_value = dom
.get_attribute(node_id, crater_form_value_attr_name)
.unwrap_or(None)
let form_state_value = dom
.get_attribute(node_id, crater_form_state_attr_name)
.unwrap_or(None)
if form_value != None || form_state_value != None {
let _ = dom.set_form_associated_state(
node_id, form_value, form_state_value,
)
let _ = dom.remove_attribute(node_id, crater_form_value_attr_name)
let _ = dom.remove_attribute(node_id, crater_form_state_attr_name)
}
}
match dom.get_children(node_id) {
Ok(children) =>
for child_id in children {
consume_internal_form_restore_state_recursive(dom, child_id)
}
Err(_) => ()
}
match dom.get_shadow_root(node_id) {
Ok(Some(shadow_root_id)) =>
consume_internal_form_restore_state_recursive(dom, shadow_root_id)
_ => ()
}
}
///|
fn consume_internal_form_restore_state(dom : @dom.DomTree) -> Unit {
consume_internal_form_restore_state_recursive(dom, dom.get_document())
}
///|
fn build_dom_tree_from_source_html(html : String) -> @dom.DomTree? {
if html.length() == 0 {
return None
}
let dom = create_empty_html_dom_tree()
let ctx = @js.JsContext::new_deferred(dom)
let html_fragment = extract_source_html_fragment(html, "html")
let head_fragment = extract_source_html_fragment(html, "head")
let body_fragment = extract_source_html_fragment(html, "body")
let body_inner_html = match body_fragment {
Some(fragment) => fragment.inner_html
None => html
}
let source = StringBuilder::new()
source.write_string("(function(){")
source.write_string("const htmlEl=document.documentElement;")
source.write_string("const headEl=document.head;")
source.write_string("const bodyEl=document.body;")
match html_fragment {
Some(fragment) => write_set_attributes_js(source, "htmlEl", fragment.attrs)
None => ()
}
match head_fragment {
Some(fragment) => {
write_set_attributes_js(source, "headEl", fragment.attrs)
source.write_string("headEl.setHTMLUnsafe('")
source.write_string(escape_js_string(fragment.inner_html))
source.write_string("');")
}
None => source.write_string("headEl.setHTMLUnsafe('');")
}
match body_fragment {
Some(fragment) => {
write_set_attributes_js(source, "bodyEl", fragment.attrs)
source.write_string("bodyEl.setHTMLUnsafe('")
source.write_string(escape_js_string(fragment.inner_html))
source.write_string("');")
}
None => {
source.write_string("bodyEl.setHTMLUnsafe('")
source.write_string(escape_js_string(body_inner_html))
source.write_string("');")
}
}
source.write_string("return 'ok';})()")
try {
let result = ctx.execute(source.to_string())
if result.success {
consume_internal_form_restore_state(dom)
Some(dom)
} else {
None
}
} catch {
_ => None
}
}
///|
fn get_declarative_shadow_root_init(
elem : @html.Element,
) -> @dom.ShadowRootInit? {
if elem.tag.to_lower() != "template" {
return None
}
match elem.attributes.get("shadowrootmode") {
Some(mode) => {
let mode = mode.to_lower()
if mode != "open" && mode != "closed" {
return None
}
Some(
@dom.ShadowRootInit::new(
mode~,
delegates_focus=elem.attributes.contains("shadowrootdelegatesfocus"),
slot_assignment=if elem.attributes.contains(
"shadowrootslotassignment",
) {
match elem.attributes.get("shadowrootslotassignment") {
Some(value) if value.to_lower() == "manual" => "manual"
_ => "named"
}
} else {
"named"
},
clonable=elem.attributes.contains("shadowrootclonable"),
serializable=elem.attributes.contains("shadowrootserializable"),
),
)
}
None => None
}
}
///|
fn build_dom_children(
dom : @dom.DomTree,
parent_id : @dom.NodeId,
children : Array[@html.Node],
) -> Unit {
for child in children {
match child {
@html.Node::Element(child_elem) =>
match get_declarative_shadow_root_init(child_elem) {
Some(shadow_init) =>
match dom.get_shadow_root(parent_id) {
Ok(None) => {
let shadow_root = dom
.attach_shadow_with_init(parent_id, shadow_init)
.unwrap()
build_dom_children(dom, shadow_root, child_elem.children)
}
_ => {
let child_id = build_dom_element(dom, child_elem)
let _ = dom.append_child(parent_id, child_id)
}
}
None => {
let child_id = build_dom_element(dom, child_elem)
let _ = dom.append_child(parent_id, child_id)
}
}
@html.Node::Text(text) =>
if text.length() > 0 {
let text_id = dom.create_text(text)
let _ = dom.append_child(parent_id, text_id)
}
}
}
}
///|
fn build_dom_element(dom : @dom.DomTree, elem : @html.Element) -> @dom.NodeId {
let node_id = dom.create_element(elem.tag)
match elem.id {
Some(id) => {
let _ = dom.set_attribute(node_id, "id", id)
}
None => ()
}
for name, value in elem.attributes {
let _ = dom.set_attribute(node_id, name, value)
}
build_dom_children(dom, node_id, elem.children)
node_id
}