LibWeb+UI/AppKit: Implement macOS IME support

This makes macOS IME input in web content work as expected.

Fixes https://github.com/LadybirdBrowser/ladybird/issues/9712
This commit is contained in:
sideshowbarker 2026-05-28 23:48:50 +09:00 committed by sideshowbarker
parent 42a6253d64
commit a82c7939d9
18 changed files with 511 additions and 10 deletions

View file

@ -8384,6 +8384,63 @@ GC::Ptr<DOM::Position> Document::cursor_position() const
return nullptr;
}
Optional<CSSPixelRect> Document::current_caret_rect()
{
// Returns the bounds of the current text caret in viewport-relative CSS pixels. Used to position platform overlays
// such as the IME candidate window. Returns nothing when no editable element is focused or when layout isn't ready.
auto position = cursor_position();
if (!position)
return {};
auto& dom_node = *position->node();
update_layout(UpdateLayoutReason::InputCaretRect);
auto* layout_node = dom_node.layout_node();
if (!layout_node)
return {};
// The caret rects computed here are document-relative (absolute). Platform IME overlays are positioned relative to
// the viewport — so translate by scroll offset and map through any containing navigables to the top-level viewport.
auto to_viewport_rect = [this](CSSPixelRect rect) -> CSSPixelRect {
auto navigable = this->navigable();
if (!navigable)
return rect;
auto scroll = navigable->viewport_scroll_offset();
CSSPixelRect viewport_rect { rect.x() - scroll.x(), rect.y() - scroll.y(), rect.width(), rect.height() };
return navigable->to_top_level_rect(viewport_rect);
};
// Walk up to the nearest PaintableWithLines, which is where text fragments live.
Painting::PaintableWithLines const* paintable_with_lines = nullptr;
for (auto paintable = layout_node->first_paintable(); paintable; paintable = paintable->parent()) {
if (auto const* with_lines = as_if<Painting::PaintableWithLines>(*paintable)) {
paintable_with_lines = with_lines;
break;
}
}
if (paintable_with_lines) {
for (auto const& fragment : paintable_with_lines->fragments()) {
if (fragment.layout_node().dom_node() != &dom_node)
continue;
auto const offset = position->offset();
if (offset < fragment.dom_start_offset_in_node() || offset > fragment.dom_end_offset_in_node())
continue;
return to_viewport_rect(fragment.range_rect(Painting::Paintable::SelectionState::StartAndEnd, offset, offset));
}
}
// Empty editable elements have no fragments; fall back to the padding-box corner.
if (auto* node_with_style = as_if<Layout::NodeWithStyleAndBoxModelMetrics>(*layout_node)) {
auto paintable = node_with_style->first_paintable();
if (auto const* box = as_if<Painting::PaintableBox>(paintable.ptr())) {
auto content_box = box->absolute_padding_box_rect();
return to_viewport_rect(CSSPixelRect { content_box.x(), content_box.y(), 1, node_with_style->computed_values().line_height() });
}
}
return {};
}
void Document::reset_cursor_blink_cycle()
{
m_cursor_blink_state = true;

View file

@ -134,6 +134,7 @@ enum class InvalidateLayoutTreeReason {
X(InspectAccessibilityTree) \
X(InspectDOMTree) \
X(InspectDevToolsLayoutData) \
X(InputCaretRect) \
X(InternalsHitTest) \
X(MediaQueryListMatches) \
X(NavigableSelectedText) \
@ -979,6 +980,7 @@ public:
InputEventsTarget* active_input_events_target(DOM::Node const* for_node = nullptr);
GC::Ptr<DOM::Position> cursor_position() const;
void set_cursor_position_needs_repaint();
Optional<CSSPixelRect> current_caret_rect();
bool cursor_blink_state() const { return m_cursor_blink_state; }

View file

@ -22,6 +22,7 @@
#include <LibWeb/DOM/DocumentLoading.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/DOM/Event.h>
#include <LibWeb/DOM/Position.h>
#include <LibWeb/DOM/Range.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/Fetch/Fetching/Fetching.h>
@ -327,6 +328,7 @@ void Navigable::visit_edges(Cell::Visitor& visitor)
visitor.visit(m_page);
visitor.visit(m_parent);
visitor.visit(m_active_document);
visitor.visit(m_input_method_composition_node);
visitor.visit(m_container);
m_event_handler.visit_edges(visitor);
@ -3353,6 +3355,75 @@ void Navigable::paste(Utf16String const& text)
m_event_handler.handle_paste(text);
}
void Navigable::set_marked_text_from_input_method(Utf16String const& text)
{
// Platform input methods call this on each composition update, with the current marked/preedit text. LibWeb owns
// the marked-text range so each update replaces the previously-marked text. The UI doesn't track the preedit
// extent or pass a replacement length. An empty marked string means there's no preedit: so, clear any text marked
// thus far, and end the composition — rather than starting or keeping a composition that has no marked text.
if (text.is_empty()) {
if (m_input_method_composition_node)
replace_input_method_marked_text(text);
m_input_method_composition_node = nullptr;
return;
}
replace_input_method_marked_text(text);
}
void Navigable::commit_text_from_input_method(Utf16String const& text)
{
// The input method has committed text and finished the composition. Replace the marked text with the committed
// text, then end the composition — so the text becomes ordinary editable content.
replace_input_method_marked_text(text);
m_input_method_composition_node = nullptr;
}
void Navigable::unmark_text_from_input_method()
{
// The input method has finished the composition — leaving the current marked text in place. End the composition
// without altering the content.
m_input_method_composition_node = nullptr;
}
void Navigable::replace_input_method_marked_text(Utf16String const& text)
{
// Insert text from a platform input method into the currently-focused editable, via the same input-events target
// that keyboard typing uses — so observers see the correct InputEvent.inputType.
auto document = active_document();
if (!document || !document->is_fully_active()) {
m_input_method_composition_node = nullptr;
return;
}
auto* target = document->active_input_events_target();
if (!target) {
m_input_method_composition_node = nullptr;
return;
}
// Drop a stale composition start (for example, if the editable content was replaced out from under us).
if (m_input_method_composition_node && !m_input_method_composition_node->is_connected())
m_input_method_composition_node = nullptr;
// The caret is the end of the marked text. Read it while the selection is still collapsed. Forming the marked-text
// selection below would otherwise make cursor_position() return null for form controls.
auto caret = document->cursor_position();
if (!caret)
return;
if (m_input_method_composition_node) {
// A composition is already in progress. Select the existing marked text [composition start, caret] — so that
// the insertion below replaces it.
target->set_selection_anchor(*m_input_method_composition_node, m_input_method_composition_offset);
target->set_selection_focus(caret->node(), caret->offset());
} else {
// Begin a new composition at the caret. The marked text spans from here to the caret as it is updated.
m_input_method_composition_node = caret->node();
m_input_method_composition_offset = caret->offset();
}
target->handle_insert(UIEvents::InputTypes::insertText, text);
}
// https://drafts.csswg.org/css-view-transitions-1/#snapshot-containing-block
CSSPixelRect Navigable::snapshot_containing_block()
{

View file

@ -220,6 +220,9 @@ public:
String cut_selected_text() const;
void select_all();
void paste(Utf16String const&);
void set_marked_text_from_input_method(Utf16String const& text);
void commit_text_from_input_method(Utf16String const& text);
void unmark_text_from_input_method();
Web::EventHandler& event_handler() { return m_event_handler; }
Web::EventHandler const& event_handler() const { return m_event_handler; }
@ -322,6 +325,13 @@ private:
// This is the authoritative source for active_document().
GC::Ptr<DOM::Document> m_active_document;
// AD-HOC: Active IME composition state. While a composition is in progress, m_input_method_composition_node and
// m_input_method_composition_offset record the start of the marked (preedit) text; the marked text spans
// from there to the caret. A null node means no composition is in progress.
void replace_input_method_marked_text(Utf16String const& text);
GC::Ptr<DOM::Node> m_input_method_composition_node;
size_t m_input_method_composition_offset { 0 };
// https://html.spec.whatwg.org/multipage/document-sequences.html#is-closing
bool m_closing { false };

View file

@ -33,6 +33,7 @@
#include <LibWeb/DOMURL/DOMURL.h>
#include <LibWeb/Dump.h>
#include <LibWeb/Fetch/Fetching/Fetching.h>
#include <LibWeb/Geometry/DOMRect.h>
#include <LibWeb/HTML/BrowsingContext.h>
#include <LibWeb/HTML/EventLoop/EventLoop.h>
#include <LibWeb/HTML/EventLoop/TaskQueue.h>
@ -418,6 +419,30 @@ String Internals::selected_text_for_clipboard()
return page().focused_navigable().selected_text();
}
void Internals::set_marked_text_from_input_method(Utf16String const& text)
{
page().focused_navigable().set_marked_text_from_input_method(text);
}
void Internals::commit_text_from_input_method(Utf16String const& text)
{
page().focused_navigable().commit_text_from_input_method(text);
}
void Internals::unmark_text_from_input_method()
{
page().focused_navigable().unmark_text_from_input_method();
}
GC::Ptr<Geometry::DOMRect> Internals::current_caret_rect()
{
auto& active_document = window().associated_document();
auto rect = active_document.current_caret_rect();
if (!rect.has_value())
return nullptr;
return MUST(Geometry::DOMRect::construct_impl(realm(), static_cast<double>(rect->x()), static_cast<double>(rect->y()), static_cast<double>(rect->width()), static_cast<double>(rect->height())));
}
WebIDL::ExceptionOr<bool> Internals::dispatch_user_activated_event(DOM::EventTarget& target, DOM::Event& event)
{
event.set_is_trusted(true);

View file

@ -7,17 +7,12 @@
#pragma once
#include <LibWeb/Export.h>
#include <LibWeb/Forward.h>
#include <LibWeb/Internals/InternalAnimationTimeline.h>
#include <LibWeb/Internals/InternalsBase.h>
#include <LibWeb/UIEvents/MouseButton.h>
#include <LibWeb/WebIDL/Types.h>
namespace Web::CSS {
class CSSStyleSheet;
}
namespace Web::Internals {
class WEB_API Internals final : public InternalsBase {
@ -72,6 +67,11 @@ public:
String selected_text_for_clipboard();
void set_marked_text_from_input_method(Utf16String const& text);
void commit_text_from_input_method(Utf16String const& text);
void unmark_text_from_input_method();
GC::Ptr<Geometry::DOMRect> current_caret_rect();
WebIDL::ExceptionOr<bool> dispatch_user_activated_event(DOM::EventTarget&, DOM::Event& event);
void spoof_current_url(String const& url);

View file

@ -49,6 +49,18 @@ interface Internals {
// Excludes nodes whose used value of user-select is 'none' — mirroring what browsers actually copy.
DOMString selectedTextForClipboard();
// Drive marked/preedit-text composition via the same path platform input methods use. setMarkedTextFromInputMethod
// updates the inline preedit — replacing any previous preedit. commitText finalizes the composition with the given
// text. unmarkText finalizes the composition — leaving the current preedit in place. Each fires an InputEvent with
// inputType = "insertText" on the focused editable. All are no-ops if nothing editable is focused.
undefined setMarkedTextFromInputMethod(Utf16DOMString text);
undefined commitTextFromInputMethod(Utf16DOMString text);
undefined unmarkTextFromInputMethod();
// Returns the bounds of the current text caret in viewport-relative CSS pixels — or null if no editable element is
// focused. Used for verifying the data the platform UI feeds into IME overlay positioning.
DOMRect? currentCaretRect();
boolean dispatchUserActivatedEvent(EventTarget target, Event event);
undefined spoofCurrentURL(USVString url);

View file

@ -791,6 +791,34 @@ void ViewImplementation::paste_text_from_clipboard()
client().async_paste(page_id(), Application::the().clipboard_text());
}
void ViewImplementation::set_marked_text_from_input_method(Utf16String const& text)
{
client().async_set_marked_text_from_input_method(page_id(), text);
}
void ViewImplementation::commit_text_from_input_method(Utf16String const& text)
{
client().async_commit_text_from_input_method(page_id(), text);
}
void ViewImplementation::unmark_text_from_input_method()
{
client().async_unmark_text_from_input_method(page_id());
}
Optional<Web::DevicePixelRect> ViewImplementation::get_input_caret_rect()
{
// Returns the most-recent caret position pushed by WebContent (see set_input_caret_rect). Deliberately makes no
// synchronous IPC request: This is read from inside AppKit text-input callbacks — where blocking can re-enter the
// run loop and deadlock the input method.
return m_input_caret_rect;
}
void ViewImplementation::set_input_caret_rect(Badge<WebContentClient>, Optional<Web::DevicePixelRect> rect)
{
m_input_caret_rect = rect;
}
void ViewImplementation::retrieved_clipboard_entries(u64 request_id, ReadonlySpan<Web::Clipboard::SystemClipboardItem> items)
{
client().async_retrieved_clipboard_entries(page_id(), request_id, items);

View file

@ -183,6 +183,14 @@ public:
void paste_text_from_clipboard();
void retrieved_clipboard_entries(u64 request_id, ReadonlySpan<Web::Clipboard::SystemClipboardItem>);
// Used by platform input methods to drive marked/preedit-text composition, and to query the on-screen caret
// position for placing IME overlays.
void set_marked_text_from_input_method(Utf16String const& text);
void commit_text_from_input_method(Utf16String const& text);
void unmark_text_from_input_method();
Optional<Web::DevicePixelRect> get_input_caret_rect();
void set_input_caret_rect(Badge<WebContentClient>, Optional<Web::DevicePixelRect>);
Web::HTML::MuteState page_mute_state() const { return m_mute_state; }
void toggle_page_mute_state();
@ -444,6 +452,9 @@ protected:
Web::HTML::MuteState m_mute_state { Web::HTML::MuteState::Unmuted };
// Most recent caret position pushed by WebContent, Used for placing platform IME overlays without a sync IPC.
Optional<Web::DevicePixelRect> m_input_caret_rect;
Web::ViewportIsFullscreen m_is_fullscreen { Web::ViewportIsFullscreen::No };
Core::AnonymousBuffer m_document_cookie_version_buffer;

View file

@ -1142,6 +1142,12 @@ void WebContentClient::did_finish_handling_input_event(u64 page_id, Web::EventRe
view->did_finish_handling_input_event({}, event_result);
}
void WebContentClient::did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect)
{
if (auto view = view_for_page_id(page_id); view.has_value())
view->set_input_caret_rect({}, rect);
}
void WebContentClient::did_change_theme_color(u64 page_id, Gfx::Color color)
{
if (auto view = view_for_page_id(page_id); view.has_value()) {

View file

@ -177,6 +177,7 @@ private:
virtual void did_request_file_picker(u64 page_id, Web::HTML::FileFilter accepted_file_types, Web::HTML::AllowMultipleFiles) override;
virtual void did_request_select_dropdown(u64 page_id, Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items) override;
virtual void did_finish_handling_input_event(u64 page_id, Web::EventResult event_result) override;
virtual void did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect) override;
virtual void did_finish_test(u64 page_id, String text) override;
virtual void did_set_test_timeout(u64 page_id, double milliseconds) override;
virtual void did_receive_reference_test_metadata(u64 page_id, JsonValue) override;

View file

@ -1683,6 +1683,43 @@ void ConnectionFromClient::paste(u64 page_id, Utf16String text)
page->page().focused_navigable().paste(text);
}
void ConnectionFromClient::set_marked_text_from_input_method(u64 page_id, Utf16String text)
{
if (auto page = this->page(page_id); page.has_value())
page->page().focused_navigable().set_marked_text_from_input_method(text);
update_input_method_caret_rect(page_id);
}
void ConnectionFromClient::commit_text_from_input_method(u64 page_id, Utf16String text)
{
if (auto page = this->page(page_id); page.has_value())
page->page().focused_navigable().commit_text_from_input_method(text);
update_input_method_caret_rect(page_id);
}
void ConnectionFromClient::unmark_text_from_input_method(u64 page_id)
{
if (auto page = this->page(page_id); page.has_value())
page->page().focused_navigable().unmark_text_from_input_method();
}
void ConnectionFromClient::update_input_method_caret_rect(u64 page_id)
{
auto page = this->page(page_id);
if (!page.has_value())
return;
// Push the updated caret position to the UI — so platform input methods can place their overlays. We deliberately
// push this asynchronously — rather than answering a synchronous request from inside an AppKit text-input callback.
// Blocking there re-enters the run loop — and can deadlock input-method server <-> UI <-> WebContent message flow.
Optional<Web::DevicePixelRect> caret_rect;
if (auto document = page->page().focused_navigable().active_document()) {
if (auto rect = document->current_caret_rect(); rect.has_value())
caret_rect = page->page().enclosing_device_rect(*rect);
}
async_did_update_input_caret_rect(page_id, caret_rect);
}
void ConnectionFromClient::set_content_blockers(u64 page_id, Core::AnonymousBuffer patterns_buffer)
{
auto& blocker = Web::ContentBlocker::the();

View file

@ -182,6 +182,10 @@ private:
virtual void find_in_page_previous_match(u64 page_id) override;
virtual void paste(u64 page_id, Utf16String text) override;
virtual void set_marked_text_from_input_method(u64 page_id, Utf16String text) override;
virtual void commit_text_from_input_method(u64 page_id, Utf16String text) override;
virtual void unmark_text_from_input_method(u64 page_id) override;
void update_input_method_caret_rect(u64 page_id);
virtual void system_time_zone_changed() override;

View file

@ -123,6 +123,7 @@ endpoint WebContentClient
did_request_file_picker(u64 page_id, Web::HTML::FileFilter accepted_file_types, Web::HTML::AllowMultipleFiles allow_multiple_files) =|
did_request_select_dropdown(u64 page_id, Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items) =|
did_finish_handling_input_event(u64 page_id, Web::EventResult event_result) =|
did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect) =|
did_change_theme_color(u64 page_id, Gfx::Color color) =|
did_change_background_color(u64 page_id, Gfx::Color color) =|

View file

@ -103,6 +103,9 @@ endpoint WebContentServer
cut_selected_text(u64 page_id) => (ByteString selection)
select_all(u64 page_id) =|
paste(u64 page_id, Utf16String text) =|
set_marked_text_from_input_method(u64 page_id, Utf16String text) =|
commit_text_from_input_method(u64 page_id, Utf16String text) =|
unmark_text_from_input_method(u64 page_id) =|
find_in_page(u64 page_id, String query, AK::CaseSensitivity case_sensitivity) =|
find_in_page_next_match(u64 page_id) =|

View file

@ -0,0 +1,22 @@
input.value after IME commit: "abcdef"
input event on <input>: {"inputType":"insertText","data":"def"}
textarea.value after IME commit: "xyzyyy"
input event on <textarea>: {"inputType":"insertText","data":"yyy"}
editable.textContent after IME commit: "qwerty"
input event on contenteditable: {"inputType":"insertText","data":"rty"}
unfocused commit threw: false
readonly value unchanged: true
focused-input caret rect non-null: true
focused-input caret rect has positive height: true
unfocused caret rect: null
empty-input caret rect non-null: true
empty-input caret rect has positive height: true
after first preedit keystroke: "n"
after extending preedit: "ni"
after candidate replaces preedit: "你"
after commit: "你"
after second composition commit: "你好"
after unmark keeps preedit: "你好?"
after abort clears preedit: "你好?"
after abort, fresh preedit at moved caret: "你z好?"
caret rect is viewport-relative (moves with scroll): true

View file

@ -0,0 +1,141 @@
<!DOCTYPE html>
<script src="include.js"></script>
<input id="input" value="abc">
<textarea id="textarea">xyz</textarea>
<div id="editable" contenteditable="true">qwe</div>
<input id="readonly_input" value="ro" readonly>
<input id="preedit_input" value="">
<script>
asyncTest(async done => {
// Spec for <input>/<textarea> fires 'input' via a queued element task. Wait for it.
const waitForEvent = (target, type) => new Promise(resolve => {
target.addEventListener(type, e => resolve({ inputType: e.inputType, data: e.data }), { once: true });
});
// <input>: committed IME text is inserted via commit and eventually fires an input event.
const input = document.getElementById("input");
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
let event_promise = waitForEvent(input, "input");
internals.commitTextFromInputMethod("def");
let event = await event_promise;
println(`input.value after IME commit: ${JSON.stringify(input.value)}`);
println(`input event on <input>: ${JSON.stringify(event)}`);
// <textarea>: same path.
const textarea = document.getElementById("textarea");
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
event_promise = waitForEvent(textarea, "input");
internals.commitTextFromInputMethod("yyy");
event = await event_promise;
println(`textarea.value after IME commit: ${JSON.stringify(textarea.value)}`);
println(`input event on <textarea>: ${JSON.stringify(event)}`);
// contenteditable: fires input synchronously through the editing pipeline.
const editable = document.getElementById("editable");
editable.focus();
getSelection().selectAllChildren(editable);
getSelection().collapseToEnd();
const collected_editable_events = [];
editable.addEventListener("input", e => collected_editable_events.push({ inputType: e.inputType, data: e.data }), { once: true });
internals.commitTextFromInputMethod("rty");
println(`editable.textContent after IME commit: ${JSON.stringify(editable.textContent)}`);
println(`input event on contenteditable: ${JSON.stringify(collected_editable_events[0])}`);
// No focus on any editable: must be a no-op (no exception, no event).
document.activeElement?.blur();
let no_focus_threw = false;
try {
internals.commitTextFromInputMethod("ignored");
} catch (e) {
no_focus_threw = true;
}
println(`unfocused commit threw: ${no_focus_threw}`);
// <input readonly>: not editable, IME commit must not change the value.
const ro = document.getElementById("readonly_input");
ro.focus();
internals.commitTextFromInputMethod("nope");
println(`readonly value unchanged: ${ro.value === "ro"}`);
// currentCaretRect returns a sensible rect on a focused editable, null otherwise.
input.focus();
input.setSelectionRange(0, 0);
const rect = internals.currentCaretRect();
println(`focused-input caret rect non-null: ${rect !== null}`);
println(`focused-input caret rect has positive height: ${rect && rect.height > 0}`);
document.activeElement?.blur();
const no_focus_rect = internals.currentCaretRect();
println(`unfocused caret rect: ${no_focus_rect === null ? "null" : "non-null"}`);
// Empty editable: the caret rect must still be sensible.
const empty = document.getElementById("preedit_input"); // value is ""
empty.focus();
empty.setSelectionRange(0, 0);
const empty_rect = internals.currentCaretRect();
println(`empty-input caret rect non-null: ${empty_rect !== null}`);
println(`empty-input caret rect has positive height: ${empty_rect && empty_rect.height > 0}`);
// Marked-text (preedit) composition. LibWeb owns the marked-text range: each setMarkedText replaces the
// previous preedit, commitText finalizes it, and unmarkText finalizes leaving the preedit in place.
const preedit = document.getElementById("preedit_input");
preedit.value = "";
preedit.focus();
preedit.setSelectionRange(0, 0);
// Build a preedit one character at a time; each update replaces the previous marked text.
internals.setMarkedTextFromInputMethod("n");
println(`after first preedit keystroke: ${JSON.stringify(preedit.value)}`);
internals.setMarkedTextFromInputMethod("ni");
println(`after extending preedit: ${JSON.stringify(preedit.value)}`);
internals.setMarkedTextFromInputMethod("你");
println(`after candidate replaces preedit: ${JSON.stringify(preedit.value)}`);
// Commit the candidate; the composition ends.
internals.commitTextFromInputMethod("你");
println(`after commit: ${JSON.stringify(preedit.value)}`);
// A fresh composition appends after the committed text.
internals.setMarkedTextFromInputMethod("h");
internals.setMarkedTextFromInputMethod("ha");
internals.commitTextFromInputMethod("好");
println(`after second composition commit: ${JSON.stringify(preedit.value)}`);
// unmarkText finalizes the current preedit in place (keeps it).
internals.setMarkedTextFromInputMethod("?");
internals.unmarkTextFromInputMethod();
println(`after unmark keeps preedit: ${JSON.stringify(preedit.value)}`);
// Aborting a composition: the IME clears the preedit with an empty marked text.
internals.setMarkedTextFromInputMethod("xx");
internals.setMarkedTextFromInputMethod("");
println(`after abort clears preedit: ${JSON.stringify(preedit.value)}`);
// After an abort, a fresh composition must start at the current caret — not reuse the old marked-text start.
preedit.setSelectionRange(1, 1);
internals.setMarkedTextFromInputMethod("z");
println(`after abort, fresh preedit at moved caret: ${JSON.stringify(preedit.value)}`);
// #5: currentCaretRect is viewport-relative, so scrolling the page moves the caret rect by the scroll delta.
const tall = document.createElement("div");
tall.style.height = "3000px";
document.body.appendChild(tall);
const scrolled = document.createElement("input");
scrolled.value = "scroll";
document.body.appendChild(scrolled);
scrolled.focus();
scrolled.setSelectionRange(0, 0);
window.scrollTo(0, 0);
const caret_at_0 = internals.currentCaretRect();
window.scrollTo(0, 400);
const scroll_y = window.scrollY;
const caret_scrolled = internals.currentCaretRect();
window.scrollTo(0, 0);
const moved = (caret_at_0 && caret_scrolled) ? caret_at_0.y - caret_scrolled.y : null;
println(`caret rect is viewport-relative (moves with scroll): ${moved !== null && scroll_y > 0 && Math.abs(moved - scroll_y) < 1}`);
done();
});
</script>

View file

@ -120,6 +120,10 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
// To handle key events after dead key processing, we need to hold onto the originating key-down event.
@property (nonatomic, strong) NSEvent* current_key_down_event;
// Length of the marked text (input-method preedit) currently shown in the focused editable. LibWeb owns the marked-text
// range and replaces the preedit itself. The UI keeps this length only to answer the NSTextInputClient range queries.
@property (nonatomic, assign) NSUInteger marked_text_length;
@end
@implementation LadybirdWebView
@ -135,6 +139,11 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (instancetype)initAsChild:(id<LadybirdWebViewObserver>)observer
parent:(LadybirdWebView*)parent
pageIndex:(u64)page_index
@ -174,6 +183,11 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
m_web_view_bridge = MUST(Ladybird::WebViewBridge::create(move(screen_rects), device_pixel_ratio, maximum_frames_per_second, display_id));
[self setWebViewCallbacks];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(inputSourceDidChange:)
name:NSTextInputContextKeyboardSelectionDidChangeNotification
object:nil];
self.page_context_menu = Ladybird::create_context_menu(self, [self view].page_context_menu());
self.link_context_menu = Ladybird::create_context_menu(self, [self view].link_context_menu());
self.image_context_menu = Ladybird::create_context_menu(self, [self view].image_context_menu());
@ -1396,6 +1410,23 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
- (void)insertText:(id)string replacementRange:(NSRange)replacementRange
{
// macOS calls this for both regular typing (the typed character routed through interpretKeyEvents:) and for CJK
// input methods committing a composition.
//
// For regular typing, the original NSEvent in current_key_down_event has the same character. Forward the NSEvent —
// so JS sees keydown/keypress/input events (the existing path). For IME commits, the committed text differs from
// the trigger key (or there is no current event) — so, route the committed text directly into the focused element.
NSString* committed = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] : (NSString*)string;
NSEvent* event = self.current_key_down_event;
bool matches_current_key_event = event && committed.length > 0 && [committed isEqualToString:event.characters];
bool has_marked_text = self.marked_text_length > 0;
if ((!matches_current_key_event && committed.length > 0) || has_marked_text) {
auto utf8 = ByteString { committed.length > 0 ? [committed UTF8String] : "" };
m_web_view_bridge->commit_text_from_input_method(Utf16String::from_utf8(utf8));
self.marked_text_length = 0;
self.current_key_down_event = nil;
return;
}
[self handleCurrentKeyDownEvent:YES];
}
@ -1406,25 +1437,49 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
- (BOOL)hasMarkedText
{
return NO;
return self.marked_text_length > 0;
}
- (NSRange)markedRange
{
return NSMakeRange(NSNotFound, 0);
return self.marked_text_length > 0 ? NSMakeRange(0, self.marked_text_length) : NSMakeRange(NSNotFound, 0);
}
- (NSRange)selectedRange
{
return NSMakeRange(NSNotFound, 0);
// We present the input method with a virtual text model whose marked text occupies [0, marked_text_length) with
// the caret at its end; keep selectedRange consistent with markedRange, so the IME's range bookkeeping is coherent.
return NSMakeRange(self.marked_text_length, 0);
}
- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange
{
// Called by the input method as the user types each composing character. We present inline preedit by inserting the
// marked text into the focused editable. LibWeb owns the marked-text range and replaces the previous preedit on
// each subsequent setMarkedText, or on commit (insertText) or abort (unmarkText). We keep marked_text_length only
// to answer the NSTextInputClient range queries (markedRange/selectedRange/hasMarkedText).
NSString* preedit = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] : (NSString*)string;
auto utf8 = ByteString { preedit.length > 0 ? [preedit UTF8String] : "" };
m_web_view_bridge->set_marked_text_from_input_method(Utf16String::from_utf8(utf8));
self.marked_text_length = preedit.length;
}
- (void)unmarkText
{
// Per the NSTextInputClient contract, unmarkText finalizes (commits) the marked text in place. Tell LibWeb to end
// the composition — keeping the inserted text, and forgetting our marked-text length.
m_web_view_bridge->unmark_text_from_input_method();
self.marked_text_length = 0;
}
- (void)inputSourceDidChange:(NSNotification*)notification
{
// When the keyboard input source changes mid-composition, the input method may not send a commit/abort callback for
// our pending preedit. Finalize it in place: end the composition in LibWeb (keeping the text), and forget the
// marked-text length — so a later composition doesn't replace the wrong characters.
(void)notification;
m_web_view_bridge->unmark_text_from_input_method();
self.marked_text_length = 0;
}
- (NSArray<NSAttributedStringKey>*)validAttributesForMarkedText
@ -1444,7 +1499,22 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange
{
return NSZeroRect;
// Tells macOS where to anchor IME overlays (candidate window, accent menu, etc.). Otherwise, without this, the
// overlays appear in the bottom-left corner of the screen — since NSZeroRect anchors at screen origin.
auto caret_rect = m_web_view_bridge->get_input_caret_rect();
if (!caret_rect.has_value())
return NSZeroRect;
auto dpr = m_web_view_bridge->device_pixel_ratio();
NSRect view_rect = NSMakeRect(
caret_rect->x().value() / dpr,
caret_rect->y().value() / dpr,
caret_rect->width().value() / dpr,
caret_rect->height().value() / dpr);
// Convert: view coords (flipped, top-left origin) → window coords → screen coords.
NSRect window_rect = [self convertRect:view_rect toView:nil];
return [[self window] convertRectToScreen:window_rect];
}
#pragma mark - NSDraggingDestination