From c5abddc286c4eb5bf0c17f8aa4d17bd0e8d0223c Mon Sep 17 00:00:00 2001 From: JC Diamante <38757877+Zeraphim@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:18:02 +0800 Subject: [PATCH] markdown: Add horizontal scrollbars to wide tables (#61745) ## Objective Add functional horizontal scrollbars to wide Markdown tables, matching the existing code-block scrollbar pattern. Fixes #61437 PR was talked about in https://github.com/zed-industries/zed/pull/61698 ## Implementation: - Added a `BTreeMap` to `Markdown` for table scroll handles, keyed by source-range start. - During `MarkdownElement` layout, each table container is connected to a `Scrollbars` widget configured for the horizontal axis, using a stable ID derived from `("markdown-table-scrollbar", range.start)`. - The inner table div uses `overflow_x_scroll()` + `track_scroll()` with `restrict_scroll_to_axis`. - Handles for removed tables are discarded after re-rendering. - No public API, setting, schema, or migration changes required. ## Recording https://github.com/user-attachments/assets/cd0f635b-a485-4de8-9059-40a05f256fb1 ## Release Notes: - Improved navigation of wide Markdown tables with horizontal scrollbars. --------- Co-authored-by: MrSubidubi --- Cargo.lock | 1 + crates/markdown/Cargo.toml | 1 + crates/markdown/src/markdown.rs | 90 ++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 701c2b058d..c76cf30642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10810,6 +10810,7 @@ dependencies = [ "gpui_platform", "html5ever 0.27.0", "image", + "indoc", "language", "languages", "linkify", diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 3db8d84b73..f123920536 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -47,6 +47,7 @@ fs = {workspace = true, features = ["test-support"]} gpui = { workspace = true, features = ["test-support"] } gpui_platform = { workspace = true, features = ["wayland", "x11"] } image.workspace = true +indoc.workspace = true language = { workspace = true, features = ["test-support"] } languages = { workspace = true, features = ["load-grammars"] } node_runtime.workspace = true diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f9a8c0dd50..07b4d9e962 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2816,7 +2816,14 @@ impl Element for MarkdownElement { .border(px(1.5)) .border_color(cx.theme().colors().border) .rounded_sm() - .overflow_x_scroll(), + .restrict_scroll_to_axis() + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Horizontal) + .id(("markdown-table-scrollbar", range.start)) + .notify_content(), + window, + cx, + ), range, markdown_end, ); @@ -4444,7 +4451,10 @@ impl RenderedText { #[cfg(test)] mod tests { use super::*; - use gpui::{RenderImage, TestAppContext, UpdateGlobal, size}; + use gpui::{ + Modifiers, RenderImage, ScrollDelta, ScrollWheelEvent, TestAppContext, TouchPhase, + UpdateGlobal, size, + }; use language::{Language, LanguageConfig, LanguageMatcher}; use std::cell::RefCell; use std::sync::{ @@ -6184,4 +6194,80 @@ mod tests { ); }); } + + #[gpui::test] + fn test_wide_table_scrolls_horizontally(cx: &mut TestAppContext) { + ensure_theme_initialized(cx); + let source = indoc::indoc! {r#" + | left | right | + | --- | --- | + | value | far_right_cell_content_that_is_much_wider_than_the_viewport | + "#}; + let left_cell_start = source.find("left").expect("left cell should be present"); + let left_cell_range = left_cell_start..left_cell_start + "left".len(); + let right_cell = "far_right_cell_content_that_is_much_wider_than_the_viewport"; + let right_cell_start = source + .find(right_cell) + .expect("right cell should be present"); + let right_cell_range = right_cell_start..right_cell_start + right_cell.len(); + + let markdown = cx.new(|cx| Markdown::new(source.into(), None, None, cx)); + let rendered_text = Rc::new(RefCell::new(None)); + let (_, cx) = cx.add_window_view({ + let rendered_text = rendered_text.clone(); + move |_, _| MarkdownTestView { + markdown, + style: MarkdownStyle { + table_columns_min_size: true, + ..MarkdownStyle::default() + }, + code_span_link: None, + rendered_text, + } + }); + cx.simulate_resize(size(px(300.), px(200.))); + cx.run_until_parked(); + + let (event_position, right_cell_before_scroll) = { + let rendered_text = rendered_text.borrow(); + let rendered_text = rendered_text + .as_ref() + .expect("markdown should be rendered before scrolling"); + let event_position = rendered_text + .bounds_for_source_range(left_cell_range) + .into_iter() + .next() + .expect("left cell should have bounds") + .center(); + let right_cell_bounds = rendered_text + .bounds_for_source_range(right_cell_range.clone()) + .into_iter() + .next() + .expect("right cell should have bounds"); + (event_position, right_cell_bounds) + }; + + cx.simulate_event(ScrollWheelEvent { + position: event_position, + delta: ScrollDelta::Pixels(point(px(-100.), px(0.))), + modifiers: Modifiers::default(), + touch_phase: TouchPhase::Moved, + }); + cx.run_until_parked(); + + let right_cell_after_scroll = rendered_text + .borrow() + .as_ref() + .expect("markdown should be rendered after scrolling") + .bounds_for_source_range(right_cell_range) + .into_iter() + .next() + .expect("right cell should have bounds after scrolling"); + + assert!(right_cell_after_scroll.left() < right_cell_before_scroll.left()); + assert_eq!( + right_cell_after_scroll.top(), + right_cell_before_scroll.top() + ); + } }