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<usize, ScrollHandle>` 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 <finn@zed.dev>
This commit is contained in:
JC Diamante 2026-08-06 23:18:02 +08:00 committed by GitHub
parent d0f797a38c
commit 51db7df750
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 90 additions and 2 deletions

1
Cargo.lock generated
View file

@ -10810,6 +10810,7 @@ dependencies = [
"gpui_platform",
"html5ever 0.27.0",
"image",
"indoc",
"language",
"languages",
"linkify",

View file

@ -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

View file

@ -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()
);
}
}