diff --git a/crates/moon-ui-components/src/moon/dock/tab_panel.rs b/crates/moon-ui-components/src/moon/dock/tab_panel.rs index 1af094e..721eeed 100644 --- a/crates/moon-ui-components/src/moon/dock/tab_panel.rs +++ b/crates/moon-ui-components/src/moon/dock/tab_panel.rs @@ -3,16 +3,18 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ App, AppContext as _, ElementId, InteractiveElement as _, IntoElement, MouseButton, - MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, - Styled as _, WeakEntity, Window, div, px, + MouseDownEvent, ParentElement as _, RenderOnce, ScrollHandle, SharedString, + StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, px, }; use super::{DockEvent, DockTabDrag, MoonTabPanelRuntimeState, TabPanel, tab_interaction_policy}; use crate::{ event::InteractiveElementExt as _, moon::{ + MOON_ICON_CARET_DOWN, MoonDropdown, MoonMenuItem, MoonMenuSize, background::MoonBackgroundPolicy, button::{MoonButton, MoonButtonSize, MoonButtonVariant}, + h_flex, text::MoonText, theme::MoonTheme, tokens::{MoonPalette, rgba_from}, @@ -107,6 +109,34 @@ impl RenderOnce for TabPanel { .border_color(rgba_from(p.border, 1.0)); header = self.header_background_policy.apply(header, p.panel, 1.0); + let tabs_scroll_handle = window + .use_keyed_state( + ElementId::from(SharedString::from(format!("{}:tabs-scroll", self.id))), + cx, + |_, _| ScrollHandle::default(), + ) + .read(cx) + .clone(); + let prev_tab_ix = window.use_keyed_state( + ElementId::from(SharedString::from(format!("{}:tabs-selected", self.id))), + cx, + |_, _| None::, + ); + if prev_tab_ix.read(cx).as_ref() != Some(&active_ix) { + tabs_scroll_handle.scroll_to_item(active_ix); + prev_tab_ix.update(cx, |value, _| *value = Some(active_ix)); + } + let mut tabs_inner = h_flex() + .id(ElementId::from(SharedString::from(format!( + "{}:tabs-inner", + self.id + )))) + .relative() + .gap(px(tokens.ui(4.0))) + .overflow_x_scroll() + .track_scroll(&tabs_scroll_handle); + let mut overflow_items: Vec = Vec::new(); + for (ix, panel) in self.items.iter().enumerate() { let selected = ix == active_ix; let state = state.clone(); @@ -115,6 +145,29 @@ impl RenderOnce for TabPanel { let dock_path = self.dock_path.clone(); let panel_name = panel.panel_name(cx); let tab_label = panel.tab_name(cx).unwrap_or_else(|| panel.panel_name(cx)); + { + let state = state.clone(); + let dock_area = self.dock_area.clone(); + let dock_root = self.dock_root; + let dock_path = self.dock_path.clone(); + overflow_items.push( + MoonMenuItem::with_key( + format!("{}:overflow:{ix}", self.id), + tab_label.clone(), + ) + .checked(selected) + .selected(selected) + .on_click(move |_, _, cx| { + state.update(cx, |state, _| state.active_ix = ix); + if let (Some(dock_area), Some(root)) = (dock_area.as_ref(), dock_root) { + _ = dock_area.update(cx, |dock, cx| { + dock.activate_tab_from_user(root, &dock_path, ix, cx); + }); + } + cx.notify(parent_view); + }), + ); + } let pinned = self .pinned_leading_panels .iter() @@ -296,21 +349,46 @@ impl RenderOnce for TabPanel { } } } - header = header.child(tab_host); + tabs_inner = tabs_inner.child(tab_host); } - header = header.child(div().flex_1()); + header = header.child( + h_flex() + .id(ElementId::from(SharedString::from(format!( + "{}:tabs", + self.id + )))) + .flex_1() + .min_w_0() + .overflow_x_hidden() + .child(tabs_inner), + ); + header = header.child( + div().flex_none().child( + MoonDropdown::new(format!("{}:overflow", self.id)) + .trigger_icon(MOON_ICON_CARET_DOWN) + .trigger_caret(false) + .trigger_variant(MoonButtonVariant::Ghost) + .trigger_size(MoonButtonSize::Micro) + .menu_size(MoonMenuSize::Compact) + .items(overflow_items), + ), + ); + // Leftover space lives inside the tab cluster (`flex_1 min_w_0`); a second `flex_1` + // sibling here would steal half the header. Toolbar buttons stay `flex_none` after + // the chevron. if let Some(panel) = active_panel.as_ref() { + let mut tools = h_flex().flex_none().items_center().gap(px(tokens.ui(4.0))); if let Some(buttons) = panel.toolbar_buttons(window, cx) { for button in buttons { - header = header.child(button); + tools = tools.child(button); } } if self.show_panel_controls { let panel_name = panel.panel_name(cx); if self.layout_editable && self.detach_allowed && panel.detachable(cx) { let dock_area = self.dock_area.clone(); - header = header.child( + tools = tools.child( MoonButton::new(format!("{}:detach", self.id)) .label("⧉") .size(MoonButtonSize::Micro) @@ -347,7 +425,7 @@ impl RenderOnce for TabPanel { } else { "▣" }; - header = header.child( + tools = tools.child( MoonButton::new(format!("{}:zoom", self.id)) .label(zoom_label) .size(MoonButtonSize::Micro) @@ -373,7 +451,7 @@ impl RenderOnce for TabPanel { } if self.layout_editable && self.close_allowed && panel.closable(cx) { let dock_area = self.dock_area.clone(); - header = header.child( + tools = tools.child( MoonButton::new(format!("{}:close", self.id)) .label("×") .size(MoonButtonSize::Micro) @@ -399,6 +477,7 @@ impl RenderOnce for TabPanel { ); } } + header = header.child(tools); } root = root.child(header); diff --git a/crates/moon-ui-components/src/moon/tab.rs b/crates/moon-ui-components/src/moon/tab.rs index 1f6656a..a932180 100644 --- a/crates/moon-ui-components/src/moon/tab.rs +++ b/crates/moon-ui-components/src/moon/tab.rs @@ -1,10 +1,16 @@ +use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::*; +use crate::ElementExt; + use super::badge::{MoonBadge, MoonBadgeSize, MoonBadgeVariant}; -use super::foundation::accent_underline; +use super::button::{MoonButtonSize, MoonButtonVariant}; +use super::dropdown::{MoonDropdown, MoonMenuItem, MoonMenuSize}; +use super::foundation::{accent_underline, h_flex}; +use super::icons::MOON_ICON_CARET_DOWN; use super::text::MoonText; use super::theme::{MoonTheme, MoonThemeTokens}; use super::tokens::{MoonPalette, MoonRect, rgba_from}; @@ -13,7 +19,7 @@ use super::tokens::{MoonPalette, MoonRect, rgba_from}; pub struct MoonTabItem { label: SharedString, badge: Option, - width: f32, + width: Option, selected: bool, disabled: bool, closable: bool, @@ -24,7 +30,7 @@ impl MoonTabItem { Self { label: label.into(), badge: None, - width: 70.0, + width: None, selected: false, disabled: false, closable: false, @@ -37,7 +43,7 @@ impl MoonTabItem { } pub fn width(mut self, width: f32) -> Self { - self.width = width; + self.width = Some(width); self } @@ -66,6 +72,7 @@ pub struct MoonTabStrip { items: Vec, padding_left: f32, gap: f32, + overflow_menu: bool, on_click: Option, on_close: Option, } @@ -78,11 +85,16 @@ impl MoonTabStrip { items: Vec::new(), padding_left: 20.0, gap: 8.0, + overflow_menu: false, on_click: None, on_close: None, } } + /// Constrain the strip to an optional max size. + /// + /// `MoonRect.x` / `MoonRect.y` are ignored; they are not a position. The root stays in-flow + /// (`relative`) whether or not bounds are set. pub fn bounds(mut self, bounds: MoonRect) -> Self { self.bounds = Some(bounds); self @@ -98,6 +110,13 @@ impl MoonTabStrip { self } + /// Show a Moon chevron dropdown of every tab after the scroll region. Default `false` + /// (same as TabBar::menu). Chart, main-stack, and dock pass `true`. + pub fn overflow_menu(mut self, overflow_menu: bool) -> Self { + self.overflow_menu = overflow_menu; + self + } + pub fn item(mut self, item: MoonTabItem) -> Self { self.items.push(item); self @@ -128,26 +147,85 @@ impl MoonTabStrip { self } - pub fn render_with_palette(self, p: MoonPalette) -> impl IntoElement { - self.render_with_theme(p, MoonThemeTokens::default()) + pub fn render_with_palette( + self, + window: &mut Window, + cx: &mut App, + p: MoonPalette, + ) -> impl IntoElement { + self.render_with_theme(window, cx, p, MoonThemeTokens::default()) } /// Renders the strip with explicit palette and scale tokens. /// - /// The label owns the flexible centre space inside each fixed-width tab, preventing its badge - /// and close button from shifting inward when the text grows or clips. + /// Tabs are in-flow flex children. When `MoonTabItem::width` is omitted the tab sizes to its + /// label, badge, close control, and token paddings. A supplied width remains a fixed override + /// and the label yields (`flex_1` + `min_w_0`) so badge and close stay inside that box. /// /// Args: + /// window: Window used to persist the scroll handle and tab bounds. + /// cx: App context used to read keyed state. /// p: Palette used for the strip and item states. /// tokens: Scale tokens used for tab geometry. /// /// Returns: /// The themed tab-strip element. - pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement { + pub fn render_with_theme( + self, + window: &mut Window, + cx: &mut App, + p: MoonPalette, + tokens: MoonThemeTokens, + ) -> impl IntoElement { + let strip_id = self.id.clone(); + let tab_h = tokens.fit_height(28.0, 13.0, 7.5); + let selected_ix = self.items.iter().position(|item| item.selected); + let item_metas: Vec<(SharedString, bool, bool)> = self + .items + .iter() + .map(|item| (item.label.clone(), item.selected, item.disabled)) + .collect(); + let on_click_menu = self.on_click.clone(); + + let scroll_handle = window + .use_keyed_state(format!("{strip_id}-scroll"), cx, |_, _| { + ScrollHandle::default() + }) + .read(cx) + .clone(); + let prev_selected = + window.use_keyed_state(format!("{strip_id}-selected"), cx, |_, _| None::); + let bounds_rc = window + .use_keyed_state(format!("{strip_id}-tab-bounds"), cx, |_, _| { + Rc::new(RefCell::new(Vec::>::new())) + }) + .read(cx) + .clone(); + bounds_rc + .borrow_mut() + .resize(self.items.len(), Bounds::default()); + + if prev_selected.read(cx).as_ref() != selected_ix.as_ref() { + if let Some(ix) = selected_ix { + let recorded = bounds_rc.borrow().get(ix).copied().unwrap_or_default(); + if recorded.size.width > px(0.0) { + scroll_selected_tab_into_view(&scroll_handle, recorded); + } else { + scroll_handle.scroll_to_item(ix); + } + } + prev_selected.update(cx, |value, _| *value = selected_ix); + } + let mut root = div() .id(self.id) .relative() - .overflow_hidden() + .flex() + .flex_row() + .items_center() + .min_w_0() + .w_full() + .h(px(tab_h)) .bg(rgba_from(p.shell_high, 1.0)) .child( div() @@ -160,136 +238,234 @@ impl MoonTabStrip { ); if let Some(bounds) = self.bounds { - root = root - .absolute() - .left(px(bounds.x)) - .top(px(bounds.y)) - .w(px(bounds.w)) - .h(px(bounds.h)); + root = root.max_w(px(bounds.w)).max_h(px(bounds.h)); } - let mut x = self.padding_left; + let mut tabs_inner = h_flex() + .id("tabs-inner") + .relative() + .pl(px(self.padding_left)) + .gap(px(self.gap)) + .overflow_x_scroll() + .track_scroll(&scroll_handle); + for (ix, item) in self.items.into_iter().enumerate() { - let active = item.selected; - let disabled = item.disabled; - let closable = item.closable; - let fg = if active { p.text } else { p.text_muted }; - let fg_alpha = if disabled { 0.45 } else { 1.0 }; - - let mut tab = div() - .id(("moon-tab", ix)) - .absolute() - .left(px(x)) - .top(px(0.0)) - .w(px(item.width)) - .h(px(tokens.fit_height(28.0, 13.0, 7.5))) + let tab = render_moon_tab( + ix, + item, + p, + tokens.clone(), + self.on_click.clone(), + self.on_close.clone(), + ); + let bounds_rc = bounds_rc.clone(); + tabs_inner = tabs_inner.child( + div() + .flex_none() + .on_prepaint(move |bounds, _, _| { + if let Some(slot) = bounds_rc.borrow_mut().get_mut(ix) { + *slot = bounds; + } + }) + .child(tab), + ); + } + + root = root.child( + h_flex() + .id("tabs") + .flex_1() + .min_w_0() + .overflow_x_hidden() + .child(tabs_inner), + ); + + if self.overflow_menu { + root = root.child(div().flex_none().child(overflow_menu_dropdown( + strip_id, + item_metas, + on_click_menu, + ))); + } + + root + } +} + +impl RenderOnce for MoonTabStrip { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // ScrollHandle persist and layout both run here so they have a Window. + // Creating a new handle every frame is forbidden; render_with_theme uses + // window.use_keyed_state(..., ScrollHandle::default()). + let tokens = MoonTheme::active_tokens(cx); + self.render_with_theme(window, cx, MoonPalette::active(cx), tokens) + } +} + +fn render_moon_tab( + ix: usize, + item: MoonTabItem, + p: MoonPalette, + tokens: MoonThemeTokens, + on_click: Option, + on_close: Option, +) -> impl IntoElement { + let active = item.selected; + let disabled = item.disabled; + let closable = item.closable; + let fixed_width = item.width; + let fg = if active { p.text } else { p.text_muted }; + let fg_alpha = if disabled { 0.45 } else { 1.0 }; + + let label = MoonText::new(item.label) + .color(fg) + .alpha(fg_alpha) + .font_size(10.0) + .line_height(13.0) + .weight(if active { 600.0 } else { 400.0 }) + .mono(true) + .uppercase(false) + .render(); + + let mut tab = div() + .id(("moon-tab", ix)) + .relative() + .h(px(tokens.fit_height(28.0, 13.0, 7.5))) + .flex() + .flex_none() + .items_center() + .pl(px(tokens.ui(8.0))) + .pr(px(tokens.ui(if closable { 5.0 } else { 8.0 }))) + .gap(px(tokens.ui(8.0))) + .when(disabled, |this| this.cursor(CursorStyle::Arrow)) + .when(!disabled, |this| this.cursor_pointer()) + .when(!active && !disabled, |this| { + this.hover(move |this| this.bg(rgba_from(p.overlay, 0.018))) + .active(move |this| this.bg(rgba_from(p.overlay, 0.012))) + }); + + if let Some(width) = fixed_width { + tab = tab.w(px(width)); + // The label is the only child allowed to yield: `flex_1 + min_w_0` lets a long + // label clip inside the fixed-width tab instead of pushing the badge and the + // close button out of it, and `justify_center` centres it in whatever space is + // left. Centring here rather than on the tab row itself is deliberate — on the + // row it would centre the whole label+badge+close cluster and drag the close + // button inward. No top margin: the row is already `items_center`, so an extra + // one only pushes the text off the vertical axis. + tab = tab.child( + h_flex() + .flex_1() + .min_w_0() + .justify_center() + .overflow_hidden() + .child(label), + ); + } else { + tab = tab.child(label); + } + + if let Some(on_click) = on_click.clone() + && !disabled + { + tab = tab.on_click(move |event, window, cx| { + on_click(ix, event, window, cx); + }); + } + + if let Some(badge) = item.badge { + tab = tab.child( + MoonBadge::new(badge) + .size(MoonBadgeSize::Tiny) + .variant(if active { + MoonBadgeVariant::Solid + } else { + MoonBadgeVariant::Soft + }) + .bg_color(if active { p.accent } else { p.overlay }) + .bg_alpha(if active { 0.80 } else { 0.06 }) + .text_color(if active { p.shell } else { p.text_soft }) + .weight(600.0) + // No top margin: the badge shares the row's `items_center` axis with the + // label and the close button, and one would reintroduce the offset the + // label just lost. + .disabled(disabled) + .render_with_theme(p, tokens.clone()), + ); + } + + if closable { + let on_close = on_close.clone(); + tab = tab.child( + div() + .id(("moon-tab-close", ix)) + .w(px(tokens.ui(16.0))) + .h(px(tokens.ui(16.0))) .flex() .items_center() - .pl(px(tokens.ui(8.0))) - .pr(px(tokens.ui(if closable { 5.0 } else { 8.0 }))) - .gap(px(tokens.ui(8.0))) - .when(disabled, |this| this.cursor(CursorStyle::Arrow)) - .when(!disabled, |this| this.cursor_pointer()) - .when(!active && !disabled, |this| { - this.hover(move |this| this.bg(rgba_from(p.overlay, 0.018))) - .active(move |this| this.bg(rgba_from(p.overlay, 0.012))) - }) - .child( - // The label is the only child allowed to yield: `flex_1 + min_w_0` lets a long - // label clip inside the fixed-width tab instead of pushing the badge and the - // close button out of it, and `justify_center` centres it in whatever space is - // left. Centring here rather than on the tab row itself is deliberate — on the - // row it would centre the whole label+badge+close cluster and drag the close - // button inward. No top margin: the row is already `items_center`, so an extra - // one only pushes the text off the vertical axis. - super::foundation::h_flex() - .flex_1() - .min_w_0() - .justify_center() - .overflow_hidden() - .child( - MoonText::new(item.label) - .color(fg) - .alpha(fg_alpha) - .font_size(10.0) - .line_height(13.0) - .weight(if active { 600.0 } else { 400.0 }) - .mono(true) - .uppercase(false) - .render(), - ), - ); - - if let Some(on_click) = self.on_click.clone() - && !disabled - { - tab = tab.on_click(move |event, window, cx| { - on_click(ix, event, window, cx); - }); - } + .justify_center() + .rounded(px(tokens.ui(3.0))) + .text_size(px(tokens.font(10.0))) + .line_height(px(tokens.line_height(10.0))) + .text_color(rgba_from(p.text_muted, 0.90)) + .hover(move |this| this.bg(rgba_from(p.overlay, 0.045))) + .child("x") + .on_click(move |event, window, cx| { + if let Some(on_close) = &on_close { + on_close(ix, event, window, cx); + } + cx.stop_propagation(); + }), + ); + } - if let Some(badge) = item.badge { - tab = tab.child( - MoonBadge::new(badge) - .size(MoonBadgeSize::Tiny) - .variant(if active { - MoonBadgeVariant::Solid - } else { - MoonBadgeVariant::Soft - }) - .bg_color(if active { p.accent } else { p.overlay }) - .bg_alpha(if active { 0.80 } else { 0.06 }) - .text_color(if active { p.shell } else { p.text_soft }) - .weight(600.0) - // No top margin: the badge shares the row's `items_center` axis with the - // label and the close button, and one would reintroduce the offset the - // label just lost. - .disabled(disabled) - .render_with_theme(p, tokens.clone()), - ); - } + if active { + tab = tab.child(moon_active_tab_underline_scaled(p, tokens)); + } - if closable { - let on_close = self.on_close.clone(); - tab = tab.child( - div() - .id(("moon-tab-close", ix)) - .w(px(tokens.ui(16.0))) - .h(px(tokens.ui(16.0))) - .flex() - .items_center() - .justify_center() - .rounded(px(tokens.ui(3.0))) - .text_size(px(tokens.font(10.0))) - .line_height(px(tokens.line_height(10.0))) - .text_color(rgba_from(p.text_muted, 0.90)) - .hover(move |this| this.bg(rgba_from(p.overlay, 0.045))) - .child("x") - .on_click(move |event, window, cx| { - if let Some(on_close) = &on_close { - on_close(ix, event, window, cx); - } - cx.stop_propagation(); - }), - ); - } + tab +} - if active { - tab = tab.child(moon_active_tab_underline_scaled(p, tokens.clone())); +fn overflow_menu_dropdown( + strip_id: ElementId, + items: Vec<(SharedString, bool, bool)>, + on_click: Option, +) -> MoonDropdown { + let mut menu = MoonDropdown::new(format!("{strip_id}-overflow")) + .trigger_icon(MOON_ICON_CARET_DOWN) + .trigger_caret(false) + .trigger_variant(MoonButtonVariant::Ghost) + .trigger_size(MoonButtonSize::Micro) + .menu_size(MoonMenuSize::Compact); + for (ix, (label, selected, disabled)) in items.into_iter().enumerate() { + let mut item = MoonMenuItem::with_key(format!("{strip_id}-overflow-{ix}"), label) + .checked(selected) + .selected(selected) + .disabled(disabled); + if !disabled { + if let Some(on_click) = on_click.clone() { + item = item.on_click(move |event, window, cx| { + on_click(ix, event, window, cx); + }); } - - root = root.child(tab); - x += item.width + self.gap; } - - root + menu = menu.item(item); } + menu } -impl RenderOnce for MoonTabStrip { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let tokens = MoonTheme::active_tokens(cx); - self.render_with_theme(MoonPalette::active(cx), tokens) +fn scroll_selected_tab_into_view(handle: &ScrollHandle, tab: Bounds) { + let container = handle.bounds(); + if container.size.width <= px(0.0) || tab.size.width <= px(0.0) { + return; + } + let mut offset = handle.offset(); + if tab.left() + offset.x < container.left() { + offset.x = container.left() - tab.left(); + handle.set_offset(offset); + } else if tab.right() + offset.x > container.right() { + offset.x = container.right() - tab.right(); + handle.set_offset(offset); } } diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index 437cebd..9b121f2 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -3873,6 +3873,10 @@ "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn on_close( mut self, handler: impl Fn(usize, &ClickEvent, &mut Window, &mut App) + 'static, ) -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/tab.rs", + "signature": "pub fn overflow_menu(mut self, overflow_menu: bool) -> Self" + }, { "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn padding_left(mut self, padding_left: f32) -> Self" @@ -3883,11 +3887,11 @@ }, { "file": "crates/moon-ui-components/src/moon/tab.rs", - "signature": "pub fn render_with_palette(self, p: MoonPalette) -> impl IntoElement" + "signature": "pub fn render_with_palette( self, window: &mut Window, cx: &mut App, p: MoonPalette, ) -> impl IntoElement" }, { "file": "crates/moon-ui-components/src/moon/tab.rs", - "signature": "pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement" + "signature": "pub fn render_with_theme( self, window: &mut Window, cx: &mut App, p: MoonPalette, tokens: MoonThemeTokens, ) -> impl IntoElement" }, { "file": "crates/moon-ui-components/src/moon/tab.rs", diff --git a/docs/component-api-removals.json b/docs/component-api-removals.json index ee1bef1..0b72c58 100644 --- a/docs/component-api-removals.json +++ b/docs/component-api-removals.json @@ -125,6 +125,16 @@ "file": "crates/moon-ui-components/src/moon/dropdown.rs", "signature": "pub fn header(mut self, header: impl IntoElement) -> Self", "reason": "MoonPopupMenu::header now takes its height as a first argument, header(height_ui, element). A pinned header is held outside the scrolling rows, so its height must come out of the row list's budget, and the virtualized list is sized in pixels before its siblings are laid out and cannot measure it. Pairing the height with the element makes the declaration unavoidable: the one-argument form left the height at zero, which pinned the header to zero height and rendered it invisible." + }, + { + "file": "crates/moon-ui-components/src/moon/tab.rs", + "signature": "pub fn render_with_palette(self, p: MoonPalette) -> impl IntoElement", + "reason": "MoonTabStrip overflow persists a ScrollHandle via window.use_keyed_state, so render_with_palette now takes Window and App. The palette-only form could not keep the handle across frames and recreated scroll state every paint." + }, + { + "file": "crates/moon-ui-components/src/moon/tab.rs", + "signature": "pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement", + "reason": "Same as render_with_palette: the themed path is the one that lays out overflow, so it takes Window and App to persist the keyed ScrollHandle instead of building a new one each frame." } ] }