From 8abeed8eb715b51a752aeef8ef6591537305d793 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 15:05:54 +0900 Subject: [PATCH] refactor(js): delegate timers and DOM adapters to plinth --- gleam.toml | 1 + manifest.toml | 2 + src/glendix/js/dom.gleam | 44 +++++++++------ src/glendix/js/dom_ffi.mjs | 18 ++---- src/glendix/js/timer.gleam | 31 +++++----- src/glendix/js/timer_ffi.mjs | 15 ++--- test/glendix/js/dom_test.gleam | 90 ++++++++++++++++++++++++++++++ test/glendix/js/dom_test_ffi.mjs | 65 +++++++++++++++++++++ test/glendix/js/timer_test.gleam | 67 ++++++++++++++++++++++ test/glendix/js/timer_test_ffi.mjs | 15 +++++ 10 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 test/glendix/js/dom_test.gleam create mode 100644 test/glendix/js/dom_test_ffi.mjs create mode 100644 test/glendix/js/timer_test.gleam create mode 100644 test/glendix/js/timer_test_ffi.mjs diff --git a/gleam.toml b/gleam.toml index b6c9ad8..b1deb1f 100644 --- a/gleam.toml +++ b/gleam.toml @@ -19,6 +19,7 @@ redraw = ">= 19.2.2 and < 20.0.0" redraw_dom = ">= 19.2.2 and < 20.0.0" lustre = ">= 5.7.1 and < 6.0.0" mendraw = ">= 2.0.0 and < 3.0.0" +plinth = ">= 0.11.0 and < 1.0.0" simplifile = ">= 2.6.0 and < 3.0.0" xmlm = ">= 1.0.1 and < 2.0.0" tom = ">= 2.1.0 and < 3.0.0" diff --git a/manifest.toml b/manifest.toml index 3e546ee..da6f768 100644 --- a/manifest.toml +++ b/manifest.toml @@ -22,6 +22,7 @@ packages = [ { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, { name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" }, { name = "mendraw", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "redraw", "redraw_dom"], otp_app = "mendraw", source = "hex", outer_checksum = "BB913054A5FEDC872AAB114FFADDFB791DACFE605B3DCEEAD3620F11BB59D090" }, + { name = "plinth", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "D30FDE8FC11EB76538AB498ACFF5399D0AE27993C74D63BE05AFE8CE80C4C839" }, { name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" }, { name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" }, { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, @@ -37,6 +38,7 @@ gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" } gleeunit = { version = ">= 1.11.0 and < 2.0.0" } lustre = { version = ">= 5.7.1 and < 6.0.0" } mendraw = { version = ">= 2.0.0 and < 3.0.0" } +plinth = { version = ">= 0.11.0 and < 1.0.0" } redraw = { version = ">= 19.2.2 and < 20.0.0" } redraw_dom = { version = ">= 19.2.2 and < 20.0.0" } simplifile = { version = ">= 2.6.0 and < 3.0.0" } diff --git a/src/glendix/js/dom.gleam b/src/glendix/js/dom.gleam index e8353f1..f9b6422 100644 --- a/src/glendix/js/dom.gleam +++ b/src/glendix/js/dom.gleam @@ -1,7 +1,13 @@ -//// Provides typed DOM element operations at the JavaScript FFI boundary. +//// Provides typed DOM operations delegated to Plinth. +//// +//// Glendix's established element and rectangle handles stay public through +//// identity adapters. Native click and default scrolling remain minimal FFI +//// gaps because Plinth 0.11.0 does not provide equivalent operations. //// import gleam/option +import plinth/browser/dom_rect +import plinth/browser/element /// Represents a DOM element handle. pub type DomElement @@ -11,27 +17,32 @@ pub type DomRect /// Focuses a DOM element. pub fn focus(element element: DomElement) -> Nil { - focus_raw(element) + element.focus(to_plinth_element(element)) } /// Removes focus from a DOM element. pub fn blur(element element: DomElement) -> Nil { - blur_raw(element) + element.blur(to_plinth_element(element)) } /// Dispatches the element's native click behavior. pub fn click(element element: DomElement) -> Nil { + // Plinth 0.11.0 does not bind `HTMLElement.click`, so this remains the + // smallest compatibility FFI required to preserve Glendix's public API. click_raw(element) } /// Scrolls an element into the viewport. pub fn scroll_into_view(element element: DomElement) -> Nil { + // Plinth 0.11.0 always requests smooth/nearest scrolling. Glendix has + // historically used the platform defaults, so retain this one adapter. scroll_into_view_raw(element) } /// Reads the element's bounding client rectangle. pub fn get_bounding_client_rect(element element: DomElement) -> DomRect { - get_bounding_client_rect_raw(element) + element.get_bounding_client_rect(to_plinth_element(element)) + |> from_plinth_dom_rect } /// Finds the first descendant matching a CSS selector. @@ -39,27 +50,24 @@ pub fn query_selector( in element: DomElement, matching selector: String, ) -> option.Option(DomElement) { - query_selector_raw(element, selector) + case element.query_selector(to_plinth_element(element), selector) { + Ok(found) -> option.Some(from_plinth_element(found)) + Error(Nil) -> option.None + } } // -- FFI -- -@external(javascript, "./dom_ffi.mjs", "dom_focus") -fn focus_raw(element: DomElement) -> Nil +@external(javascript, "./dom_ffi.mjs", "identity") +fn to_plinth_element(element: DomElement) -> element.Element + +@external(javascript, "./dom_ffi.mjs", "identity") +fn from_plinth_element(element: element.Element) -> DomElement -@external(javascript, "./dom_ffi.mjs", "dom_blur") -fn blur_raw(element: DomElement) -> Nil +@external(javascript, "./dom_ffi.mjs", "identity") +fn from_plinth_dom_rect(rectangle: dom_rect.DomRect) -> DomRect @external(javascript, "./dom_ffi.mjs", "dom_click") fn click_raw(element: DomElement) -> Nil @external(javascript, "./dom_ffi.mjs", "dom_scroll_into_view") fn scroll_into_view_raw(element: DomElement) -> Nil - -@external(javascript, "./dom_ffi.mjs", "dom_get_bounding_client_rect") -fn get_bounding_client_rect_raw(element: DomElement) -> DomRect - -@external(javascript, "./dom_ffi.mjs", "dom_query_selector") -fn query_selector_raw( - element: DomElement, - selector: String, -) -> option.Option(DomElement) diff --git a/src/glendix/js/dom_ffi.mjs b/src/glendix/js/dom_ffi.mjs index fa90b59..89cba08 100644 --- a/src/glendix/js/dom_ffi.mjs +++ b/src/glendix/js/dom_ffi.mjs @@ -1,20 +1,12 @@ -import { Some, None } from "../../../gleam_stdlib/gleam/option.mjs"; -export function dom_focus(element) { - element.focus(); -} -export function dom_blur(element) { - element.blur(); +// Glendix keeps its established public handles while Plinth owns the platform +// operations. JavaScript uses the same object for both typed views. +export function identity(value) { + return value; } + export function dom_click(element) { element.click(); } export function dom_scroll_into_view(element) { element.scrollIntoView(); } -export function dom_get_bounding_client_rect(element) { - return element.getBoundingClientRect(); -} -export function dom_query_selector(element, selector) { - const result = element.querySelector(selector); - return result !== null ? new Some(result) : new None(); -} diff --git a/src/glendix/js/timer.gleam b/src/glendix/js/timer.gleam index 62dce52..de0b108 100644 --- a/src/glendix/js/timer.gleam +++ b/src/glendix/js/timer.gleam @@ -1,5 +1,10 @@ -//// Provides typed JavaScript timer handles. +//// Provides typed JavaScript timer handles backed by Plinth. //// +//// Glendix's established timer handle stays public through identity adapters +//// so callers retain the original platform handle at the JavaScript boundary. +//// + +import plinth/javascript/global /// Represents a JavaScript timer handle. pub type TimerId @@ -9,12 +14,13 @@ pub fn set_timeout( callback callback: fn() -> Nil, after milliseconds: Int, ) -> TimerId { - set_timeout_raw(callback, milliseconds) + global.set_timeout(milliseconds, callback) + |> from_plinth_timer } /// Cancels a one-shot timer. pub fn clear_timeout(timer timer: TimerId) -> Nil { - clear_timeout_raw(timer) + global.clear_timeout(to_plinth_timer(timer)) } /// Starts a repeating timer. @@ -22,23 +28,18 @@ pub fn set_interval( callback callback: fn() -> Nil, every milliseconds: Int, ) -> TimerId { - set_interval_raw(callback, milliseconds) + global.set_interval(milliseconds, callback) + |> from_plinth_timer } /// Cancels a repeating timer. pub fn clear_interval(timer timer: TimerId) -> Nil { - clear_interval_raw(timer) + global.clear_interval(to_plinth_timer(timer)) } // -- FFI -- -@external(javascript, "./timer_ffi.mjs", "set_timeout") -fn set_timeout_raw(callback: fn() -> Nil, milliseconds: Int) -> TimerId - -@external(javascript, "./timer_ffi.mjs", "clear_timeout") -fn clear_timeout_raw(timer: TimerId) -> Nil - -@external(javascript, "./timer_ffi.mjs", "set_interval") -fn set_interval_raw(callback: fn() -> Nil, milliseconds: Int) -> TimerId +@external(javascript, "./timer_ffi.mjs", "identity") +fn from_plinth_timer(timer: global.TimerID) -> TimerId -@external(javascript, "./timer_ffi.mjs", "clear_interval") -fn clear_interval_raw(timer: TimerId) -> Nil +@external(javascript, "./timer_ffi.mjs", "identity") +fn to_plinth_timer(timer: TimerId) -> global.TimerID diff --git a/src/glendix/js/timer_ffi.mjs b/src/glendix/js/timer_ffi.mjs index 8f5e475..e588756 100644 --- a/src/glendix/js/timer_ffi.mjs +++ b/src/glendix/js/timer_ffi.mjs @@ -1,12 +1,5 @@ -export function set_timeout(callback, ms) { - return setTimeout(callback, ms); -} -export function clear_timeout(id) { - clearTimeout(id); -} -export function set_interval(callback, ms) { - return setInterval(callback, ms); -} -export function clear_interval(id) { - clearInterval(id); +// Glendix retains its public timer handle while Plinth owns all timer +// operations. JavaScript uses the same platform handle for both typed views. +export function identity(value) { + return value; } diff --git a/test/glendix/js/dom_test.gleam b/test/glendix/js/dom_test.gleam new file mode 100644 index 0000000..6b9780f --- /dev/null +++ b/test/glendix/js/dom_test.gleam @@ -0,0 +1,90 @@ +//// Exercises Glendix DOM operations with deterministic element handles. +//// + +import gleam/option +import gleeunit/should +import glendix/js/dom + +/// Verifies focus delegates to the element's native focus operation. +pub fn focus_invokes_element_method_test() -> Nil { + let element = new_element_fixture() + dom.focus(element) + operation_count(element, "focus") |> should.equal(1) +} + +/// Verifies blur delegates to the element's native blur operation. +pub fn blur_invokes_element_method_test() -> Nil { + let element = new_element_fixture() + dom.blur(element) + operation_count(element, "blur") |> should.equal(1) +} + +/// Verifies the retained click adapter invokes native click behavior. +pub fn click_invokes_element_method_test() -> Nil { + let element = new_element_fixture() + dom.click(element) + operation_count(element, "click") |> should.equal(1) +} + +/// Verifies scrolling retains the native no-options behavior. +pub fn scroll_into_view_uses_platform_defaults_test() -> Nil { + let element = new_element_fixture() + dom.scroll_into_view(element) + operation_count(element, "scroll_into_view") |> should.equal(1) + scroll_argument_count(element) |> should.equal(0) +} + +/// Verifies bounding rectangles preserve the platform rectangle values. +pub fn get_bounding_client_rect_returns_element_rectangle_test() -> Nil { + let element = new_element_fixture() + let rectangle = dom.get_bounding_client_rect(element) + rectangle_x(rectangle) |> should.equal(1.0) + rectangle_y(rectangle) |> should.equal(2.0) + rectangle_width(rectangle) |> should.equal(30.0) + rectangle_height(rectangle) |> should.equal(40.0) +} + +/// Verifies query selector converts a Plinth hit to `Some`. +pub fn query_selector_hit_returns_some_test() -> Nil { + let element = new_element_fixture() + + case dom.query_selector(in: element, matching: ".child") { + option.Some(child) -> is_fixture_child(element, child) |> should.be_true + option.None -> should.fail() + } +} + +/// Verifies query selector converts a Plinth miss to `None`. +pub fn query_selector_miss_returns_none_test() -> Nil { + let element = new_element_fixture() + + case dom.query_selector(in: element, matching: ".missing") { + option.None -> Nil + option.Some(_) -> should.fail() + } +} + +// -- FFI -- +@external(javascript, "./dom_test_ffi.mjs", "new_element_fixture") +fn new_element_fixture() -> dom.DomElement + +@external(javascript, "./dom_test_ffi.mjs", "operation_count") +fn operation_count(element: dom.DomElement, operation: String) -> Int + +@external(javascript, "./dom_test_ffi.mjs", "scroll_argument_count") +fn scroll_argument_count(element: dom.DomElement) -> Int + +@external(javascript, "./dom_test_ffi.mjs", "is_fixture_child") +fn is_fixture_child(element: dom.DomElement, candidate: dom.DomElement) -> Bool + +@external(javascript, "./dom_test_ffi.mjs", "rectangle_x") +fn rectangle_x(rectangle: dom.DomRect) -> Float + +@external(javascript, "./dom_test_ffi.mjs", "rectangle_y") +fn rectangle_y(rectangle: dom.DomRect) -> Float + +@external(javascript, "./dom_test_ffi.mjs", "rectangle_width") +fn rectangle_width(rectangle: dom.DomRect) -> Float + +@external(javascript, "./dom_test_ffi.mjs", "rectangle_height") +fn rectangle_height(rectangle: dom.DomRect) -> Float diff --git a/test/glendix/js/dom_test_ffi.mjs b/test/glendix/js/dom_test_ffi.mjs new file mode 100644 index 0000000..c2dcb37 --- /dev/null +++ b/test/glendix/js/dom_test_ffi.mjs @@ -0,0 +1,65 @@ +export function new_element_fixture() { + const child = { + operations: { click: 0 }, + click() { + this.operations.click += 1; + }, + }; + return { + child, + operations: { + blur: 0, + click: 0, + focus: 0, + scroll_into_view: 0, + }, + scrollArgumentCount: -1, + focus() { + this.operations.focus += 1; + }, + blur() { + this.operations.blur += 1; + }, + click() { + this.operations.click += 1; + }, + scrollIntoView(...arguments_) { + this.operations.scroll_into_view += 1; + this.scrollArgumentCount = arguments_.length; + }, + getBoundingClientRect() { + return { x: 1, y: 2, width: 30, height: 40 }; + }, + querySelector(selector) { + return selector === ".child" ? this.child : null; + }, + }; +} + +export function operation_count(element, operation) { + return element.operations[operation]; +} + +export function scroll_argument_count(element) { + return element.scrollArgumentCount; +} + +export function is_fixture_child(element, candidate) { + return element.child === candidate; +} + +export function rectangle_x(rectangle) { + return rectangle.x; +} + +export function rectangle_y(rectangle) { + return rectangle.y; +} + +export function rectangle_width(rectangle) { + return rectangle.width; +} + +export function rectangle_height(rectangle) { + return rectangle.height; +} diff --git a/test/glendix/js/timer_test.gleam b/test/glendix/js/timer_test.gleam new file mode 100644 index 0000000..efc4924 --- /dev/null +++ b/test/glendix/js/timer_test.gleam @@ -0,0 +1,67 @@ +//// Exercises the public timer API against the JavaScript global timer queue. +//// + +import gleam/javascript/promise +import gleeunit/should +import glendix/js/timer + +/// Verifies a one-shot timer invokes its callback exactly once. +pub fn timeout_callback_runs_once_test() -> promise.Promise(Nil) { + let counter = new_counter() + let timer_id = + timer.set_timeout(callback: fn() { increment_counter(counter) }, after: 0) + + promise.wait(20) + |> promise.map(fn(_) { + case timer_handle_is_defined(timer_id) { + True -> counter_value(counter) |> should.equal(1) + False -> should.fail() + } + }) +} + +/// Verifies clearing a one-shot timer prevents its callback. +pub fn clear_timeout_prevents_callback_test() -> promise.Promise(Nil) { + let counter = new_counter() + let timer_id = + timer.set_timeout(callback: fn() { increment_counter(counter) }, after: 10) + timer.clear_timeout(timer_id) + + promise.wait(30) + |> promise.map(fn(_) { counter_value(counter) |> should.equal(0) }) +} + +/// Verifies an interval repeats and clearing it prevents later callbacks. +pub fn clear_interval_stops_repeating_callback_test() -> promise.Promise(Nil) { + let counter = new_counter() + let timer_id = + timer.set_interval(callback: fn() { increment_counter(counter) }, every: 1) + + use _ <- promise.await(promise.wait(20)) + timer.clear_interval(timer_id) + let count_after_clear = counter_value(counter) + promise.wait(20) + |> promise.map(fn(_) { + case count_after_clear > 0 { + True -> + counter_value(counter) + |> should.equal(count_after_clear) + False -> should.fail() + } + }) +} + +type Counter + +// -- FFI -- +@external(javascript, "./timer_test_ffi.mjs", "new_counter") +fn new_counter() -> Counter + +@external(javascript, "./timer_test_ffi.mjs", "increment_counter") +fn increment_counter(counter: Counter) -> Nil + +@external(javascript, "./timer_test_ffi.mjs", "counter_value") +fn counter_value(counter: Counter) -> Int + +@external(javascript, "./timer_test_ffi.mjs", "timer_handle_is_defined") +fn timer_handle_is_defined(timer_id: timer.TimerId) -> Bool diff --git a/test/glendix/js/timer_test_ffi.mjs b/test/glendix/js/timer_test_ffi.mjs new file mode 100644 index 0000000..9972fa2 --- /dev/null +++ b/test/glendix/js/timer_test_ffi.mjs @@ -0,0 +1,15 @@ +export function new_counter() { + return { value: 0 }; +} + +export function increment_counter(counter) { + counter.value += 1; +} + +export function counter_value(counter) { + return counter.value; +} + +export function timer_handle_is_defined(timerId) { + return timerId !== null && timerId !== undefined; +}