diff --git a/README.md b/README.md index 9f641fb3841909..24840eec031df7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!IMPORTANT] +> Remove this line to confirm you've reviewed this PR before submitting. + # Zed [![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev) diff --git a/assets/settings/default.json b/assets/settings/default.json index c0e79ab1916ba7..a6ab3a2f21f247 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -127,6 +127,25 @@ // The maximum width, in pixels, of the rendered markdown content when // limit_content_width is enabled. "max_width": 800, + // Whether to constrain top-level Mermaid blocks to `mermaid_max_width`. + // When disabled, no Mermaid-specific maximum width is applied. + // To restore Zed's native layout, disable both Mermaid width options + // and set mermaid_alignment to "left". + "limit_mermaid_width": false, + // The maximum width, in pixels, of top-level Mermaid blocks when + // `limit_mermaid_width` is enabled and `mermaid_width_follows_diagram` + // is disabled. Has no effect otherwise. + "mermaid_max_width": 800, + // Whether top-level Mermaid blocks follow the diagram's 100% natural + // width, with enough space for controls, instead of using Zed's default + // full-width Mermaid block. This takes precedence over + // `limit_mermaid_width`. Interactive zoom remains within the block. + "mermaid_width_follows_diagram": false, + // Where to align top-level Mermaid diagrams horizontally: "left", + // "center", or "right". In the default or limited-width layout this + // aligns the diagram within the Mermaid block; when + // `mermaid_width_follows_diagram` is enabled, it aligns the block itself. + "mermaid_alignment": "center", }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. // diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c32ac995ec5e72..a7dfd3efe5884c 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1384,6 +1384,15 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Set the disabled state reported to assistive technology. + /// + /// This only changes accessibility metadata; input handlers must enforce + /// the disabled state separately. + fn aria_disabled(mut self, disabled: bool) -> Self { + self.interactivity().aria.disabled = Some(disabled); + self + } + /// Set the selected state for this element. fn aria_selected(mut self, selected: bool) -> Self { self.interactivity().aria.selected = Some(selected); @@ -2102,6 +2111,7 @@ pub(crate) struct AriaProperties { pub(crate) label: Option, pub(crate) description: Option, pub(crate) keyshortcuts: Option, + pub(crate) disabled: Option, pub(crate) selected: Option, pub(crate) expanded: Option, pub(crate) toggled: Option, @@ -3534,6 +3544,13 @@ impl Interactivity { if let Some(keyshortcuts) = &self.aria.keyshortcuts { node.set_keyboard_shortcut(keyshortcuts.to_string()); } + if let Some(disabled) = self.aria.disabled { + if disabled { + node.set_disabled(); + } else { + node.clear_disabled(); + } + } if let Some(selected) = self.aria.selected { node.set_selected(selected); } @@ -5256,6 +5273,28 @@ mod tests { assert_eq!(node.author_id(), Some("settings.buffer-font-size")); } + #[test] + fn test_aria_disabled_preserves_default_and_can_be_cleared() { + for (disabled, initially_disabled, expected_disabled) in [ + (None, false, false), + (None, true, true), + (Some(true), false, true), + (Some(false), true, false), + ] { + let element = div() + .id("number-field") + .when_some(disabled, |this, disabled| this.aria_disabled(disabled)); + let mut node = accesskit::Node::new(accesskit::Role::SpinButton); + if initially_disabled { + node.set_disabled(); + } + + element.write_a11y_info(&mut node); + + assert_eq!(node.is_disabled(), expected_disabled); + } + } + #[test] fn test_write_a11y_info_string_and_numeric_properties() { let mut interactivity = Interactivity::default(); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index a7591fc413d426..b3da270048c915 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1717,6 +1717,79 @@ pub enum AutoscrollBehavior { Controlled(ScrollHandle), } +/// Where a Mermaid diagram is aligned horizontally. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MermaidAlignment { + /// Align the diagram to the left. + #[default] + Left, + /// Center the diagram. + Center, + /// Align the diagram to the right. + Right, +} + +/// Layout overrides for top-level Mermaid diagrams. +/// +/// These defaults describe Zed's native renderer, independently of the +/// Markdown Preview preference defaults. Nested diagrams use this baseline +/// to retain their parent layout. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct MermaidLayout { + /// Optional Mermaid-specific maximum width for the block. + /// + /// `None` means no Mermaid-specific width override; Zed's native Markdown + /// and Mermaid layout remains responsible for the block width. + /// + /// Ignored when `width_follows_diagram` is enabled. + pub max_width: Option, + /// Horizontal alignment for the Mermaid diagram or, when + /// `width_follows_diagram` is enabled, for the Mermaid block itself. + pub alignment: MermaidAlignment, + /// When true, the top-level Mermaid block follows the rendered diagram's + /// 100% natural width instead of using Zed's native full-width block layout, + /// with enough space for its controls. + /// + /// This takes precedence over `max_width`. Interactive zoom changes the + /// diagram inside the block without changing the block's baseline width. + pub width_follows_diagram: bool, +} + +impl MermaidLayout { + pub fn has_width_override(&self) -> bool { + self.max_width.is_some() || self.width_follows_diagram + } + + /// Whether any Mermaid layout override is active. When this + /// is `false`, the Mermaid preview uses Zed's native layout unchanged. + pub fn has_overrides(&self) -> bool { + self.has_width_override() || self.alignment != MermaidAlignment::Left + } +} + +/// How a top-level block participates in the content width limit. +#[derive(Clone, Copy, Debug, Default)] +enum RootBlockWidth { + /// Constrain to `content_max_width`, like normal content. + #[default] + Default, + /// Render at the full width of the container. + Full, + /// Constrain to this width, ignoring `content_max_width`. + Fixed(Pixels), +} + +/// Constrains a top-level block to `max_width`, centering it within the +/// container. +fn constrain_width(child: AnyElement, max_width: Pixels) -> AnyElement { + div() + .w_full() + .max_w(max_width) + .mx_auto() + .child(child) + .into_any_element() +} + pub struct MarkdownElement { markdown: Entity, style: MarkdownStyle, @@ -1730,6 +1803,12 @@ pub struct MarkdownElement { on_mermaid_zoom: Option, image_resolver: Option Option>>, show_root_block_markers: bool, + /// When set, top-level blocks are constrained to this width and centered. + /// Blocks may opt out via the internal root block width, e.g. Mermaid + /// diagrams that are configured to be wider than the content. + content_max_width: Option, + /// How Mermaid diagrams are laid out. + mermaid_layout: MermaidLayout, autoscroll: AutoscrollBehavior, /// Test-only hook to observe the laid-out text when this element is /// rendered beneath a view, where the layout state isn't otherwise @@ -1757,6 +1836,8 @@ impl MarkdownElement { on_mermaid_zoom: None, image_resolver: None, show_root_block_markers: false, + content_max_width: None, + mermaid_layout: MermaidLayout::default(), autoscroll: AutoscrollBehavior::Propagate, #[cfg(test)] on_render: None, @@ -1863,6 +1944,20 @@ impl MarkdownElement { self } + /// Constrains top-level Markdown blocks to `max_width` and centers them, + /// while wide blocks (e.g. Mermaid diagrams) are allowed to use the full + /// width of the container. Passing `None` renders all blocks edge to edge. + pub fn content_max_width(mut self, max_width: Option) -> Self { + self.content_max_width = max_width; + self + } + + /// Configures how Mermaid diagrams are laid out. + pub fn mermaid_layout(mut self, mermaid_layout: MermaidLayout) -> Self { + self.mermaid_layout = mermaid_layout; + self + } + pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self { self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle); self @@ -2616,6 +2711,8 @@ impl Element for MarkdownElement { self.style.syntax.clone(), highlights, parsed_markdown.code_block_highlights.clone(), + self.content_max_width, + self.mermaid_layout, ); let markdown_end = if let Some(last) = parsed_markdown.events.last() { last.0.end @@ -2660,6 +2757,7 @@ impl Element for MarkdownElement { match event { MarkdownEvent::RootStart => { + builder.root_block_width = RootBlockWidth::Default; if self.show_root_block_markers { builder.push_root_block(range, markdown_end); } @@ -2760,6 +2858,31 @@ impl Element for MarkdownElement { } => *copy_button_visibility, _ => CopyButtonVisibility::VisibleOnHover, }; + // Top-level Mermaid blocks only override their + // width when a Mermaid layout option is active: + // width-following releases the block, the width + // limit pins it to `mermaid_max_width`, and + // otherwise the block keeps Zed's native layout. + // Nested diagrams stay within their parent block. + let is_top_level = + builder.div_stack.len() == builder.root_block_content_depth; + let mermaid_layout = if is_top_level { + builder.mermaid_layout + } else { + MermaidLayout::default() + }; + if is_top_level { + builder.root_block_width = match ( + mermaid_layout.width_follows_diagram, + mermaid_layout.max_width, + ) { + (true, _) => RootBlockWidth::Full, + (false, Some(max_width)) => { + RootBlockWidth::Fixed(max_width) + } + (false, None) => RootBlockWidth::Default, + }; + } builder.push_sourced_element( mermaid_diagram.content_range.clone(), render_mermaid_diagram( @@ -2771,6 +2894,7 @@ impl Element for MarkdownElement { showing_code, zoom, copy_button_visibility, + mermaid_layout, self.on_mermaid_zoom.clone(), window, cx, @@ -3708,6 +3832,16 @@ struct MarkdownElementBuilder { table: TableState, syntax_theme: Arc, highlights: MarkdownHighlights, + /// See `MarkdownElement::content_max_width`. + content_max_width: Option, + /// How the current root block should be constrained. Reset at each root + /// block boundary. + root_block_width: RootBlockWidth, + /// Depth of the div holding a root block's contents, used to tell whether a + /// block is top-level and therefore eligible to be a wide block. + root_block_content_depth: usize, + /// How Mermaid diagrams are laid out. + mermaid_layout: MermaidLayout, } struct MarkdownHighlights { @@ -3805,6 +3939,8 @@ impl MarkdownElementBuilder { syntax_theme: Arc, highlights: MarkdownHighlights, code_block_highlights: Arc, + content_max_width: Option, + mermaid_layout: MermaidLayout, ) -> Self { Self { div_stack: vec![{ @@ -3829,6 +3965,10 @@ impl MarkdownElementBuilder { table: TableState::default(), syntax_theme, highlights, + content_max_width, + root_block_width: RootBlockWidth::Default, + root_block_content_depth: 1, + mermaid_layout, } } @@ -3905,6 +4045,7 @@ impl MarkdownElementBuilder { markdown_end, ); self.push_div(div().pl_4(), range, markdown_end); + self.root_block_content_depth = self.div_stack.len(); } fn push_image_child(&mut self, child: impl IntoElement) { @@ -3931,6 +4072,20 @@ impl MarkdownElementBuilder { } fn append_child(&mut self, child: AnyElement) { + // Only direct children of the root container are top-level blocks, and + // they are the ones subject to the content width limit. + let child = if self.div_stack.len() == 1 { + match self.root_block_width { + RootBlockWidth::Full => child, + RootBlockWidth::Fixed(max_width) => constrain_width(child, max_width), + RootBlockWidth::Default => match self.content_max_width { + Some(max_width) => constrain_width(child, max_width), + None => child, + }, + } + } else { + child + }; self.div_stack.last_mut().unwrap().div.extend([child]); } @@ -3975,6 +4130,7 @@ impl MarkdownElementBuilder { ) }); self.pop_div(); + self.root_block_content_depth = self.div_stack.len(); } fn pop_div(&mut self) { diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 0d4bc76108c605..10850399e3f5de 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -1,8 +1,9 @@ use collections::HashMap; use gpui::{ - Animation, AnimationExt, AnyElement, App, ClipboardItem, Context, Entity, ImageSource, - ParsedSvg, RenderImage, SMOOTH_SVG_SCALE_FACTOR, ScrollDelta, ScrollHandle, ScrollWheelEvent, - Size, Stateful, StyledText, Task, Window, img, pulsating_between, size, + AbsoluteLength, Animation, AnimationExt, AnyElement, App, AvailableSpace, ClipboardItem, + Context, DefiniteLength, Entity, ImageSource, ParsedSvg, RenderImage, SMOOTH_SVG_SCALE_FACTOR, + ScrollDelta, ScrollHandle, ScrollWheelEvent, Size, Stateful, StyleRefinement, StyledText, Task, + Window, img, pulsating_between, px, size, }; use std::collections::BTreeMap; use std::ops::Range; @@ -15,7 +16,10 @@ use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag}; use settings::Settings as _; use theme_settings::ThemeSettings; -use super::{CopyButtonVisibility, Markdown, MarkdownStyle, MermaidZoomCallback, ParsedMarkdown}; +use super::{ + CopyButtonVisibility, MERMAID_MAX_ZOOM, Markdown, MarkdownStyle, MermaidAlignment, + MermaidLayout, MermaidZoomCallback, ParsedMarkdown, +}; type MermaidDiagramCache = HashMap>; @@ -525,6 +529,88 @@ fn mermaid_display_size(base_size: Size, display_scale: f32) -> Size Pixels { + fn padding(length: Option) -> Pixels { + match length { + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels))) => pixels, + _ => px(0.), + } + } + fn border(width: Option) -> Pixels { + match width { + Some(AbsoluteLength::Pixels(pixels)) => pixels, + _ => px(0.), + } + } + padding(style.padding.left) + + padding(style.padding.right) + + border(style.border_widths.left) + + border(style.border_widths.right) +} + +/// Narrows the Mermaid container (its background, controls, and the tab bar) to +/// the natural diagram width or the toolbar's minimum width. The container is +/// aligned within the available area according to `alignment`. +fn hug_mermaid_diagram( + element: AnyElement, + layout: &MermaidLayout, + diagram_width: Option, +) -> AnyElement { + if layout.width_follows_diagram + && let Some(diagram_width) = diagram_width + { + return apply_mermaid_alignment( + div().w_full().max_w(diagram_width).child(element), + layout.alignment, + ) + .into_any_element(); + } + element +} + +fn mermaid_toolbar_min_width( + source_offset: usize, + code: &str, + markdown: &Entity, + window: &mut Window, + cx: &mut App, +) -> Pixels { + // Measure in a separate namespace so these controls cannot reuse the + // state of the real toolbar. Reserve zoom controls even at 100% to keep + // the block width stable when the user zooms. + window.with_element_namespace(("mermaid-toolbar-measurement", source_offset), |window| { + render_mermaid_toolbar( + source_offset, + code.to_owned(), + Some(false), + Some(MERMAID_MAX_ZOOM), + markdown.clone(), + None, + ) + .into_any_element() + .layout_as_root( + size(AvailableSpace::MaxContent, AvailableSpace::MaxContent), + window, + cx, + ) + .width + }) +} + +/// Aligns a Mermaid diagram horizontally within its container. Auto margins +/// collapse to zero once the diagram fills or overflows the container, so a +/// zoomed diagram still scrolls from its leading edge. +fn apply_mermaid_alignment(element: T, alignment: MermaidAlignment) -> T { + match alignment { + MermaidAlignment::Left => element, + MermaidAlignment::Center => element.mx_auto(), + MermaidAlignment::Right => element.ml_auto(), + } +} + /// The number of zoom ticks represented by a scroll-wheel event. /// /// A discrete wheel notch arrives as one `Lines` event whose magnitude varies @@ -577,6 +663,7 @@ pub(crate) fn render_mermaid_diagram( showing_code: bool, zoom: f32, copy_button_visibility: CopyButtonVisibility, + layout: MermaidLayout, on_zoom: Option, window: &mut Window, cx: &mut App, @@ -584,20 +671,44 @@ pub(crate) fn render_mermaid_diagram( let cached = mermaid_state.cache.get(&parsed.contents); let render_result = cached.and_then(|cached| cached.render_image.get()); let show_interactive = copy_button_visibility != CopyButtonVisibility::Hidden; - let code = parsed.contents.contents.clone(); + let diagram_width = if layout.width_follows_diagram { + mermaid_state.natural_size(&parsed.contents).map(|size| { + let content_width = if show_interactive { + size.width.max(mermaid_toolbar_min_width( + source_offset, + &code, + &markdown, + window, + cx, + )) + } else { + size.width + }; + content_width + code_block_horizontal_inset(&style.code_block) + }) + } else { + None + }; + let use_toolbar = show_interactive && layout.width_follows_diagram; - let mut container = div().group("code_block").relative().w_full().rounded_lg(); + let mut container = div() + .group("code_block") + .relative() + .w_full() + .rounded_lg() + .debug_selector(|| "mermaid-container".into()); container.style().refine(&style.code_block); - match render_result { + let element = match render_result { Some(Ok(render_image)) => { let body = if showing_code { render_mermaid_code_view(&parsed.contents.contents) } else { let rasterized_scale = cached.map_or(1.0, |cached| cached.rasterized_scale); - let image_element = - img(ImageSource::Render(render_image.clone())).with_fallback(|| { + let image_element = img(ImageSource::Render(render_image.clone())) + .debug_selector(|| "mermaid-image".into()) + .with_fallback(|| { Label::new("Failed to Load Mermaid Diagram").into_any_element() }); let scroll_handle = markdown.update(cx, |markdown, _| { @@ -611,13 +722,26 @@ pub(crate) fn render_mermaid_diagram( &scroll_handle, on_zoom.clone(), ) - .child(image_element.w(display_size.width).h(display_size.height)) + .child(apply_mermaid_alignment( + image_element.w(display_size.width).h(display_size.height), + layout.alignment, + )) .into_any_element(); with_mermaid_horizontal_scrollbar(source_offset, &scroll_handle, body, window, cx) }; container - .when(show_interactive, |container| { + .when(use_toolbar, |container| { + container.child(render_mermaid_toolbar( + source_offset, + code.to_string(), + Some(showing_code), + (!showing_code && zoom != 1.0).then_some(zoom), + markdown.clone(), + on_zoom.clone(), + )) + }) + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_tab_header( source_offset, showing_code, @@ -625,7 +749,7 @@ pub(crate) fn render_mermaid_diagram( )) }) .child(body) - .when(show_interactive, |container| { + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_overlay_controls( source_offset, code.to_string(), @@ -639,8 +763,18 @@ pub(crate) fn render_mermaid_diagram( Some(Err(_)) => { // Render failed — show the source code without tabs container + .when(use_toolbar, |container| { + container.child(render_mermaid_toolbar( + source_offset, + code.to_string(), + None, + None, + markdown.clone(), + on_zoom.clone(), + )) + }) .child(render_mermaid_code_view(&parsed.contents.contents)) - .when(show_interactive, |container| { + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_overlay_controls( source_offset, code.to_string(), @@ -676,11 +810,12 @@ pub(crate) fn render_mermaid_diagram( &scroll_handle, on_zoom.clone(), ) - .child( + .child(apply_mermaid_alignment( fallback_element .w(display_size.width) .h(display_size.height), - ) + layout.alignment, + )) .into_any_element(); let body = with_mermaid_horizontal_scrollbar( source_offset, @@ -690,7 +825,17 @@ pub(crate) fn render_mermaid_diagram( cx, ); container - .when(show_interactive, |container| { + .when(use_toolbar, |container| { + container.child(render_mermaid_toolbar( + source_offset, + code.to_string(), + Some(showing_code), + (zoom != 1.0).then_some(zoom), + markdown.clone(), + on_zoom.clone(), + )) + }) + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_tab_header( source_offset, showing_code, @@ -698,7 +843,7 @@ pub(crate) fn render_mermaid_diagram( )) }) .child(body) - .when(show_interactive, |container| { + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_overlay_controls( source_offset, code.to_string(), @@ -711,22 +856,34 @@ pub(crate) fn render_mermaid_diagram( } else { // No fallback — show the code so the user has something to look at container + .when(use_toolbar, |container| { + container.child(render_mermaid_toolbar( + source_offset, + code.to_string(), + None, + None, + markdown.clone(), + on_zoom.clone(), + )) + }) .child(render_mermaid_code_view(&parsed.contents.contents)) .child( - div().absolute().top_1().right_2().child( - Label::new("Rendering...") - .size(LabelSize::XSmall) - .color(Color::Muted) - .with_animation( - "mermaid-loading-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ), - ), + div() + .when(!use_toolbar, |this| this.absolute().top_1().right_2()) + .child( + Label::new("Rendering...") + .size(LabelSize::XSmall) + .color(Color::Muted) + .with_animation( + "mermaid-loading-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ), + ), ) - .when(show_interactive, |container| { + .when(show_interactive && !use_toolbar, |container| { container.child(render_mermaid_overlay_controls( source_offset, code.to_string(), @@ -738,7 +895,8 @@ pub(crate) fn render_mermaid_diagram( .into_any_element() } } - } + }; + hug_mermaid_diagram(element, &layout, diagram_width) } /// The horizontal scroll container wrapping a mermaid raster. The element id @@ -802,7 +960,7 @@ fn render_mermaid_tab_header( source_offset: usize, showing_code: bool, markdown: Entity, -) -> impl IntoElement { +) -> Div { let preview_id = ElementId::NamedChild( Arc::new(ElementId::from(( "mermaid-tab-preview", @@ -818,6 +976,7 @@ fn render_mermaid_tab_header( let code_markdown = markdown; h_flex() + .debug_selector(|| "mermaid-tabs".into()) .gap_0p5() .mb_2p5() .child( @@ -850,6 +1009,42 @@ fn render_mermaid_tab_header( ) } +fn render_mermaid_toolbar( + source_offset: usize, + code: String, + showing_code: Option, + zoom: Option, + markdown: Entity, + on_zoom: Option, +) -> Div { + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .mb_2p5() + .debug_selector(|| "mermaid-toolbar".into()) + .when_some(showing_code, |this, showing_code| { + this.child( + render_mermaid_tab_header(source_offset, showing_code, markdown.clone()) + .mb_0() + .min_w_0() + .flex_wrap(), + ) + }) + .when_some(zoom, |this, zoom| { + this.child( + render_mermaid_zoom_indicator(source_offset, zoom, markdown.clone(), on_zoom) + .min_w_0() + .flex_wrap(), + ) + }) + .child( + div() + .debug_selector(|| "mermaid-toolbar-copy".into()) + .child(render_mermaid_copy_button(source_offset, code, markdown)), + ) +} + /// The overlay controls anchored to the top-right corner of a diagram: an /// optional "Zoom NNN%" readout with a reset button (shown only while zoomed /// away from the natural size) followed by the hover-revealed copy button. @@ -884,10 +1079,11 @@ fn render_mermaid_zoom_indicator( zoom: f32, markdown: Entity, on_zoom: Option, -) -> impl IntoElement { +) -> Div { let percentage = (zoom * 100.0).round() as i32; h_flex() + .debug_selector(|| "mermaid-zoom-controls".into()) .gap_0p5() .child( Label::new(format!("Zoom {percentage}%")) @@ -964,11 +1160,13 @@ mod tests { }; use crate::{ CodeBlockRenderer, CopyButtonVisibility, MERMAID_ZOOM_DEBOUNCE, Markdown, MarkdownElement, - MarkdownOptions, MarkdownStyle, WrapButtonVisibility, + MarkdownFont, MarkdownOptions, MarkdownStyle, MermaidAlignment, MermaidLayout, + WrapButtonVisibility, }; use collections::HashMap; use gpui::{ - Context, Entity, IntoElement, Render, RenderImage, TestAppContext, Window, point, size, + Bounds, Context, Entity, IntoElement, Render, RenderImage, ScrollHandle, TestAppContext, + VisualTestContext, Window, point, size, }; use std::cell::RefCell; use std::rc::Rc; @@ -1066,6 +1264,326 @@ mod tests { }) } + #[derive(Clone, Copy)] + struct MermaidLayoutTestOptions { + layout: MermaidLayout, + content_max_width: Option, + markers: bool, + interactive: bool, + } + + impl Default for MermaidLayoutTestOptions { + fn default() -> Self { + Self { + layout: MermaidLayout::default(), + content_max_width: Some(px(800.)), + markers: true, + interactive: false, + } + } + } + + struct MermaidLayoutTestView { + markdown: Entity, + options: MermaidLayoutTestOptions, + font_size: Pixels, + } + + impl Render for MermaidLayoutTestView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let options = self.options; + let mut element = MarkdownElement::new( + self.markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Preview, window, cx), + ) + .mermaid_layout(options.layout) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button_visibility: if options.interactive { + CopyButtonVisibility::VisibleOnHover + } else { + CopyButtonVisibility::Hidden + }, + wrap_button_visibility: WrapButtonVisibility::Hidden, + border: false, + }); + if options.markers { + element = element.show_root_block_markers(); + } + if options.layout.has_width_override() { + element = element.content_max_width(options.content_max_width); + } + ui::utils::WithRemSize::new(self.font_size).child( + div() + .w_full() + .when(!options.layout.has_width_override(), |this| { + this.when_some(options.content_max_width, |this, width| { + this.max_w(width).mx_auto() + }) + }) + .child(element), + ) + } + } + + fn prepare_mermaid_layout<'a>( + source: &str, + natural_width: f32, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + ensure_theme_initialized(cx); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + source.to_owned().into(), + None, + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + markdown.update(cx, |markdown, cx| { + let svg = format!( + r#""# + ); + let parsed = Arc::new( + cx.svg_renderer() + .parse_svg(svg.as_bytes()) + .expect("test SVG"), + ); + let image = cx + .svg_renderer() + .render_parsed(&parsed, 1.0) + .expect("test raster"); + for diagram in markdown.parsed_markdown.mermaid_diagrams.values() { + markdown.mermaid_state.cache.insert( + diagram.contents.clone(), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(image.clone()), + None, + Some(parsed.clone()), + )), + ); + } + assert!( + !markdown.parsed_markdown.mermaid_diagrams.is_empty(), + "Mermaid fixture" + ); + }); + let (_, cx) = cx.add_window_view(|_, _| MermaidLayoutTestView { + markdown: markdown.clone(), + options: MermaidLayoutTestOptions::default(), + font_size: px(16.), + }); + (markdown, cx) + } + + fn draw_mermaid_layout( + markdown: &Entity, + options: MermaidLayoutTestOptions, + width: f32, + font_size: f32, + cx: &mut VisualTestContext, + ) -> (Bounds, Bounds, ScrollHandle) { + cx.update(|window, cx| { + window + .root::() + .flatten() + .expect("layout test window") + .update(cx, |view, cx| { + view.markdown = markdown.clone(); + view.options = options; + view.font_size = px(font_size); + cx.notify(); + }); + }); + cx.simulate_resize(size(px(width), px(1500.))); + cx.run_until_parked(); + // Scrollbars reserve their space using the previous frame's measured + // scroll bounds, so compare geometry only after that frame settles. + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let container = cx.debug_bounds("mermaid-container").expect("diagram block"); + let image = cx.debug_bounds("mermaid-image").expect("diagram image"); + let scroll = markdown.read_with(cx, |markdown, _| { + markdown + .mermaid_views + .values() + .next() + .expect("diagram view") + .scroll_handle + .clone() + }); + (container, image, scroll) + } + + #[gpui::test] + fn test_mermaid_layout_default_width_and_alignment(cx: &mut TestAppContext) { + let (markdown, cx) = prepare_mermaid_layout("```mermaid\nflowchart LR\nA\n```", 1000., cx); + for width in [420., 1200.] { + for content_max_width in [None, Some(px(800.))] { + let options = MermaidLayoutTestOptions { + content_max_width, + ..Default::default() + }; + let (baseline, _, _) = draw_mermaid_layout(&markdown, options, width, 16., cx); + assert_eq!( + baseline.size.width, + px(content_max_width.map_or(width, |limit| width.min(f32::from(limit))) - 16.) + ); + for alignment in [ + MermaidAlignment::Left, + MermaidAlignment::Center, + MermaidAlignment::Right, + ] { + let options = MermaidLayoutTestOptions { + layout: MermaidLayout { + alignment, + ..Default::default() + }, + ..options + }; + let (container, image, scroll) = + draw_mermaid_layout(&markdown, options, width, 16., cx); + assert_eq!(container, baseline); + assert_eq!(image.size.width, px(1000.)); + let free_space = (scroll.bounds().size.width - image.size.width).max(px(0.)); + let offset = match alignment { + MermaidAlignment::Left => px(0.), + MermaidAlignment::Center => free_space / 2., + MermaidAlignment::Right => free_space, + }; + assert_eq!(image.left(), scroll.bounds().left() + offset); + assert_eq!( + scroll.max_offset().x, + (image.size.width - scroll.bounds().size.width).max(px(0.)) + ); + } + } + } + } + + #[gpui::test] + fn test_mermaid_layout_width_priority_and_zoom(cx: &mut TestAppContext) { + let (markdown, cx) = prepare_mermaid_layout("```mermaid\nflowchart LR\nA\n```", 650., cx); + let options = MermaidLayoutTestOptions { + content_max_width: Some(px(400.)), + layout: MermaidLayout { + max_width: Some(px(700.)), + ..Default::default() + }, + ..Default::default() + }; + let (limited, _, _) = draw_mermaid_layout(&markdown, options, 1000., 16., cx); + assert_eq!(limited.size.width, px(684.)); + let options = MermaidLayoutTestOptions { + layout: MermaidLayout { + width_follows_diagram: true, + max_width: Some(px(200.)), + ..Default::default() + }, + ..options + }; + let (natural, _, scroll) = draw_mermaid_layout(&markdown, options, 1000., 16., cx); + assert_eq!(scroll.bounds().size.width, px(650.)); + let (narrow, _, scroll) = draw_mermaid_layout(&markdown, options, 360., 16., cx); + assert!(narrow.right() <= px(360.)); + assert!(scroll.max_offset().x > px(0.)); + markdown.update(cx, |markdown, cx| { + markdown.set_mermaid_zoom_level(0, 2.0, cx) + }); + let (zoomed, image, scroll) = draw_mermaid_layout(&markdown, options, 1000., 16., cx); + assert_eq!(zoomed.size.width, natural.size.width); + assert_eq!(image.size.width, px(1300.)); + assert_eq!(scroll.max_offset().x, px(650.)); + } + + #[gpui::test] + fn test_mermaid_layout_does_not_override_nested_diagrams(cx: &mut TestAppContext) { + for source in [ + "- item\n\n ```mermaid\n flowchart LR\n A\n ```", + "1. item\n\n ```mermaid\n flowchart LR\n A\n ```", + ] { + let (markdown, cx) = prepare_mermaid_layout(source, 80., cx); + for markers in [false, true] { + let options = MermaidLayoutTestOptions { + content_max_width: Some(px(500.)), + markers, + ..Default::default() + }; + let (baseline, image, _) = draw_mermaid_layout(&markdown, options, 1000., 16., cx); + for width_follows_diagram in [false, true] { + let options = MermaidLayoutTestOptions { + layout: MermaidLayout { + max_width: Some(px(900.)), + width_follows_diagram, + alignment: MermaidAlignment::Right, + }, + ..options + }; + let (container, aligned_image, _) = + draw_mermaid_layout(&markdown, options, 1000., 16., cx); + assert_eq!(container, baseline); + assert_eq!(aligned_image, image); + } + } + } + } + + #[gpui::test] + fn test_mermaid_layout_small_toolbar_wraps_without_overlap(cx: &mut TestAppContext) { + let (markdown, cx) = prepare_mermaid_layout("```mermaid\nflowchart LR\nA\n```", 80., cx); + let options = MermaidLayoutTestOptions { + interactive: true, + layout: MermaidLayout { + width_follows_diagram: true, + ..Default::default() + }, + ..Default::default() + }; + for font_size in [16., 24.] { + for width in [120., 220., 1000.] { + markdown.update(cx, |markdown, cx| { + markdown.set_mermaid_zoom_level(0, 1.0, cx) + }); + let (natural, _, _) = draw_mermaid_layout(&markdown, options, width, font_size, cx); + markdown.update(cx, |markdown, cx| { + markdown.set_mermaid_zoom_level(0, 2.0, cx) + }); + let (zoomed, _, scroll) = + draw_mermaid_layout(&markdown, options, width, font_size, cx); + assert_eq!(zoomed.size.width, natural.size.width); + let toolbar = cx.debug_bounds("mermaid-toolbar").expect("toolbar"); + let controls = [ + "mermaid-tabs", + "mermaid-zoom-controls", + "mermaid-toolbar-copy", + ] + .map(|selector| cx.debug_bounds(selector).expect("toolbar controls")); + for bounds in controls { + assert!(bounds.left() >= toolbar.left()); + assert!(bounds.right() <= toolbar.right() + px(1.)); + assert!(bounds.bottom() <= toolbar.bottom() + px(1.)); + } + for (index, left) in controls.iter().enumerate() { + for right in controls.iter().skip(index + 1) { + assert!( + left.right() <= right.left() + || right.right() <= left.left() + || left.bottom() <= right.top() + || right.bottom() <= left.top(), + "overlapping toolbar controls at width={width}, font_size={font_size}" + ); + } + } + assert!(scroll.bounds().top() >= toolbar.bottom()); + assert!(zoomed.right() <= px(width)); + } + } + } + fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents { ParsedMarkdownMermaidDiagramContents { contents: contents.to_string().into(), diff --git a/crates/markdown_preview/src/markdown_preview_settings.rs b/crates/markdown_preview/src/markdown_preview_settings.rs index ab22cd6476da90..f6b9a9a257d032 100644 --- a/crates/markdown_preview/src/markdown_preview_settings.rs +++ b/crates/markdown_preview/src/markdown_preview_settings.rs @@ -1,14 +1,23 @@ use gpui::Pixels; +use markdown::{MermaidAlignment, MermaidLayout}; use settings::{IntoGpui, RegisterSetting, Settings}; /// The settings for the markdown preview. -#[derive(Clone, Copy, Debug, Default, RegisterSetting)] +#[derive(Clone, Copy, Debug, RegisterSetting)] pub struct MarkdownPreviewSettings { /// Whether to automatically open Markdown files in the preview. pub open_markdown_files_in_preview: bool, /// The maximum width of the rendered markdown content, or `None` to render /// content edge to edge. pub max_width: Option, + /// How Mermaid diagrams are laid out. + pub mermaid_layout: MermaidLayout, +} + +impl Default for MarkdownPreviewSettings { + fn default() -> Self { + Self::from_settings(&settings::SettingsContent::default()) + } } impl Settings for MarkdownPreviewSettings { @@ -19,9 +28,123 @@ impl Settings for MarkdownPreviewSettings { } else { None }; + let mermaid_max_width = if content.limit_mermaid_width.unwrap_or(false) { + content.mermaid_max_width.map(IntoGpui::into_gpui) + } else { + None + }; + let mermaid_alignment = match content.mermaid_alignment.unwrap_or_default() { + settings::MermaidAlignment::Left => MermaidAlignment::Left, + settings::MermaidAlignment::Center => MermaidAlignment::Center, + settings::MermaidAlignment::Right => MermaidAlignment::Right, + }; Self { open_markdown_files_in_preview: content.open_markdown_files_in_preview.unwrap_or(false), max_width, + mermaid_layout: MermaidLayout { + max_width: mermaid_max_width, + alignment: mermaid_alignment, + width_follows_diagram: content.mermaid_width_follows_diagram.unwrap_or(false), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::px; + use serde_json::json; + + #[test] + fn test_mermaid_inactive_width_does_not_limit_markdown() { + for limit_content_width in [false, true] { + for mermaid_max_width in [800, 1400] { + let content = serde_json::from_value(json!({ + "markdown_preview": { + "limit_content_width": limit_content_width, + "max_width": 900, + "mermaid_max_width": mermaid_max_width + } + })) + .expect("valid preview settings"); + let settings = MarkdownPreviewSettings::from_settings(&content); + assert_eq!(settings.max_width, limit_content_width.then_some(px(900.))); + assert_eq!( + settings.mermaid_layout, + MermaidLayout { + alignment: MermaidAlignment::Center, + ..MermaidLayout::default() + } + ); + assert!(!settings.mermaid_layout.has_width_override()); + } + } + } + + #[test] + fn test_mermaid_alignment_defaults_to_center_and_preserves_explicit_choices() { + assert_eq!( + MarkdownPreviewSettings::default().mermaid_layout.alignment, + MermaidAlignment::Center + ); + for (content, expected) in [ + (json!({}), MermaidAlignment::Center), + (json!({"markdown_preview": {}}), MermaidAlignment::Center), + ( + json!({"markdown_preview": {"mermaid_alignment": null}}), + MermaidAlignment::Center, + ), + ( + json!({"markdown_preview": {"mermaid_alignment": "left"}}), + MermaidAlignment::Left, + ), + ( + json!({"markdown_preview": {"mermaid_alignment": "center"}}), + MermaidAlignment::Center, + ), + ( + json!({"markdown_preview": {"mermaid_alignment": "right"}}), + MermaidAlignment::Right, + ), + ] { + let content = serde_json::from_value(content).expect("valid preview settings"); + let settings = MarkdownPreviewSettings::from_settings(&content); + assert_eq!(settings.mermaid_layout.alignment, expected); + assert!(!settings.mermaid_layout.has_width_override()); + } + } + + #[test] + fn test_mermaid_settings_keep_width_flags_and_alignment_independent() { + for limit_mermaid_width in [false, true] { + for width_follows_diagram in [false, true] { + let content = serde_json::from_value(json!({ + "markdown_preview": { + "limit_content_width": false, + "limit_mermaid_width": limit_mermaid_width, + "mermaid_max_width": 1200, + "mermaid_width_follows_diagram": width_follows_diagram, + "mermaid_alignment": "center" + } + })) + .expect("valid preview settings"); + let settings = MarkdownPreviewSettings::from_settings(&content); + assert_eq!(settings.max_width, None); + assert_eq!( + settings.mermaid_layout.max_width, + limit_mermaid_width.then_some(px(1200.)) + ); + assert_eq!( + settings.mermaid_layout.width_follows_diagram, + width_follows_diagram + ); + assert_eq!(settings.mermaid_layout.alignment, MermaidAlignment::Center); + assert_eq!( + settings.mermaid_layout.has_width_override(), + limit_mermaid_width || width_follows_diagram + ); + } } } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 7a751c71b01e97..67761ab21a054f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1138,6 +1138,16 @@ impl MarkdownPreviewView { } }); + // Alignment alone does not need to replace the document's native + // width constraint with per-block constraints. + let preview_settings = MarkdownPreviewSettings::get_global(cx); + if preview_settings.mermaid_layout.has_overrides() { + markdown_element = markdown_element.mermaid_layout(preview_settings.mermaid_layout); + } + if preview_settings.mermaid_layout.has_width_override() { + markdown_element = markdown_element.content_max_width(preview_settings.max_width); + } + if let Some(active_editor) = active_editor { let editor_for_checkbox = active_editor.clone(); let view_handle = cx.entity().downgrade(); @@ -1800,7 +1810,6 @@ impl Render for MarkdownPreviewView { let markdown_element = self.render_markdown_element(&preview_theme, window, cx); let markdown = self.markdown.clone(); - let max_width = MarkdownPreviewSettings::get_global(cx).max_width; let content = right_click_menu("markdown-preview-context-menu") .trigger(move |_, _, _| markdown_element) .maybe_menu(move |window, cx| { @@ -1854,12 +1863,20 @@ impl Render for MarkdownPreviewView { }) })) }); - div() - .w_full() - .when_some(max_width, |this, max_width| { - this.max_w(max_width).mx_auto() - }) - .child(content) + let preview_settings = MarkdownPreviewSettings::get_global(cx); + if preview_settings.mermaid_layout.has_width_override() { + // The element handles width per block. + div().w_full().child(content) + } else { + // Zed's native layout: one centered, width-limited + // column wrapping the whole document. + div() + .w_full() + .when_some(preview_settings.max_width, |this, max_width| { + this.max_w(max_width).mx_auto() + }) + .child(content) + } }), ), ) diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 290a14c0d9852c..d4267e0e7a5afd 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1286,6 +1286,32 @@ pub enum LineIndicatorFormat { Long, } +/// Where to align a Mermaid diagram horizontally in the markdown preview. +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + JsonSchema, + MergeFrom, + Deserialize, + Serialize, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum MermaidAlignment { + /// Align the diagram to the left. + Left, + /// Center the diagram. + #[default] + Center, + /// Align the diagram to the right. + Right, +} + /// The settings for the markdown preview. #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, Default, PartialEq)] @@ -1317,6 +1343,36 @@ pub struct MarkdownPreviewSettingsContent { /// /// Default: 800 pub max_width: Option, + /// Whether to constrain top-level Mermaid blocks to `mermaid_max_width`. + /// When disabled, no Mermaid-specific maximum width is applied. + /// To restore Zed's native layout, disable both Mermaid width options + /// and set `mermaid_alignment` to `left`. + /// + /// Default: false + pub limit_mermaid_width: Option, + /// The maximum width, in pixels, of top-level Mermaid blocks when + /// `limit_mermaid_width` is enabled and `mermaid_width_follows_diagram` + /// is disabled. Has no effect otherwise. + /// + /// Default: 800 + pub mermaid_max_width: Option, + /// Where to align top-level Mermaid diagrams horizontally. + /// + /// In the default or limited-width layout, this aligns the diagram within + /// the Mermaid block. When `mermaid_width_follows_diagram` is enabled, + /// it aligns the block itself. + /// + /// Default: center + pub mermaid_alignment: Option, + /// Whether top-level Mermaid blocks follow the rendered diagram's 100% + /// natural width, with enough space for controls, instead of using Zed's + /// default full-width Mermaid block. + /// + /// When enabled, this takes precedence over `limit_mermaid_width`. + /// Interactive zoom remains within the block. + /// + /// Default: false + pub mermaid_width_follows_diagram: Option, } /// The settings for the image viewer. diff --git a/crates/settings_ui/src/components/number_field.rs b/crates/settings_ui/src/components/number_field.rs index 60ca9583a077ba..c68c5a2c2a3046 100644 --- a/crates/settings_ui/src/components/number_field.rs +++ b/crates/settings_ui/src/components/number_field.rs @@ -277,6 +277,7 @@ pub struct NumberField { tab_index: Option, aria_label: Option, aria_description: Option, + disabled: bool, } impl NumberField { @@ -319,6 +320,7 @@ impl NumberField { tab_index: None, aria_label: None, aria_description: None, + disabled: false, } } @@ -342,6 +344,11 @@ impl NumberField { self } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self { self.on_change = Rc::new(on_change); self @@ -390,6 +397,67 @@ fn a11y_value_to_field_value(value: f64) -> Option { impl RenderOnce for NumberField { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + if self.disabled { + // Invalidate the retained editor's blur callback before removing it + // from the element tree, so disabling cannot commit an old draft. + self.on_change_state.write(cx, None); + if let Some(editor) = self + .edit_editor + .read(cx) + .as_ref() + .and_then(|editor| editor.upgrade()) + { + editor.update(cx, |editor, cx| { + editor.set_read_only(true); + editor.set_text(format!("{}", self.value), window, cx); + }); + } + self.last_synced_value.write(cx, Some(self.value)); + + let border_color = cx.theme().colors().border_variant; + let background = cx.theme().colors().surface_background; + let button = |icon| { + h_flex() + .p_1p5() + .border_1() + .border_color(border_color) + .bg(background) + .child(Icon::new(icon).size(IconSize::Small).color(Color::Disabled)) + }; + return h_flex() + .id(self.id.clone()) + .items_stretch() + .role(Role::SpinButton) + .aria_disabled(true) + .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(self.aria_description, |this, description| { + this.aria_description(description) + }) + .when_some(a11y_numeric_value(&self.value), |this, value| { + this.aria_numeric_value(value) + }) + .child(button(IconName::Dash).rounded_tl_sm().rounded_bl_sm()) + .child( + h_flex() + .min_w_16() + .px_1() + .border_y_1() + .border_color(border_color) + .bg(background) + .justify_center() + .child(Label::new((self.format)(&self.value)).color(Color::Disabled)), + ) + .child(button(IconName::Plus).rounded_tr_sm().rounded_br_sm()) + .into_any_element(); + } + if let Some(editor) = self + .edit_editor + .read(cx) + .as_ref() + .and_then(|editor| editor.upgrade()) + { + editor.update(cx, |editor, _| editor.set_read_only(false)); + } // Sync the on_change callback to Entity state so focus_out handlers can access it self.sync_on_change_state(cx); @@ -834,6 +902,7 @@ impl RenderOnce for NumberField { ) }) }) + .into_any_element() } } @@ -884,3 +953,170 @@ impl Component for NumberField { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::Stateful; + + #[gpui::test] + fn number_field_accessibility_tracks_disabled_state(cx: &mut gpui::TestAppContext) { + cx.update(crate::test::register_settings); + let (view, cx) = cx.add_window_view(|_, _| NumberFieldTestView { + value: 1200, + mode: NumberFieldMode::Read, + disabled: false, + changes: 0, + editor_state: None, + callback_state: None, + accessibility_node: None, + }); + + for mode in [NumberFieldMode::Read, NumberFieldMode::Edit] { + for disabled in [false, true, false] { + view.update(cx, |view, cx| { + view.mode = mode; + view.disabled = disabled; + cx.notify(); + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + let node = view + .accessibility_node + .as_ref() + .expect("accessibility node"); + assert_eq!(node.role(), Role::SpinButton); + assert_eq!(node.label(), Some("Maximum Width")); + assert_eq!(node.description(), Some("Maximum Mermaid width in pixels")); + assert_eq!(node.numeric_value(), Some(1200.0)); + assert_eq!(node.is_disabled(), disabled); + for action in [ + AccessibleAction::Focus, + AccessibleAction::SetValue, + AccessibleAction::Increment, + AccessibleAction::Decrement, + ] { + assert_eq!(node.supports_action(action), !disabled); + } + }); + } + } + } + + struct NumberFieldTestView { + value: usize, + mode: NumberFieldMode, + disabled: bool, + changes: usize, + editor_state: Option>>>, + callback_state: Option>>>, + accessibility_node: Option, + } + + impl Render for NumberFieldTestView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let field = NumberField::new("number-field", self.value, window, cx) + .mode(self.mode, cx) + .disabled(self.disabled) + .aria_label("Maximum Width") + .aria_description("Maximum Mermaid width in pixels") + .on_change(cx.listener(|this, value, _, cx| { + this.value = *value; + this.changes += 1; + cx.notify(); + })); + self.editor_state = Some(field.edit_editor.clone()); + self.callback_state = Some(field.on_change_state.clone()); + let mut element = field.render(window, cx).into_any_element(); + let root = element + .downcast_mut::>() + .expect("number field root"); + let mut node = gpui::accesskit::Node::new( + root.a11y_role().expect("number field accessibility role"), + ); + root.write_a11y_info(&mut node); + self.accessibility_node = Some(node); + div().child(element) + } + } + + #[gpui::test] + fn disabled_number_field_discards_pending_edit_and_reenables(cx: &mut gpui::TestAppContext) { + cx.update(crate::test::register_settings); + let (view, cx) = cx.add_window_view(|_, _| NumberFieldTestView { + value: 1200, + mode: NumberFieldMode::Edit, + disabled: false, + changes: 0, + editor_state: None, + callback_state: None, + accessibility_node: None, + }); + cx.run_until_parked(); + + let editor = view.read_with(cx, |view, cx| { + view.editor_state + .as_ref() + .expect("editor state") + .read(cx) + .as_ref() + .and_then(|editor| editor.upgrade()) + .expect("number editor") + }); + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.set_text("9999", window, cx); + }); + }); + view.update(cx, |view, cx| { + view.disabled = true; + cx.notify(); + }); + cx.run_until_parked(); + assert!(editor.read_with(cx, |editor, cx| editor.read_only(cx))); + assert_eq!(editor.read_with(cx, |editor, cx| editor.text(cx)), "1200"); + assert!(view.read_with(cx, |view, cx| { + view.callback_state + .as_ref() + .expect("callback state") + .read(cx) + .is_none() + })); + cx.update(|window, cx| window.blur(cx)); + cx.run_until_parked(); + assert_eq!( + view.read_with(cx, |view, _| (view.value, view.changes)), + (1200, 0) + ); + + view.update(cx, |view, cx| { + view.disabled = false; + cx.notify(); + }); + cx.run_until_parked(); + let (editor, on_change) = view.read_with(cx, |view, cx| { + ( + view.editor_state + .as_ref() + .expect("editor state") + .read(cx) + .as_ref() + .and_then(|editor| editor.upgrade()) + .expect("number editor"), + view.callback_state + .as_ref() + .expect("callback state") + .read(cx) + .clone() + .expect("enabled callback"), + ) + }); + assert!(!editor.read_with(cx, |editor, cx| editor.read_only(cx))); + cx.update(|window, cx| on_change(&1300, window, cx)); + assert_eq!( + view.read_with(cx, |view, _| (view.value, view.changes)), + (1300, 1) + ); + } +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 972d401b68f768..d4a04406fd7039 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -10,8 +10,9 @@ use theme::SystemAppearance; use ui::IntoElement; use crate::{ - ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, - SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names, + ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsDisabledCondition, + SettingsFieldMetadata, SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, + all_language_names, pages::{ open_audio_test_window, render_edit_prediction_setup_page, render_external_agents_page, render_llm_providers_page, render_mcp_servers_page, render_sandbox_settings_page, @@ -29,6 +30,35 @@ const DEFAULT_EMPTY_AUDIO_OUTPUT: Option<&AudioOutputDeviceName> = Some(&DEFAULT const DEFAULT_AUDIO_INPUT: AudioInputDeviceName = AudioInputDeviceName(None); const DEFAULT_EMPTY_AUDIO_INPUT: Option<&AudioInputDeviceName> = Some(&DEFAULT_AUDIO_INPUT); +fn mermaid_width_follows_diagram(settings: &SettingsContent) -> Option { + settings + .markdown_preview + .as_ref()? + .mermaid_width_follows_diagram +} + +fn custom_mermaid_width_metadata() -> Option> { + Some(Box::new(SettingsFieldMetadata { + disabled_when: Some(SettingsDisabledCondition { + pick: mermaid_width_follows_diagram, + reason: "Mermaid Width Follows Diagram is enabled. Custom width settings are preserved but currently inactive.", + }), + ..Default::default() + })) +} + +fn custom_mermaid_width_is_disabled(settings: &SettingsContent, app: &App) -> bool { + // Recheck when an edit is applied: a queued blur, step, or reset may have + // originated before the diagram-following setting changed. + mermaid_width_follows_diagram(settings) + .or_else(|| { + app.try_global::()? + .get_value_from_file(settings::SettingsFile::User, mermaid_width_follows_diagram) + .1 + }) + .unwrap_or(false) +} + macro_rules! concat_sections { (@vec, $($arr:expr),+ $(,)?) => {{ let total_len = 0_usize $(+ $arr.len())+; @@ -10537,7 +10567,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] { + fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SettingItem(SettingItem { title: "Image Viewer", @@ -10640,6 +10670,117 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { }], ], }), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Use Custom Mermaid Width", + description: "Use a maximum width for top-level Mermaid blocks independently of the Markdown content width. Mermaid Width Follows Diagram takes precedence.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.limit_mermaid_width"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .limit_mermaid_width + .as_ref() + }, + write: |settings_content, value, app| { + if custom_mermaid_width_is_disabled(settings_content, app) { + return; + } + settings_content + .markdown_preview + .get_or_insert_default() + .limit_mermaid_width = value; + }, + }), + metadata: custom_mermaid_width_metadata(), + }, + pick_discriminant: |settings_content| { + let enabled = settings_content + .markdown_preview + .as_ref()? + .limit_mermaid_width + .unwrap_or(false); + Some(usize::from(enabled)) + }, + fields: vec![ + vec![], + vec![SettingItem { + files: USER, + title: "Maximum Width", + description: "Maximum width of top-level Mermaid blocks, in pixels. Used when Use Custom Mermaid Width is enabled and Mermaid Width Follows Diagram is disabled.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.mermaid_max_width"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .mermaid_max_width + .as_ref() + }, + write: |settings_content, value, app| { + if custom_mermaid_width_is_disabled(settings_content, app) { + return; + } + settings_content + .markdown_preview + .get_or_insert_default() + .mermaid_max_width = value; + }, + }), + metadata: custom_mermaid_width_metadata(), + }], + ], + }), + SettingsPageItem::SettingItem(SettingItem { + files: USER, + title: "Mermaid Width Follows Diagram", + description: "Whether top-level Mermaid blocks follow the diagram's 100% natural width, with enough space for controls, instead of using Zed's default full-width Mermaid block. This takes precedence over Use Custom Mermaid Width. Interactive zoom remains within the block.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.mermaid_width_follows_diagram"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .mermaid_width_follows_diagram + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .markdown_preview + .get_or_insert_default() + .mermaid_width_follows_diagram = value; + }, + }), + metadata: None, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Mermaid Alignment", + description: "Where to align top-level Mermaid diagrams horizontally. In the default or limited-width layout, this aligns the diagram within the Mermaid block; when `mermaid_width_follows_diagram` is enabled, it aligns the block itself.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.mermaid_alignment"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .mermaid_alignment + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .markdown_preview + .get_or_insert_default() + .mermaid_alignment = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Drop Size Target", description: "Relative size of the drop target in the editor that will open dropped file as a split pane.", diff --git a/crates/settings_ui/src/pages/external_agents_page.rs b/crates/settings_ui/src/pages/external_agents_page.rs index 406dc35c001d50..8027563ba7320c 100644 --- a/crates/settings_ui/src/pages/external_agents_page.rs +++ b/crates/settings_ui/src/pages/external_agents_page.rs @@ -507,6 +507,7 @@ fn render_custom_agent_form_page( None, None, false, + None, cx, ) .into_any_element(), @@ -521,6 +522,7 @@ fn render_custom_agent_form_page( None, None, false, + None, cx, ) .into_any_element(), @@ -535,6 +537,7 @@ fn render_custom_agent_form_page( None, None, false, + None, cx, ) .into_any_element(), @@ -642,6 +645,7 @@ fn render_env_section( None, None, false, + None, cx, ) .into_any_element() diff --git a/crates/settings_ui/src/pages/mcp_servers_page.rs b/crates/settings_ui/src/pages/mcp_servers_page.rs index 92cee7ff3a4a7b..d5346a3f58262d 100644 --- a/crates/settings_ui/src/pages/mcp_servers_page.rs +++ b/crates/settings_ui/src/pages/mcp_servers_page.rs @@ -1022,6 +1022,7 @@ fn render_form_field( None, None, false, + None, cx, ) .into_any_element() @@ -1092,6 +1093,7 @@ fn render_kv_section( None, None, false, + None, cx, ) .into_any_element() diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index e139245a5697db..9cade4e500b77f 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -445,6 +445,23 @@ struct SettingsFieldMetadata { display_clear_button: bool, confirm_on_focus_out: bool, treat_missing_text_as_empty: bool, + disabled_when: Option, +} + +struct SettingsDisabledCondition { + pick: fn(&SettingsContent) -> Option, + reason: &'static str, +} + +impl SettingsFieldMetadata { + fn disabled_reason(&self, file: &SettingsUiFile, cx: &App) -> Option<&'static str> { + let condition = self.disabled_when.as_ref()?; + SettingsStore::global(cx) + .get_value_from_file(file.to_settings(), condition.pick) + .1 + .unwrap_or(false) + .then_some(condition.reason) + } } pub fn init(cx: &mut App) { @@ -639,6 +656,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -1412,6 +1430,7 @@ fn render_settings_item_layout( modified_in: Option, json_path: Option<&'static str>, sub_field: bool, + disabled_reason: Option<&'static str>, cx: &mut Context<'_, SettingsWindow>, ) -> Stateful
{ // Note: the row itself is intentionally not exposed as a labeled group. @@ -1432,7 +1451,12 @@ fn render_settings_item_layout( h_flex() .w_full() .gap_1() - .child(Label::new(SharedString::new_static(title))) + .child( + Label::new(SharedString::new_static(title)) + .when(disabled_reason.is_some(), |label| { + label.color(Color::Disabled) + }), + ) .when_some(reset_fn, |this, reset_to_default| { this.child( IconButton::new("reset-to-default-btn", IconName::Undo) @@ -1458,7 +1482,14 @@ fn render_settings_item_layout( .size(LabelSize::Small) .color(Color::Muted) .render_code_spans(), - ), + ) + .when_some(disabled_reason.filter(|_| !sub_field), |this, reason| { + this.child( + Label::new(reason) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), ) .child(control) .when(settings_window.sub_page_stack.is_empty(), |this| { @@ -1482,8 +1513,12 @@ fn render_settings_item( ) -> Stateful
{ let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx); let file_set_in = SettingsUiFile::from_settings(found_in_file.clone()); + let disabled_reason = setting_item + .metadata + .as_ref() + .and_then(|metadata| metadata.disabled_reason(&file, cx)); - let reset_fn = if sub_field { + let reset_fn = if sub_field || disabled_reason.is_some() { None } else { setting_item @@ -1533,6 +1568,7 @@ fn render_settings_item( modified_in, setting_item.field.json_path(), sub_field, + disabled_reason, cx, ) } @@ -2499,6 +2535,42 @@ impl SettingsWindow { item_index, json_path, }); + + if let SettingsPageItem::DynamicItem(dynamic_item) = item { + // Inactive fields must remain discoverable without changing + // their enabling setting. All variants link to the parent group. + let mut indexed_fields = HashSet::new(); + for field in dynamic_item.fields.iter().flatten() { + let json_path = field + .field + .json_path() + .map(|path| path.trim_end_matches('$')); + if !indexed_fields.insert((json_path, field.title)) { + continue; + } + let key_index = key_lut.len(); + let parts = [ + page.title, + header_str, + dynamic_item.discriminant.title, + field.title, + field.description, + ]; + documents.push(SearchDocument { + id: key_index, + words: split_into_words(&parts), + }); + for part in parts { + push_candidates(&mut fuzzy_match_candidates, key_index, part); + } + key_lut.push(SearchKeyLUTEntry { + page_index, + header_index, + item_index, + json_path, + }); + } + } } } self.search_index = Some(Arc::new(SearchIndex { @@ -4420,14 +4492,21 @@ impl SettingsWindow { for (page_index, page) in self.pages.iter().enumerate() { for (item_index, item) in page.items.iter().enumerate() { - let item_json_path = match item { - SettingsPageItem::SettingItem(setting_item) => setting_item.field.json_path(), + let matches_path = match item { + SettingsPageItem::SettingItem(setting_item) => { + setting_item.field.json_path() == Some(json_path) + } SettingsPageItem::DynamicItem(dynamic_item) => { - dynamic_item.discriminant.field.json_path() + dynamic_item.discriminant.field.json_path() == Some(json_path) + || dynamic_item + .fields + .iter() + .flatten() + .any(|field| field.field.json_path() == Some(json_path)) } - _ => None, + _ => false, }; - if item_json_path == Some(json_path) { + if matches_path { if let Some(navbar_entry_index) = self .navbar_entries .iter() @@ -4977,7 +5056,7 @@ fn render_text_field + Into + AsRef + Clone>( fn render_toggle_button + From + Copy>( field: SettingField, file: SettingsUiFile, - _metadata: Option<&SettingsFieldMetadata>, + metadata: Option<&SettingsFieldMetadata>, title: &'static str, description: &'static str, _window: &mut Window, @@ -4987,6 +5066,7 @@ fn render_toggle_button + From + Copy>( let (value, disabled) = value .map(|current_value| (*current_value.value, current_value.disabled)) .unwrap_or((false.into(), false)); + let disabled_reason = metadata.and_then(|metadata| metadata.disabled_reason(&file, cx)); let toggle_state = if value.into() { ToggleState::Selected @@ -5000,7 +5080,10 @@ fn render_toggle_button + From + Copy>( .when(!description.is_empty(), |this| { this.aria_description(description) }) - .disabled(disabled) + .when_some(disabled_reason, |this, reason| { + this.aria_description(format!("{description} {reason}")) + }) + .disabled(disabled || disabled_reason.is_some()) .on_click({ move |state, window, cx| { telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type()); @@ -5018,7 +5101,7 @@ fn render_toggle_button + From + Copy>( fn render_editable_number_field( field: SettingField, file: SettingsUiFile, - _metadata: Option<&SettingsFieldMetadata>, + metadata: Option<&SettingsFieldMetadata>, title: &'static str, description: &'static str, window: &mut Window, @@ -5026,6 +5109,7 @@ fn render_editable_number_field( ) -> AnyElement { let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick); let value = value.copied().unwrap_or_else(T::min_value); + let disabled_reason = metadata.and_then(|metadata| metadata.disabled_reason(&file, cx)); let id = field .json_path @@ -5034,11 +5118,15 @@ fn render_editable_number_field( NumberField::new(id, value, window, cx) .mode(NumberFieldMode::Edit, cx) + .disabled(disabled_reason.is_some()) .tab_index(0_isize) .aria_label(title) .when(!description.is_empty(), |this| { this.aria_description(description) }) + .when_some(disabled_reason, |this, reason| { + this.aria_description(format!("{description} {reason}")) + }) .on_change({ move |value, window, cx| { let value = *value; @@ -5411,6 +5499,206 @@ pub mod test { language_model::init(cx); } + fn settings_window_for_search( + window: &mut Window, + cx: &mut Context, + ) -> SettingsWindow { + let app_state = AppState::test(cx); + AppState::set_global(app_state, cx); + let mut settings = SettingsWindow::test(window, cx); + settings.pages = page_data::settings_data(cx); + settings.build_filter_table(); + settings.build_navbar(cx); + settings.build_content_handles(window, cx); + settings.build_search_index(); + settings + } + + #[gpui::test] + fn test_mermaid_width_child_is_searchable_while_disabled(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + register_settings(cx); + let settings = cx.new(|cx| settings_window_for_search(window, cx)); + settings.update(cx, |settings, cx| { + let matches = settings.filter_by_json_path("#markdown_preview.mermaid_max_width"); + assert_eq!(matches.len(), 1); + let index = settings + .search_index + .as_ref() + .expect("settings search index"); + let entry = &index.key_lut[matches[0]]; + let SettingsPageItem::DynamicItem(group) = + &settings.pages[entry.page_index].items[entry.item_index] + else { + panic!("Mermaid maximum width must belong to its custom-width group"); + }; + assert_eq!(group.discriminant.title, "Use Custom Mermaid Width"); + let selected = SettingsStore::global(cx) + .get_value_from_file( + SettingsUiFile::User.to_settings(), + group.pick_discriminant, + ) + .1 + .expect("default Mermaid width setting"); + assert!(group.fields[selected].is_empty()); + for word in ["mermaid", "maximum", "width"] { + assert!( + index.documents[matches[0]] + .words + .iter() + .any(|value| value == word) + ); + } + let (page_index, item_index) = (entry.page_index, entry.item_index); + settings.apply_match_indices(matches.into_iter(), "mermaid maximum width"); + assert!(settings.filter_table[page_index][item_index]); + }); + }); + } + + #[gpui::test] + fn test_mermaid_width_child_link_does_not_enable_custom_width(cx: &mut gpui::TestAppContext) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + register_settings(cx); + let settings = cx.new(|cx| settings_window_for_search(window, cx)); + settings.update(cx, |settings, cx| { + let read_preview = |cx: &App| { + SettingsStore::global(cx) + .get_value_from_file(SettingsUiFile::User.to_settings(), |content| { + content.markdown_preview.clone() + }) + .1 + .expect("default preview settings") + }; + let before = read_preview(cx); + assert_eq!(before.limit_mermaid_width, Some(false)); + assert!(settings.navigate_to_setting( + "markdown_preview.mermaid_max_width", + window, + cx, + )); + let matches = settings.filter_by_json_path("#markdown_preview.mermaid_max_width"); + let index = settings + .search_index + .as_ref() + .expect("settings search index"); + assert_eq!( + settings.navbar_entries[settings.navbar_entry].page_index, + index.key_lut[matches[0]].page_index, + ); + assert_eq!(read_preview(cx), before); + }); + }); + } + + #[gpui::test] + fn test_mermaid_following_disables_custom_width_without_losing_values( + cx: &mut gpui::TestAppContext, + ) { + let window = cx.add_empty_window(); + window.update(|window, cx| { + register_settings(cx); + let settings_window = cx.new(|cx| settings_window_for_search(window, cx)); + settings_window.update(cx, |settings_window, cx| { + let matches = + settings_window.filter_by_json_path("#markdown_preview.mermaid_max_width"); + let index = settings_window.search_index.as_ref().expect("search index"); + let entry = &index.key_lut[matches[0]]; + let SettingsPageItem::DynamicItem(group) = + &settings_window.pages[entry.page_index].items[entry.item_index] + else { + panic!("custom-width group"); + }; + let width = &group.fields[1][0]; + let toggle_field = group + .discriminant + .field + .as_any() + .downcast_ref::>() + .expect("width toggle"); + let width_field = width + .field + .as_any() + .downcast_ref::>() + .expect("width value"); + let set_store = |settings: &SettingsContent, cx: &mut App| { + let json = serde_json::to_string(settings).expect("settings JSON"); + cx.update_global::(|store, cx| { + store.set_user_settings(&json, cx).unwrap(); + }); + }; + + for saved_limit in [false, true] { + let mut settings: SettingsContent = serde_json::from_value(serde_json::json!({ + "markdown_preview": { + "limit_mermaid_width": saved_limit, + "mermaid_max_width": 1234, + "mermaid_width_follows_diagram": true, + "mermaid_alignment": "right" + } + })) + .expect("test settings"); + set_store(&settings, cx); + for item in [&group.discriminant, width] { + assert!( + item.metadata + .as_ref() + .expect("dependency metadata") + .disabled_reason(&SettingsUiFile::User, cx) + .is_some() + ); + } + let before = settings.markdown_preview.clone(); + (toggle_field.write)(&mut settings, Some(!saved_limit), cx); + (width_field.write)(&mut settings, Some(2200.0_f32.into()), cx); + (toggle_field.write)(&mut settings, None, cx); + (width_field.write)(&mut settings, None, cx); + assert_eq!(settings.markdown_preview, before); + + settings + .markdown_preview + .as_mut() + .expect("preview settings") + .mermaid_width_follows_diagram = Some(false); + set_store(&settings, cx); + for item in [&group.discriminant, width] { + assert!( + item.metadata + .as_ref() + .expect("dependency metadata") + .disabled_reason(&SettingsUiFile::User, cx) + .is_none() + ); + } + let preview = settings + .markdown_preview + .as_ref() + .expect("preview settings"); + assert_eq!(preview.limit_mermaid_width, Some(saved_limit)); + assert_eq!(preview.mermaid_max_width, Some(1234.0_f32.into())); + assert_eq!( + preview.mermaid_alignment, + Some(settings::MermaidAlignment::Right) + ); + assert_eq!( + (group.pick_discriminant)(&settings), + Some(usize::from(saved_limit)) + ); + (toggle_field.write)(&mut settings, Some(true), cx); + (width_field.write)(&mut settings, Some(2200.0_f32.into()), cx); + let preview = settings + .markdown_preview + .as_ref() + .expect("preview settings"); + assert_eq!(preview.limit_mermaid_width, Some(true)); + assert_eq!(preview.mermaid_max_width, Some(2200.0_f32.into())); + } + }); + }); + } + fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow { struct PageBuilder { title: &'static str,