Skip to content

script: Add a Rope type and use it for TextInput#41650

Merged
mrobinson merged 1 commit intoservo:mainfrom
mrobinson:rope
Jan 6, 2026
Merged

script: Add a Rope type and use it for TextInput#41650
mrobinson merged 1 commit intoservo:mainfrom
mrobinson:rope

Conversation

@mrobinson
Copy link
Copy Markdown
Member

@mrobinson mrobinson commented Jan 3, 2026

TextInput internally stores a rope of text content, one segment for
each line. This change separates out the rope into a separate Rope
data structure that can be shared with other parts of Servo.

TextInput needs to move through text content in a variety of ways,
depending on the keys pressed when interacting with a text area. This
change provides a unified movement API in both Rope and in
TextInput ensuring that selection is handled consistently (apart from
a few minor quirks 1). This simplifies the code an improves
interactive behavior.

Testing: This is covered by existing unit tests (updated for the new API) and
the WPT suite.

Footnotes

  1. One of these quirks is that the edit point will move to
    directional end of the motion when collapsing a selection, but only when
    moving by grapheme or word (and not by line). These quirks existed in an
    undocumented way previously and they are preserved with code comments.

@mrobinson mrobinson requested a review from gterzian as a code owner January 3, 2026 12:18
@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Jan 3, 2026
@mrobinson mrobinson added the T-linux-wpt Do a try run of the WPT label Jan 3, 2026
@github-actions github-actions bot removed the T-linux-wpt Do a try run of the WPT label Jan 3, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 3, 2026

🔨 Triggering try run (#20677166239) for Linux (WPT)

@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 3, 2026

Test results for linux-wpt from try job (#20677166239):

Flaky unexpected result (31)
  • OK /_mozilla/css/offset_properties_inline.html (#40543)
    • FAIL [expected PASS] subtest: offsetTop

      assert_equals: offsetTop of #inline-1 should be 0. expected 0 but got -1
      

    • FAIL [expected PASS] subtest: offsetLeft

      assert_equals: offsetLeft of #inline-2 should be 40. expected 40 but got 25
      

  • OK /_mozilla/mozilla/getBoundingClientRect.html (#39668)
    • FAIL [expected PASS] subtest: getBoundingClientRect 1

      assert_equals: expected 62 but got 60.35
      

  • OK /_mozilla/webxr/create_session.https.html
    • FAIL [expected PASS] subtest: create_session

      can't access property "simulateDeviceConnection", navigator.xr.test is undefined
      

  • OK /_mozilla/webxr/obtain_frame.https.html
    • FAIL [expected PASS] subtest: obtain_frame

      promise_test: Unhandled rejection with value: object "TypeError: can't access property "simulateDeviceConnection", navigator.xr.test is undefined"
      

  • ERROR [expected TIMEOUT] /_mozilla/webxr/sessionavailable.https.html
  • OK /css/css-cascade/layer-cssom-order-reverse.html (#36094)
    • PASS [expected FAIL] subtest: Delete layer invalidates @font-face
  • OK /css/css-fonts/generic-family-keywords-001.html (#37467)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(nastaliq)
  • OK /custom-elements/form-associated/ElementInternals-setFormValue.html (#29174)
    • PASS [expected FAIL] subtest: Multiple values - name content attribute is ignored
  • ERROR [expected OK] /fetch/fetch-later/quota/same-origin-iframe/multiple-iframes.https.window.html (#35176)
  • ERROR [expected OK] /fetch/metadata/window-open.https.sub.html (#40339)
  • CRASH [expected OK] /html/browsers/browsing-the-web/history-traversal/persisted-user-state-restoration/scroll-restoration-navigation-cross-origin.html
  • OK /html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/load-pageshow-events-window-open.html (#28691)
    • PASS [expected FAIL] subtest: load event does not fire on window.open('about:blank')
  • OK /html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-same-origin-fragment.html (#20768)
    • FAIL [expected PASS] subtest: Tests that a fragment navigation in the unload handler will not block the initial navigation

      assert_equals: expected "" but got "#fragment"
      

  • OK /html/browsers/history/the-history-interface/traverse_the_history_5.html (#21383)
    • PASS [expected FAIL] subtest: Multiple history traversals, last would be aborted
  • OK [expected TIMEOUT] /html/interaction/focus/the-autofocus-attribute/supported-elements.html (#24145)
    • PASS [expected TIMEOUT] subtest: Non-HTMLElement should not support autofocus
    • FAIL [expected NOTRUN] subtest: Host element with delegatesFocus should support autofocus

      assert_equals: expected Element node <div autofocus=""></div> but got Element node <body></body>
      

    • FAIL [expected NOTRUN] subtest: Host element with delegatesFocus including no focusable descendants should be skipped

      assert_equals: expected Element node <input autofocus=""></input> but got Element node <body><div autofocus=""></div><input autofocus=""></body>
      

    • FAIL [expected NOTRUN] subtest: Area element should support autofocus

      promise_test: Unhandled rejection with value: object "TypeError: can't access property "appendChild", w.document.querySelector(...) is null"
      

  • OK [expected TIMEOUT] /html/interaction/focus/the-autofocus-attribute/update-the-rendering.html (#24145)
    • FAIL [expected TIMEOUT] subtest: "Flush autofocus candidates" should be happen before a scroll event and animation frame callbacks

      assert_array_equals: animationFrame lengths differ, expected array ["autofocus", "scroll", "animationFrame"] length 3, got ["animationFrame"] length 1
      

  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-link-click.html (#32664)
    • FAIL [expected PASS] subtest: Navigating iframe loading='lazy' before it is loaded: link click

      uncaught exception: Error: assert_equals: expected "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?nav" but got "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?src"
      

  • TIMEOUT [expected OK] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_navigate_other_frame_popup.sub.html (#39702)
    • TIMEOUT [expected FAIL] subtest: Sandboxed iframe can not navigate other frame's popup

      Test timed out
      

  • OK /html/semantics/forms/form-submission-0/multipart-formdata.window.html (#28725)
    • PASS [expected FAIL] subtest: multipart/form-data: non-ASCII in name and value (normal form)
  • OK /html/semantics/forms/form-submission-0/text-plain.window.html (#28687)
    • PASS [expected FAIL] subtest: text/plain: 0x00 in filename (formdata event)
    • PASS [expected FAIL] subtest: text/plain: \r\n in name (normal form)
  • OK /html/semantics/scripting-1/the-script-element/execution-timing/077.html (#22139)
    • FAIL [expected PASS] subtest: adding several types of scripts through the DOM and removing some of them confuses scheduler

      assert_array_equals: expected property 1 to be "Script #1 ran" but got "Script #3 ran" (expected array ["Script #2 ran", "Script #1 ran", "Script #3 ran", "Script #4 ran"] got ["Script #2 ran", "Script #3 ran", "Script #4 ran", "Script #1 ran"])
      

  • FAIL [expected PASS] /png/apng/acTL-plays-one.html (#41218)
  • PASS [expected FAIL] /png/apng/fcTL-dispose-none.html
  • OK /preload/prefetch-document.html (#37210)
    • FAIL [expected PASS] subtest: different-site document prefetch with 'as=document' should not be consumed

      assert_equals: expected 2 but got 1
      

  • OK /preload/preload-error.sub.html (#37177)
    • FAIL [expected PASS] subtest: success (fetch): main

      assert_greater_than: http://web-platform.test:8000/preload/resources/dummy.xml?label=fetch should be loaded expected a number greater than 0 but got 0
      

    • FAIL [expected PASS] subtest: 404 (fetch): main

      assert_greater_than: http://web-platform.test:8000/preload/resources/dummy.xml?pipe=status%28404%29&label=fetch should be loaded expected a number greater than 0 but got 0
      

  • CRASH [expected OK] /trusted-types/should-trusted-type-policy-creation-be-blocked-by-csp-001.html
  • CRASH [expected OK] /trusted-types/trusted-types-reporting-for-Window-eval.html
  • CRASH [expected OK] /uievents/idlharness.window.html
  • CRASH [expected OK] /wasm/webapi/esm-integration/worklet-import-source-phase.tentative.https.html
  • OK [expected ERROR] /webxr/render_state_update.https.html (#27535)
  • CRASH [expected OK] /workers/constructors/Worker/ctor-1.html
Stable unexpected results that are known to be intermittent (23)
  • OK /IndexedDB/idbcursor-continuePrimaryKey-exceptions.any.html (#39277)
    • FAIL [expected PASS] subtest: IDBCursor continuePrimaryKey() on object store cursor

      assert_throws_dom: continuePrimaryKey() should throw if source is not an index function "function() {
              cursor.continuePrimaryKey(2, 2);
            }" threw object "TypeError: cursor.continuePrimaryKey is not a function" that is not a DOMException InvalidAccessError: property "code" is equal to undefined, expected 15
      

  • OK /IndexedDB/idbcursor-continuePrimaryKey-exceptions.any.worker.html (#39277)
    • FAIL [expected PASS] subtest: IDBCursor continuePrimaryKey() on object store cursor

      assert_throws_dom: continuePrimaryKey() should throw if source is not an index function "function() {
              cursor.continuePrimaryKey(2, 2);
            }" threw object "TypeError: cursor.continuePrimaryKey is not a function" that is not a DOMException InvalidAccessError: property "code" is equal to undefined, expected 15
      

  • OK /IndexedDB/idbobjectstore_getAll.any.html (#39276)
    • PASS [expected FAIL] subtest: Get all values with transaction.commit()
  • OK /IndexedDB/idbobjectstore_getAll.any.worker.html (#39400)
    • PASS [expected FAIL] subtest: Get all values with transaction.commit()
  • OK /IndexedDB/idbrequest-onupgradeneeded.any.html (#38895)
    • PASS [expected FAIL] subtest: transaction oncomplete ordering relative to open request onsuccess
  • OK /IndexedDB/idbrequest-onupgradeneeded.any.worker.html (#38971)
    • PASS [expected FAIL] subtest: transaction oncomplete ordering relative to open request onsuccess
  • OK /IndexedDB/key-conversion-exceptions.any.html (#39305)
    • FAIL [expected PASS] subtest: IDBCursor continue() method with throwing/invalid keys

      assert_throws_exactly: key conversion with throwing getter should rethrow function "() => {
            receiver[method](key);
          }" threw object "TypeError: receiver[method] is not a function" but we expected it to throw object "getter: throwing from getter"
      

    • FAIL [expected PASS] subtest: IDBCursor update() method with throwing/invalid keys

      assert_throws_exactly: throwing getter should rethrow during clone function "() => {
            cursor.update(value);
          }" threw object "TypeError: cursor.update is not a function" but we expected it to throw object "getter: throwing from getter"
      

  • OK /IndexedDB/key-conversion-exceptions.any.worker.html (#39284)
    • FAIL [expected PASS] subtest: IDBCursor continue() method with throwing/invalid keys

      assert_throws_exactly: key conversion with throwing getter should rethrow function "() => {
            receiver[method](key);
          }" threw object "TypeError: receiver[method] is not a function" but we expected it to throw object "getter: throwing from getter"
      

    • FAIL [expected PASS] subtest: IDBCursor update() method with throwing/invalid keys

      assert_throws_exactly: throwing getter should rethrow during clone function "() => {
            cursor.update(value);
          }" threw object "TypeError: cursor.update is not a function" but we expected it to throw object "getter: throwing from getter"
      

  • FAIL [expected PASS] /_mozilla/mozilla/sslfail.html (#10760)
  • TIMEOUT [expected OK] /_mozilla/mozilla/window_resize_event.html (#36741)
    • TIMEOUT [expected PASS] subtest: Popup onresize event fires after resizeTo

      Test timed out
      

  • OK /_webgl/conformance/textures/misc/texture-upload-size.html (#21770)
    • PASS [expected FAIL] subtest: WebGL test #45
    • PASS [expected FAIL] subtest: WebGL test #47
    • PASS [expected FAIL] subtest: WebGL test #49
    • PASS [expected FAIL] subtest: WebGL test #51
    • FAIL [expected PASS] subtest: WebGL test #53

      assert_true: Texture was smaller than the expected size 2x2 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #55

      assert_true: getError expected: INVALID_VALUE. Was NO_ERROR : when calling texSubImage2D with the same texture upload with offset 1, 1 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #57

      assert_true: Texture was smaller than the expected size 2x2 expected true got false
      

    • FAIL [expected PASS] subtest: WebGL test #59

      assert_true: getError expected: INVALID_VALUE. Was NO_ERROR : when calling texSubImage2D with the same texture upload with offset 1, 1 expected true got false
      

    • PASS [expected FAIL] subtest: WebGL test #61
    • PASS [expected FAIL] subtest: WebGL test #63
    • And 6 more unexpected results...
  • OK /css/css-cascade/layer-font-face-override.html (#35935)
    • PASS [expected FAIL] subtest: @font-face override update with appended sheet 1
    • PASS [expected FAIL] subtest: @font-face override update with appended sheet 2
  • OK /fetch/metadata/generated/css-font-face.https.sub.tentative.html (#32732)
    • PASS [expected FAIL] subtest: sec-fetch-user
  • ERROR [expected OK] /focus/focus-event-after-switching-iframes.sub.html (#40368)
  • OK /html/browsers/browsing-the-web/navigating-across-documents/005.html (#27062)
    • PASS [expected FAIL] subtest: Link with onclick navigation and href navigation
  • OK /html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/a-click.html (#28697)
    • FAIL [expected PASS] subtest: aElement.click() before the load event must NOT replace

      assert_equals: expected "http://web-platform.test:8000/common/blank.html?thereplacement" but got "http://web-platform.test:8000/html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/resources/code-injector.html?pipe=sub(none)&code=%0A%20%20%20%20const%20a%20%3D%20document.createElement(%22a%22)%3B%0A%20%20%20%20a.href%20%3D%20%22%2Fcommon%2Fblank.html%3Fthereplacement%22%3B%0A%20%20%20%20document.currentScript.before(a)%3B%0A%20%20%20%20a.click()%3B%0A%20%20"
      

  • OK /html/browsers/history/the-history-interface/traverse_the_history_4.html (#21383)
    • PASS [expected FAIL] subtest: Multiple history traversals, last would be aborted
  • OK /html/semantics/document-metadata/the-meta-element/pragma-directives/attr-meta-http-equiv-refresh/allow-scripts-flag-changing-1.html (#39694)
    • PASS [expected FAIL] subtest: Meta refresh is blocked by the allow-scripts sandbox flag at its creation time, not when refresh comes due
  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-location-assign.html (#32863)
    • PASS [expected FAIL] subtest: Navigating iframe loading='lazy' before it is loaded: location.assign
  • OK /html/semantics/scripting-1/the-script-element/module/dynamic-import/blob-url.any.html (#33948)
    • PASS [expected FAIL] subtest: Revoking a blob URL immediately after calling import will not fail
  • OK /navigation-timing/test-navigation-type-reload.html (#33334)
    • PASS [expected FAIL] subtest: Reload domComplete > Original domComplete
    • PASS [expected FAIL] subtest: Reload domContentLoadedEventEnd > Original domContentLoadedEventEnd
    • PASS [expected FAIL] subtest: Reload domContentLoadedEventStart > Original domContentLoadedEventStart
    • PASS [expected FAIL] subtest: Reload domInteractive > Original domInteractive
    • PASS [expected FAIL] subtest: Reload fetchStart > Original fetchStart
    • PASS [expected FAIL] subtest: Reload loadEventEnd > Original loadEventEnd
    • PASS [expected FAIL] subtest: Reload loadEventStart > Original loadEventStart
  • TIMEOUT /resource-timing/test_resource_timing.html (#25720)
    • PASS [expected FAIL] subtest: PerformanceEntry has correct name, initiatorType, startTime, and duration (img)
  • TIMEOUT /trusted-types/trusted-types-navigation.html?31-35 (#38034)
    • TIMEOUT [expected PASS] subtest: Navigate a frame via form-submission with javascript:-urls in report-only mode.

      Test timed out
      

    • NOTRUN [expected TIMEOUT] subtest: Navigate a frame via form-submission with javascript:-urls w/ default policy in report-only mode.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 3, 2026

✨ Try run (#20677166239) succeeded.

@mrobinson
Copy link
Copy Markdown
Member Author

Apologies for the large change here. To replace the functionality in textinput.rs requires essentially reworking the entire thing. I could also split up this change by gradually adding the functionality of Rope in multiple PRs, but it would be unused until it can fully replace the internal rope in textinput.rs. I'm open to ideas about other ways of splitting it too.

FWIW, the motivation for this change is what we have planned improvements to interactivity in Servo and this is the first step, which will make followup work a lot easier.

@Narfinger
Copy link
Copy Markdown
Contributor

I think this is a good change as it cleans up the textinput code. However, I am wondering about the impact on Domstring conversions. If I understand correctly, the Rope will always be a vector of Rust Strings, hence, any strings from the JS Engine need to be converted. Is that right?

@mrobinson
Copy link
Copy Markdown
Member Author

I think this is a good change as it cleans up the textinput code. However, I am wondering about the impact on Domstring conversions. If I understand correctly, the Rope will always be a vector of Rust Strings, hence, any strings from the JS Engine need to be converted. Is that right?

Yes, I think that is effectively the case anyway though, as most string operations require Rust strings currently anyway. For instance, as soon as you type a character into the input field. My long term plan is that we eventually factor out the non-JavaScript string types into String and StringView classes in base, which would allow us to avoid having to convert everything to UTF-8.

}

let start_line = self.line_for_index_mut(start_index);
let last_line = new_contents.last().expect("Should have at last one line");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let last_line = new_contents.last().expect("Should have at last one line");
let last_line = new_contents.last().expect("Should have at least one line");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

boundary_value
}

/// Given a [`RopeIndex`] clamp it, meaning that its indices are all bound by the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Given a [`RopeIndex`] clamp it, meaning that its indices are all bound by the
/// Given a [`RopeIndex`], clamp it, meaning that its indices are all bound by the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

}

/// Given a [`RopeIndex`] clamp it, meaning that its indices are all bound by the
/// actual size of the line and the number of lines in this [`Rope]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// actual size of the line and the number of lines in this [`Rope]
/// actual size of the line and the number of lines in this [`Rope`].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

RopeIndex::new(line_index, rope_index.code_point.min(line_length_utf8))
}

/// Convert a TextPoint into a byte offset from the start of the content.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Convert a TextPoint into a byte offset from the start of the content.
/// Convert a [`RopeIndex`] into a byte offset from the start of the content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

let final_line = self.line(rope_index.line);

// The offset might be past the end of the line due to being an exclusive offset and
// also the fact that every line has a virtual newline at the end (apart from the last).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment has been carried over from the previous code. Aren't all the lines now represented with a real newline character at the end? Also, maybe RopeIndex documentation needs to mention that it can point to the final newline and so APIs like replace_range can potentially delete the newline to "append" to the end of a line, if the start index is directly pointing to the end of the line, rather than beyond it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is an old comment. I've also added more information to the rustdoc for RopeIndex as you suggested.

range: Range<RopeIndex>,
string: impl Into<String>,
) -> RopeIndex {
let start_index = range.start;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should start_index be clamped first or is it expected that this API should only be called for a valid range within the rope and thus can panic otherwise?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm. I can add an assertion here, which I think will address your question.

/// [rope data structure]: https://en.wikipedia.org/wiki/Rope_(data_structure)
#[derive(MallocSizeOf)]
pub struct Rope {
/// The lines of the rope. Each an owned strings each with a trailing newline (`\n`),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The lines of the rope. Each an owned strings each with a trailing newline (`\n`),
/// The lines of the rope. Each line is an owned string that ends with a trailing newline (`\n`),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.


/// Return a [`RopeIndex`] which points to the start of the subsequent line.
/// If the given [`RopeIndex`] is already on the final line, this will return
/// the final line of the entire [`Rope`].
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// the final line of the entire [`Rope`].
/// the last index of the entire [`Rope`].

"will return the final line" seems confusing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It should say "final index" here.

RopeIndex::new(line_index, self.line(line_index).len())
}

/// Replace the given range of [`RopeIndex`]s with the given string. Returns the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API assumes that the replacement string has at least one newline at the end. This allows the user of the API to violate the invariant that every line except the last has a final new line.
For example,

    let mut rope = Rope::new("A\nBB\nCCC", Lines::Multiple);
    rope.replace_range(RopeIndex::new(0, 2)..RopeIndex::new(1, 0), "D");

will put the rope into an invalid state

A\nD
BB\n
CCC

Not sure if it is sufficient to document the requirements of the string parameter here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point. I've adjust the way that clamping indexes works, so that now clamped indexes never point past the trailing newline in any line. I also now clamp all inputs to fn replace_range and fn delete_range. Finally I've added unit tests for these cases.


/// Convert a byte offset from the start of the content into a RopeIndex.
pub fn utf8_offset_to_rope_index(&self, utf8_offset: Utf8CodeUnitLength) -> RopeIndex {
let mut current_utf8_offset = utf8_offset;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like if current_utf8_offset were declared as usize i.e
let mut current_utf8_offset = utf8_offset.0;
we could avoid all the unwrapping and wrapping in the code below. Not sure if this is written this way for documentation, as the Utf8CodeUnitLength itself doesn't perform any validation and the code uses only the Sub implementation of the wrapper type, which is identical to usize.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I've done this.

@mrobinson
Copy link
Copy Markdown
Member Author

@mukilan Thank you kindly for the review. I think I have addressed all of your concerns.

/// [rope data structure]: https://en.wikipedia.org/wiki/Rope_(data_structure)
#[derive(MallocSizeOf)]
pub struct Rope {
/// The lines of the rope. Each line is an owned strings that ends with a newline
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The lines of the rope. Each line is an owned strings that ends with a newline
/// The lines of the rope. Each line is an owned string that ends with a newline

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed.

fn test_rope_replace_slice() {
let mut rope = Rope::new("AAA\nBBB\nCCC", Lines::Multiple);
rope.replace_range(RopeIndex::new(0, 1)..RopeIndex::new(0, 2), "x");
assert_eq!(rope.contents(), "Ax\nBBB\nCCC",);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI shows a failure at this assertion. Looks like just the expectation needs to be updated as the input range only includes one 'A' character in the middle.

Suggested change
assert_eq!(rope.contents(), "Ax\nBBB\nCCC",);
assert_eq!(rope.contents(), "AxA\nBBB\nCCC",);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've modified this now.

rope.replace_range(RopeIndex::new(0, 2)..RopeIndex::new(1, 0), "D");
assert_eq!(
rope.contents(),
"ADBB\nCCC",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be passing only because rope.contents() joins the individual lines and thus hides the missing new line. Perhaps the test needs to check each individual line by asserting the value of rope.lines.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see the issue. I've rewritten delete_range to address this and improved the tests to detect the bug you have found.

@mrobinson
Copy link
Copy Markdown
Member Author

@mukilan I think I've addressed your concerns. Please take another look.

"Trying to delete a slice beyond the last index should not put the Rope in an invalid state."
);
rope.delete_range(RopeIndex::new(0, 3)..RopeIndex::new(0, 4));
assert_eq!(rope.lines.len(), 3);
Copy link
Copy Markdown
Member

@mukilan mukilan Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use arrays to assert both the length and contents together, which is also more compact.
i.e

assert_eq!(rope.lines, ["ABC\n", "DEF\n", ""]);

But this just a stylistic suggestion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. I've done this.

@servo-highfive servo-highfive removed the S-awaiting-review There is new code that needs to be reviewed. label Jan 6, 2026
`TextInput` internally stores a rope of text content, one segment for
each line. This change separates out the rope into a separate `Rope`
data structure that can be shared with other parts of Servo.

`TextInput` needs to move through text content in a variety of ways,
depending on the keys pressed when interacting with a text area. This
change provides a unified movement API in both `Rope` and in
`TextInput` ensuring that selection is handled consistently (apart from
a few minor quirks [^1]). This simplifies the code an improves
interactive behavior.

[^1] One of these quirks is that the edit point will move to
directional end of the motion when collapsing a selection, but only when
moving by grapheme or word (and not by line). These quirks existed in an
undocumented way previously and they are preserved with code comments.

Signed-off-by: Martin Robinson <[email protected]>
@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Jan 6, 2026
@mrobinson mrobinson enabled auto-merge January 6, 2026 14:46
@mrobinson mrobinson added this pull request to the merge queue Jan 6, 2026
@servo-highfive servo-highfive added the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 6, 2026
Merged via the queue into servo:main with commit 2efa7d5 Jan 6, 2026
32 checks passed
@mrobinson mrobinson deleted the rope branch January 6, 2026 15:37
@servo-highfive servo-highfive removed the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Jan 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-awaiting-review There is new code that needs to be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants