More consistent split panels and tabs
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss index 8210721..6d80735 100644 --- a/ui/src/assets/common.scss +++ b/ui/src/assets/common.scss
@@ -33,6 +33,10 @@ -webkit-tap-highlight-color: transparent; } +.pf-muted { + color: var(--pf-color-text-muted); +} + html { font-size: 16px; font-family: var(--pf-font);
diff --git a/ui/src/assets/theme_provider.scss b/ui/src/assets/theme_provider.scss index f93a0a0..26dc955 100644 --- a/ui/src/assets/theme_provider.scss +++ b/ui/src/assets/theme_provider.scss
@@ -36,11 +36,11 @@ --pf-color-text-hint: #808080; --pf-color-box-shadow: rgba(0, 0, 0, 0.2); --pf-color-neutral: gray; - --pf-color-accent: #2667e7; + --pf-color-accent: #3d7cc4; --pf-color-text-on-accent: white; --pf-color-highlight: #ffe263; - --pf-color-primary: #3d5688; + --pf-color-primary: #3d7cc4; --pf-color-text-on-primary: white; --pf-color-danger: rgb(202, 38, 38); --pf-color-text-on-danger: white; @@ -64,8 +64,8 @@ } &--dark { - --pf-color-background: #232426; - --pf-color-background-secondary: #383a3e; + --pf-color-background: #1d1d1d; + --pf-color-background-secondary: #2d2d2d; --pf-color-interactive-base: white; --pf-color-text: #dce0e2; --pf-color-text-muted: #a0a2a5; @@ -75,11 +75,11 @@ --pf-color-text-hint: #9aa0a6; --pf-color-box-shadow: rgba(0, 0, 0, 0.4); --pf-color-neutral: gray; - --pf-color-accent: #2667e7; + --pf-color-accent: #3d7cc4; --pf-color-text-on-accent: white; --pf-color-highlight: #5f4d06; - --pf-color-primary: #7197e3; + --pf-color-primary: #4a8ad4; --pf-color-text-on-primary: #333; --pf-color-danger: rgb(230, 90, 90); --pf-color-text-on-danger: #333;
diff --git a/ui/src/assets/timeline_page.scss b/ui/src/assets/timeline_page.scss index 032ef29..f590d40 100644 --- a/ui/src/assets/timeline_page.scss +++ b/ui/src/assets/timeline_page.scss
@@ -16,6 +16,7 @@ isolation: isolate; overflow: hidden; height: 100%; + font-size: 14px; .pf-resize-handle { flex-shrink: 0;
diff --git a/ui/src/assets/widgets/split_panel.scss b/ui/src/assets/widgets/split_panel.scss index d9f3ceb..2fb9898 100644 --- a/ui/src/assets/widgets/split_panel.scss +++ b/ui/src/assets/widgets/split_panel.scss
@@ -12,94 +12,100 @@ // See the License for the specific language governing permissions and // limitations under the License. -@import "../theme"; - .pf-split-panel { - height: 100%; display: flex; + width: 100%; + height: 100%; + overflow: hidden; +} + +.pf-split-horizontal { + flex-direction: row; +} + +.pf-split-vertical { flex-direction: column; +} - &__main { - flex: 1; // Fill the remaining space - overflow: auto; +.pf-split-panel__first, +.pf-split-panel__second { + overflow: hidden; + min-width: 0; + min-height: 0; +} - // Making this a flex element allows multiple individually overflowing - // sections to be added to the main content area. - display: flex; - flex-direction: column; // Users normally expect content to stack vertically - } +.pf-split-panel__handle { + flex-shrink: 0; + background-color: var(--pf-color-background-secondary); + touch-action: none; +} - &__handle { - gap: 2px; - background-color: var(--pf-color-background-secondary); - border-top: 1px solid var(--pf-color-border); - cursor: row-resize; - display: flex; - align-items: baseline; - padding-inline: 2px; - align-items: center; - } +.pf-split-horizontal > .pf-split-panel__handle { + width: 4px; + cursor: col-resize; + position: relative; +} - &__tabs { - padding-inline: 3px; // Leave some space for the drop shadow - display: flex; - overflow: hidden; - flex: 1; // Expand to fill remaining space. +// Larger hit area for easier grabbing +.pf-split-horizontal > .pf-split-panel__handle::before { + content: ""; + position: absolute; + inset: 0 -6px; +} - // Align tabs to the bottom of the handle & overlap the drawer border so - // that the active tab looks like it is part of the drawer. - align-self: flex-end; - margin-bottom: -1px; - } +.pf-split-vertical > .pf-split-panel__handle { + height: 4px; + cursor: row-resize; + position: relative; +} - &__tab { - display: flex; - align-items: baseline; - gap: 2px; +// Larger hit area for easier grabbing +.pf-split-vertical > .pf-split-panel__handle::before { + content: ""; + position: absolute; + inset: -6px 0; +} - font-family: var(--pf-font-compact); - font-size: 14px; +.pf-split-panel__handle:hover, +.pf-split-panel__handle.pf-split-handle--active { + background-color: var(--pf-color-accent); +} - padding: 5px; // Space around the tab content - margin-top: 2px; // Space between tab and top of handle - cursor: pointer; // Active tab is not clickable +// Grip pattern indicator +.pf-split-panel__handle::after { + content: ""; + display: block; + position: absolute; +} - border-radius: 3px 3px 0 0; // Rounded top corners only - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - border-style: solid; - border-color: var(--pf-color-border-secondary); - border-width: 1px 1px 0 1px; +.pf-split-horizontal > .pf-split-panel__handle::after { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2px; + height: 24px; + background: repeating-linear-gradient( + to bottom, + var(--pf-color-text-muted) 0px, + var(--pf-color-text-muted) 2px, + transparent 2px, + transparent 4px + ); + opacity: 0.5; +} - &:not(:first-child) { - // Merge borders - margin-left: -1px; - } - - &:hover { - background: color_hover(transparent); - } - - &.pf-split-panel__tab--active { - cursor: default; - background-color: var(--pf-color-background); - box-shadow: var(--pf-color-box-shadow) 0px 0px 3px; - border-color: var(--pf-color-border); - z-index: 1; - } - } - - &__tab-title { - margin: 0px 4px; - overflow: hidden; - user-select: none; - } - - &__drawer { - flex-shrink: 0; - overflow: auto; - box-shadow: var(--pf-color-box-shadow) 0px 0px 3px; - border-top: 1px solid var(--pf-color-border); - } +.pf-split-vertical > .pf-split-panel__handle::after { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 24px; + height: 2px; + background: repeating-linear-gradient( + to right, + var(--pf-color-text-muted) 0px, + var(--pf-color-text-muted) 2px, + transparent 2px, + transparent 4px + ); + opacity: 0.5; }
diff --git a/ui/src/assets/widgets/tabs.scss b/ui/src/assets/widgets/tabs.scss index 29ef45c..792aa36 100644 --- a/ui/src/assets/widgets/tabs.scss +++ b/ui/src/assets/widgets/tabs.scss
@@ -14,45 +14,74 @@ .pf-tabs { display: flex; - align-items: baseline; + flex-direction: column; + min-height: 0; + background-color: var(--pf-color-background); + font-family: var(--pf-font-compact); + + &__header { + display: flex; + align-items: center; + border-bottom: 1px solid var(--pf-color-border); + padding: 4px 4px 0; + flex-shrink: 0; + } + + &__left-content { + display: flex; + align-items: center; + flex-shrink: 0; + } &__tabs { display: flex; - overflow: hidden; + gap: 1px; flex: 1; + min-width: 0; + } + + &__right-content { + display: flex; + align-items: center; + flex-shrink: 0; } &__tab { - color: var(--pf-color-text-muted); - padding: 4px; - margin-top: 3px; + user-select: none; + display: inline-flex; align-items: center; + gap: 4px; + padding: 3px 8px; + color: var(--pf-color-text-muted); + background: transparent; + border: 1px solid transparent; + border-bottom: none; + border-radius: 4px 4px 0 0; cursor: pointer; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - display: flex; - align-items: baseline; - gap: 2px; &:hover { + color: var(--pf-color-text); background-color: color_hover(transparent); } &[active] { - color: var(--pf-color-primary); - border-bottom: solid 2px var(--pf-color-primary); + color: var(--pf-color-text); + background-color: var(--pf-color-background-secondary); + border-color: var(--pf-color-border); + position: relative; + z-index: 1; + margin-bottom: -1px; + padding-bottom: 4px; cursor: default; } - - &:nth-child(1) { - margin-left: 3px; - } } &__tab-title { - margin: 0px 4px; overflow: hidden; + text-overflow: ellipsis; } &__tab-icon { @@ -66,4 +95,11 @@ align-self: center; } } + + &__content { + flex: 1; + overflow: auto; + min-height: 0; + background-color: var(--pf-color-background-secondary); + } }
diff --git a/ui/src/components/details/sql_table_tab.ts b/ui/src/components/details/sql_table_tab.ts index 2547dc2..c1cc6df 100644 --- a/ui/src/components/details/sql_table_tab.ts +++ b/ui/src/components/details/sql_table_tab.ts
@@ -36,7 +36,7 @@ import {SqlBarChart, SqlBarChartState} from '../widgets/charts/sql_bar_chart'; import {SqlHistogram, SqlHistogramState} from '../widgets/charts/sql_histogram'; import {sqlColumnId} from '../widgets/sql/table/sql_column'; -import {TabOption, TabStrip} from '../../widgets/tabs'; +import {Tab as TabDef, Tabs} from '../../widgets/tabs'; import {Gate} from '../../base/mithril_utils'; import {isQuantitativeType} from '../../trace_processor/perfetto_sql_type'; @@ -194,7 +194,7 @@ render() { const hasFilters = this.tableState.filters.get().length > 0; - const tabs: (TabOption & {content: m.Children})[] = [ + const tabs: TabDef[] = [ { key: this.tableState.uuid, title: 'Table', @@ -298,7 +298,7 @@ m('.pf-sql-table__toolbar', [ hasFilters && renderFilters(this.tableState.filters), tabs.length > 1 && - m(TabStrip, { + m(Tabs, { tabs, currentTabKey: this.selectedTab, onTabChange: (key) => (this.selectedTab = key),
diff --git a/ui/src/core/tab_manager.ts b/ui/src/core/tab_manager.ts index 94306cd..53bd8a4 100644 --- a/ui/src/core/tab_manager.ts +++ b/ui/src/core/tab_manager.ts
@@ -12,19 +12,34 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {assertUnreachable} from '../base/logging'; import {DetailsPanel} from '../public/details_panel'; import {TabDescriptor, TabManager} from '../public/tab'; -import { - SplitPanelDrawerVisibility, - toggleVisibility, -} from '../widgets/split_panel'; export interface ResolvedTab { uri: string; tab?: TabDescriptor; } -export type TabPanelVisibility = 'COLLAPSED' | 'VISIBLE' | 'FULLSCREEN'; +export enum TabPanelVisibility { + VISIBLE, + FULLSCREEN, + COLLAPSED, +} + +export function toggleTabPanelVisibility( + visibility: TabPanelVisibility, +): TabPanelVisibility { + switch (visibility) { + case TabPanelVisibility.COLLAPSED: + case TabPanelVisibility.FULLSCREEN: + return TabPanelVisibility.VISIBLE; + case TabPanelVisibility.VISIBLE: + return TabPanelVisibility.COLLAPSED; + default: + assertUnreachable(visibility); + } +} /** * Stores tab & current selection section registries. @@ -37,7 +52,7 @@ private _instantiatedTabs = new Map<string, TabDescriptor>(); private _openTabs: string[] = []; // URIs of the tabs open. private _currentTab: string = 'current_selection'; - private _tabPanelVisibility = SplitPanelDrawerVisibility.COLLAPSED; + private _tabPanelVisibility = TabPanelVisibility.COLLAPSED; private _tabPanelVisibilityChanged = false; [Symbol.dispose]() { @@ -86,9 +101,9 @@ // they are. if ( !this._tabPanelVisibilityChanged && - this._tabPanelVisibility === SplitPanelDrawerVisibility.COLLAPSED + this._tabPanelVisibility === TabPanelVisibility.COLLAPSED ) { - this.setTabPanelVisibility(SplitPanelDrawerVisibility.VISIBLE); + this.setTabPanelVisibility(TabPanelVisibility.VISIBLE); } } @@ -191,13 +206,15 @@ return tabs; } - setTabPanelVisibility(visibility: SplitPanelDrawerVisibility): void { + setTabPanelVisibility(visibility: TabPanelVisibility): void { this._tabPanelVisibility = visibility; this._tabPanelVisibilityChanged = true; } toggleTabPanelVisibility(): void { - this.setTabPanelVisibility(toggleVisibility(this._tabPanelVisibility)); + this.setTabPanelVisibility( + toggleTabPanelVisibility(this._tabPanelVisibility), + ); } get tabPanelVisibility() {
diff --git a/ui/src/core_plugins/dev.perfetto.MultiTraceOpen/multi_trace_modal.ts b/ui/src/core_plugins/dev.perfetto.MultiTraceOpen/multi_trace_modal.ts index b6306a4..b41b329 100644 --- a/ui/src/core_plugins/dev.perfetto.MultiTraceOpen/multi_trace_modal.ts +++ b/ui/src/core_plugins/dev.perfetto.MultiTraceOpen/multi_trace_modal.ts
@@ -23,7 +23,7 @@ import {Callout} from '../../widgets/callout'; import {Spinner} from '../../widgets/spinner'; import {Stack} from '../../widgets/stack'; -import {TabStrip, TabOption} from '../../widgets/tabs'; +import {Tab, Tabs} from '../../widgets/tabs'; import {TextParagraph} from '../../widgets/text_paragraph'; import {MultiTraceController} from './multi_trace_controller'; import {TraceFile} from './multi_trace_types'; @@ -67,7 +67,7 @@ } private renderDescription() { - const tabs: TabOption[] = [ + const tabs: Tab[] = [ {key: 'synchronous', title: 'Synchronous Traces'}, {key: 'cross-machine', title: 'Cross-Machine Traces'}, {key: 'comparison', title: 'Trace Comparison'}, @@ -79,7 +79,7 @@ className: 'pf-multi-trace-modal__description-panel', orientation: 'vertical', }, - m(TabStrip, { + m(Tabs, { className: 'pf-multi-trace-modal__tabs', tabs, currentTabKey: this.currentTab,
diff --git a/ui/src/frontend/timeline_page/tab_panel.ts b/ui/src/frontend/timeline_page/tab_panel.ts index a0f7d36..eacbacd 100644 --- a/ui/src/frontend/timeline_page/tab_panel.ts +++ b/ui/src/frontend/timeline_page/tab_panel.ts
@@ -13,13 +13,17 @@ // limitations under the License. import m from 'mithril'; +import { + TabPanelVisibility, + toggleTabPanelVisibility, +} from '../../core/tab_manager'; import {TraceImpl} from '../../core/trace_impl'; -import {Button} from '../../widgets/button'; +import {Button, ButtonBar} from '../../widgets/button'; import {MenuItem, PopupMenu} from '../../widgets/menu'; -import {Tab, SplitPanel} from '../../widgets/split_panel'; +import {SplitPanel} from '../../widgets/split_panel'; +import {Tab, Tabs} from '../../widgets/tabs'; import {DEFAULT_DETAILS_CONTENT_HEIGHT} from '../css_constants'; import {CurrentSelectionTab} from './current_selection_tab'; -import {Gate} from '../../base/mithril_utils'; export interface TabPanelAttrs { readonly trace: TraceImpl; @@ -27,82 +31,117 @@ } export class TabPanel implements m.ClassComponent<TabPanelAttrs> { - view({ - attrs, - children, - }: m.Vnode<TabPanelAttrs, this>): m.Children | null | void { - const {tabs, drawerContent} = this.gatherTabs(attrs.trace); + private drawerHeight = DEFAULT_DETAILS_CONTENT_HEIGHT; + private containerHeight = 0; - return m( - SplitPanel, - { - className: attrs.className, - startingHeight: DEFAULT_DETAILS_CONTENT_HEIGHT, - leftHandleContent: this.renderDropdownMenu(attrs.trace), - tabs, - drawerContent, - visibility: attrs.trace.tabs.tabPanelVisibility, - onVisibilityChange: (visibility) => - attrs.trace.tabs.setTabPanelVisibility(visibility), + view({attrs, children}: m.Vnode<TabPanelAttrs, this>): m.Children { + const trace = attrs.trace; + const visibility = trace.tabs.tabPanelVisibility; + + // Calculate effective height based on visibility + let effectiveHeight: number; + switch (visibility) { + case TabPanelVisibility.COLLAPSED: + effectiveHeight = 0; + break; + case TabPanelVisibility.FULLSCREEN: + effectiveHeight = this.containerHeight; + break; + case TabPanelVisibility.VISIBLE: + default: + effectiveHeight = Math.min( + Math.max(this.drawerHeight, 0), + this.containerHeight, + ); + break; + } + + const tabs = this.buildTabs(trace); + const currentTabUri = trace.tabs.currentTabUri; + + return m(SplitPanel, { + className: attrs.className, + direction: 'vertical', + split: {fixed: {panel: 'second', size: effectiveHeight}}, + minSize: 0, + onResize: (size) => { + this.drawerHeight = size; + // When user resizes, switch to VISIBLE mode + if (visibility !== TabPanelVisibility.VISIBLE) { + trace.tabs.setTabPanelVisibility(TabPanelVisibility.VISIBLE); + } }, - children, - ); + firstPanel: children, + secondPanel: m(Tabs, { + tabs, + currentTabKey: currentTabUri, + onTabChange: (key) => trace.tabs.showTab(key), + leftContent: this.renderDropdownMenu(trace), + rightContent: this.renderVisibilityButtons(trace, visibility), + fillHeight: true, + }), + }); } - private gatherTabs(trace: TraceImpl) { + oncreate(vnode: m.VnodeDOM<TabPanelAttrs, this>) { + this.setupResizeObserver(vnode); + } + + onupdate(vnode: m.VnodeDOM<TabPanelAttrs, this>) { + // Re-measure container on update in case it changed + const container = vnode.dom as HTMLElement; + if (container.parentElement) { + this.containerHeight = container.parentElement.clientHeight; + } + } + + private setupResizeObserver(vnode: m.VnodeDOM<TabPanelAttrs, this>) { + const container = vnode.dom as HTMLElement; + if (container.parentElement) { + this.containerHeight = container.parentElement.clientHeight; + const resizeObs = new ResizeObserver(() => { + this.containerHeight = container.parentElement!.clientHeight; + m.redraw(); + }); + resizeObs.observe(container.parentElement); + } + } + + private buildTabs(trace: TraceImpl): Tab[] { const tabMan = trace.tabs; const tabList = trace.tabs.openTabsUri; const resolvedTabs = tabMan.resolveTabs(tabList); const currentTabUri = trace.tabs.currentTabUri; - const drawerContent: m.Child[] = []; + const tabs: Tab[] = []; - const tabs = resolvedTabs.map(({uri, tab: tabDesc}) => { - const active = uri === currentTabUri; - if (tabDesc) { - drawerContent.push(m(Gate, {open: active}, tabDesc.content.render())); - return m( - Tab, - { - active, - onclick: () => trace.tabs.showTab(uri), - hasCloseButton: true, - onClose: () => { - trace.tabs.hideTab(uri); - }, - }, - tabDesc.content.getTitle(), - ); - } else { - return m( - Tab, - { - active, - onclick: () => trace.tabs.showTab(uri), - }, - 'Tab does not exist', - ); - } + // Add the permanent current selection tab first + tabs.push({ + key: 'current_selection', + title: 'Current Selection', + content: m(CurrentSelectionTab, {trace}), }); - // Add the permanent current selection tab to the front of the list of tabs - const active = currentTabUri === 'current_selection'; - drawerContent.unshift( - m(Gate, {open: active}, m(CurrentSelectionTab, {trace})), - ); + // Add dynamic tabs + for (const {uri, tab: tabDesc} of resolvedTabs) { + if (tabDesc) { + tabs.push({ + key: uri, + title: tabDesc.content.getTitle(), + content: currentTabUri === uri ? tabDesc.content.render() : undefined, + hasCloseButton: true, + onClose: () => trace.tabs.hideTab(uri), + }); + } else { + tabs.push({ + key: uri, + title: 'Tab does not exist', + content: undefined, + }); + } + } - tabs.unshift( - m( - Tab, - { - active, - onclick: () => trace.tabs.showTab('current_selection'), - }, - 'Current Selection', - ), - ); - - return {tabs, drawerContent}; + return tabs; } private renderDropdownMenu(trace: TraceImpl): m.Child { @@ -136,4 +175,31 @@ }), ); } + + private renderVisibilityButtons( + trace: TraceImpl, + visibility: TabPanelVisibility, + ): m.Child { + const isClosed = visibility === TabPanelVisibility.COLLAPSED; + return m( + ButtonBar, + m(Button, { + title: 'Open fullscreen', + disabled: visibility === TabPanelVisibility.FULLSCREEN, + icon: 'vertical_align_top', + onclick: () => { + trace.tabs.setTabPanelVisibility(TabPanelVisibility.FULLSCREEN); + }, + }), + m(Button, { + onclick: () => { + trace.tabs.setTabPanelVisibility( + toggleTabPanelVisibility(visibility), + ); + }, + title: isClosed ? 'Show panel' : 'Hide panel', + icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', + }), + ); + } }
diff --git a/ui/src/frontend/timeline_page/timeline_page.ts b/ui/src/frontend/timeline_page/timeline_page.ts index a9156e4..8a224f1 100644 --- a/ui/src/frontend/timeline_page/timeline_page.ts +++ b/ui/src/frontend/timeline_page/timeline_page.ts
@@ -27,7 +27,7 @@ import {KeyboardNavigationHandler} from './wasd_navigation_handler'; import {trackMatchesFilter} from '../../core/track_manager'; import {TraceImpl} from '../../core/trace_impl'; -import {ResizeHandle} from '../../widgets/resize_handle'; +import {SplitPanel} from '../../widgets/split_panel'; const OVERVIEW_PANEL_FLAG = featureFlags.register({ id: 'overviewVisible', @@ -53,7 +53,7 @@ class TimelinePage implements m.ClassComponent<TimelinePageAttrs> { private readonly trash = new DisposableStack(); private timelineBounds?: Rect2D; - private pinnedTracksHeight: number | 'auto' = 'auto'; + private pinnedTracksHeight = 200; view({attrs}: m.CVnode<TimelinePageAttrs>) { const {trace} = attrs; @@ -76,51 +76,39 @@ onTimelineBoundsChange: (rect) => (this.timelineBounds = rect), }), // Hide tracks while the trace is loading to prevent thrashing. - !AppImpl.instance.isLoadingTrace && [ - // Don't render pinned tracks if we have none. - trace.currentWorkspace.pinnedTracks.length > 0 && [ - m( - '.pf-timeline-page__pinned-track-tree', - { - style: - this.pinnedTracksHeight === 'auto' - ? {maxHeight: '40%'} - : {height: `${this.pinnedTracksHeight}px`}, - }, - m(TrackTreeView, { + !AppImpl.instance.isLoadingTrace && + (trace.currentWorkspace.pinnedTracks.length > 0 + ? m(SplitPanel, { + direction: 'vertical', + split: {fixed: {panel: 'first', size: this.pinnedTracksHeight}}, + minSize: 50, + onResize: (size: number) => { + this.pinnedTracksHeight = size; + }, + firstPanel: m(TrackTreeView, { + trace, + className: 'pf-timeline-page__pinned-track-tree', + rootNode: trace.currentWorkspace.pinnedTracksNode, + canReorderNodes: true, + scrollToNewTracks: true, + }), + secondPanel: m(TrackTreeView, { + trace, + className: 'pf-timeline-page__scrolling-track-tree', + rootNode: trace.currentWorkspace.tracks, + canReorderNodes: trace.currentWorkspace.userEditable, + canRemoveNodes: trace.currentWorkspace.userEditable, + trackFilter: (track) => trackMatchesFilter(trace, track), + }), + }) + : m(TrackTreeView, { trace, - rootNode: trace.currentWorkspace.pinnedTracksNode, - canReorderNodes: true, - scrollToNewTracks: true, - }), - ), - m(ResizeHandle, { - onResize: (deltaPx: number) => { - if (this.pinnedTracksHeight === 'auto') { - this.pinnedTracksHeight = toHTMLElement( - document.querySelector( - '.pf-timeline-page__pinned-track-tree', - )!, - ).getBoundingClientRect().height; - } - this.pinnedTracksHeight = this.pinnedTracksHeight + deltaPx; - m.redraw(); - }, - ondblclick: () => { - this.pinnedTracksHeight = 'auto'; - }, - }), - ], - - m(TrackTreeView, { - trace, - className: 'pf-timeline-page__scrolling-track-tree', - rootNode: trace.currentWorkspace.tracks, - canReorderNodes: trace.currentWorkspace.userEditable, - canRemoveNodes: trace.currentWorkspace.userEditable, - trackFilter: (track) => trackMatchesFilter(trace, track), - }), - ], + className: 'pf-timeline-page__scrolling-track-tree', + rootNode: trace.currentWorkspace.tracks, + canReorderNodes: trace.currentWorkspace.userEditable, + canRemoveNodes: trace.currentWorkspace.userEditable, + trackFilter: (track) => trackMatchesFilter(trace, track), + })), ), ); }
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.scss b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.scss index c2d48fa..88d4ed3 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.scss +++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.scss
@@ -36,34 +36,34 @@ height: 100%; - // SplitPanel's main section needs to have the grid layout - .pf-split-panel__main { - display: grid; - grid-template-columns: 1fr auto auto auto; - overflow: hidden; - } - .pf-qb-node-graph { - grid-column: 1; overflow: auto; position: relative; + height: 100%; } - // Position the resize handle in the grid, but let the widget handle its own styling - .pf-resize-handle { - grid-column: 2; + .pf-qb-results-panel { + overflow: auto; + height: 100%; + } + + .pf-qb-sidebar { + display: flex; + flex-direction: row; + height: 100%; + overflow: hidden; } .pf-qb-explorer { - grid-column: 3; overflow: hidden; height: 100%; + flex-shrink: 0; // Width is controlled by inline style for smooth resizing } .pf-qb-side-panel { - grid-column: 4; width: var(--pf-qb-side-panel-width); + flex-shrink: 0; height: 100%; display: flex; flex-direction: column; @@ -156,10 +156,6 @@ } &.explorer-collapsed { - .pf-resize-handle { - display: none; - } - .pf-qb-explorer { border-left: none; overflow: hidden;
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts index 0bd0bd5..4a3cf6a 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts +++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts
@@ -65,7 +65,6 @@ // - this.dataSource: Wrapped data source for DataGrid display import m from 'mithril'; -import {classNames} from '../../../base/classnames'; import {Button} from '../../../widgets/button'; import {Icons} from '../../../base/semantic_icons'; import {Icon} from '../../../widgets/icon'; @@ -76,10 +75,7 @@ import {NodeExplorer} from './node_explorer'; import {Graph} from './graph/graph'; import {DataExplorer} from './data_explorer'; -import { - SplitPanel, - SplitPanelDrawerVisibility, -} from '../../../widgets/split_panel'; +import {SplitPanel} from '../../../widgets/split_panel'; import {SQLDataSource} from '../../../components/widgets/datagrid/sql_data_source'; import {createSimpleSchema} from '../../../components/widgets/datagrid/sql_schema'; import {QueryResponse} from '../../../components/query_table/queries'; @@ -90,7 +86,6 @@ import {DataExplorerEmptyState, RoundActionButton} from './widgets'; import {UIFilter} from './operations/filter'; import {QueryExecutionService} from './query_execution_service'; -import {ResizeHandle} from '../../../widgets/resize_handle'; import {getAllDownstreamNodes} from './graph_utils'; import {Popup, PopupPosition} from '../../../widgets/popup'; import {DataSource} from '../../../components/widgets/datagrid/data_source'; @@ -194,12 +189,8 @@ private previousSelectedNode?: QueryNode; private response?: QueryResponse; private dataSource?: DataSource; - private drawerVisibility = SplitPanelDrawerVisibility.COLLAPSED; private selectedView: SelectedView = SelectedView.kInfo; - private readonly MIN_SIDEBAR_WIDTH = 250; - private readonly MAX_SIDEBAR_WIDTH = 800; private readonly DEFAULT_SIDEBAR_WIDTH = 500; - private hasEverSelectedNode = false; constructor({attrs}: m.Vnode<BuilderAttrs>) { this.trace = attrs.trace; @@ -207,18 +198,6 @@ this.queryExecutionService = attrs.queryExecutionService; } - private handleSidebarResize(attrs: BuilderAttrs, deltaPx: number) { - const currentWidth = attrs.sidebarWidth ?? this.DEFAULT_SIDEBAR_WIDTH; - // Subtract delta because the handle is on the left edge of the sidebar - // Dragging left (negative delta) = narrower sidebar (positive change) - // Dragging right (positive delta) = wider sidebar (negative change) - const newWidth = Math.max( - this.MIN_SIDEBAR_WIDTH, - Math.min(this.MAX_SIDEBAR_WIDTH, currentWidth - deltaPx), - ); - attrs.onSidebarWidthChange?.(newWidth); - } - view({attrs}: m.CVnode<BuilderAttrs>) { const {trace, rootNodes, onNodeSelected, selectedNode, onClearAllNodes} = attrs; @@ -228,12 +207,6 @@ this.isQueryRunning = false; this.isAnalyzing = false; - // Show drawer the first time any node is selected - if (!this.hasEverSelectedNode) { - this.drawerVisibility = SplitPanelDrawerVisibility.VISIBLE; - this.hasEverSelectedNode = true; - } - const hasModifyPanel = selectedNode.nodeSpecificModify() != null; // If current view is Info, switch to Modify (if available) when selecting a new node if (this.selectedView === SelectedView.kInfo && hasModifyPanel) { @@ -251,17 +224,129 @@ // When transitioning to unselected state with collapsed explorer, reappear at minimum size if (!selectedNode && this.previousSelectedNode && isExplorerCollapsed) { attrs.onExplorerCollapsedChange?.(false); - attrs.onSidebarWidthChange?.(this.MIN_SIDEBAR_WIDTH); } this.previousSelectedNode = selectedNode; - const layoutClasses = - classNames( - 'pf-query-builder-layout', - isExplorerCollapsed && 'explorer-collapsed', - ) || ''; + const layoutClasses = isExplorerCollapsed ? 'explorer-collapsed' : ''; + // Node graph panel with floating controls + const graphPanel = m( + '.pf-qb-node-graph', + m(Graph, { + rootNodes, + selectedNode, + onNodeSelected, + nodeLayouts: attrs.nodeLayouts, + labels: attrs.labels, + onNodeLayoutChange: attrs.onNodeLayoutChange, + onLabelsChange: attrs.onLabelsChange, + onDeselect: attrs.onDeselect, + onAddSourceNode: attrs.onAddSourceNode, + onClearAllNodes, + onDuplicateNode: attrs.onDuplicateNode, + onAddOperationNode: (id, node) => attrs.onAddOperationNode(id, node), + onDeleteNode: attrs.onDeleteNode, + onConnectionRemove: attrs.onConnectionRemove, + onImport: attrs.onImport, + onExport: attrs.onExport, + onRecenterReady: attrs.onRecenterReady, + }), + selectedNode && + m( + '.pf-qb-floating-controls', + !selectedNode.validate() && + m( + Popup, + { + trigger: m( + '.pf-qb-floating-warning', + m(Icon, { + icon: Icons.Warning, + filled: true, + className: 'pf-qb-warning-icon', + title: 'Click to see error details', + }), + ), + position: PopupPosition.BottomEnd, + showArrow: true, + }, + m( + '.pf-error-details', + selectedNode.state.issues?.getTitle() ?? 'No error details', + ), + ), + ), + m( + '.pf-qb-floating-controls-bottom', + attrs.onUndo && + RoundActionButton({ + icon: Icons.Undo, + title: 'Undo (Ctrl+Z)', + onclick: attrs.onUndo, + disabled: !attrs.canUndo, + }), + attrs.onRedo && + RoundActionButton({ + icon: Icons.Redo, + title: 'Redo (Ctrl+Shift+Z)', + onclick: attrs.onRedo, + disabled: !attrs.canRedo, + }), + ), + ); + + // Results panel (DataExplorer) + const resultsPanel = m( + '.pf-qb-results-panel', + selectedNode + ? m(DataExplorer, { + trace: this.trace, + query: this.query, + node: selectedNode, + response: this.response, + dataSource: this.dataSource, + isQueryRunning: this.isQueryRunning, + isAnalyzing: this.isAnalyzing, + onchange: () => { + attrs.onNodeStateChange?.(); + }, + onFilterAdd: (filter, filterOperator) => { + attrs.onFilterAdd(selectedNode, filter, filterOperator); + }, + isFullScreen: false, + onFullScreenToggle: () => {}, + onExecute: async () => { + if (!selectedNode.validate()) { + console.warn( + `Cannot execute query: node ${selectedNode.nodeId} failed validation`, + ); + return; + } + + // Use the centralized service with manual=true. + // The service handles both analysis and execution. + await this.queryExecutionService.processNode( + selectedNode, + this.trace.engine, + { + manual: true, // User explicitly clicked "Run Query" + hasExistingResult: this.queryExecuted, + ...this.createManualExecutionCallbacks(selectedNode), + }, + ); + }, + onExportToTimeline: () => { + this.exportToTimeline(selectedNode); + }, + }) + : m(DataExplorerEmptyState, { + icon: 'info', + title: 'Select a node to see the data', + }), + ); + + // Explorer sidebar const explorer = selectedNode ? m(NodeExplorer, { // The key to force mithril to re-create the component when the @@ -322,146 +407,14 @@ }), ); - return m( - SplitPanel, - { - className: layoutClasses, - visibility: this.drawerVisibility, - onVisibilityChange: (v) => { - this.drawerVisibility = v; - }, - startingHeight: 300, - drawerContent: selectedNode - ? m(DataExplorer, { - trace: this.trace, - query: this.query, - node: selectedNode, - response: this.response, - dataSource: this.dataSource, - isQueryRunning: this.isQueryRunning, - isAnalyzing: this.isAnalyzing, - onchange: () => { - attrs.onNodeStateChange?.(); - }, - onFilterAdd: (filter, filterOperator) => { - attrs.onFilterAdd(selectedNode, filter, filterOperator); - }, - isFullScreen: - this.drawerVisibility === SplitPanelDrawerVisibility.FULLSCREEN, - onFullScreenToggle: () => { - if ( - this.drawerVisibility === - SplitPanelDrawerVisibility.FULLSCREEN - ) { - this.drawerVisibility = SplitPanelDrawerVisibility.VISIBLE; - } else { - this.drawerVisibility = SplitPanelDrawerVisibility.FULLSCREEN; - } - }, - onExecute: async () => { - if (!selectedNode.validate()) { - console.warn( - `Cannot execute query: node ${selectedNode.nodeId} failed validation`, - ); - return; - } - - // Use the centralized service with manual=true. - // The service handles both analysis and execution. - await this.queryExecutionService.processNode( - selectedNode, - this.trace.engine, - { - manual: true, // User explicitly clicked "Run Query" - hasExistingResult: this.queryExecuted, - ...this.createManualExecutionCallbacks(selectedNode), - }, - ); - }, - onExportToTimeline: () => { - this.exportToTimeline(selectedNode); - }, - }) - : m(DataExplorerEmptyState, { - icon: 'info', - title: 'Select a node to see the data', - }), - }, - m( - '.pf-qb-node-graph', - m(Graph, { - rootNodes, - selectedNode, - onNodeSelected, - nodeLayouts: attrs.nodeLayouts, - labels: attrs.labels, - onNodeLayoutChange: attrs.onNodeLayoutChange, - onLabelsChange: attrs.onLabelsChange, - onDeselect: attrs.onDeselect, - onAddSourceNode: attrs.onAddSourceNode, - onClearAllNodes, - onDuplicateNode: attrs.onDuplicateNode, - onAddOperationNode: (id, node) => attrs.onAddOperationNode(id, node), - onDeleteNode: attrs.onDeleteNode, - onConnectionRemove: attrs.onConnectionRemove, - onImport: attrs.onImport, - onExport: attrs.onExport, - onRecenterReady: attrs.onRecenterReady, - }), - selectedNode && - m( - '.pf-qb-floating-controls', - !selectedNode.validate() && - m( - Popup, - { - trigger: m( - '.pf-qb-floating-warning', - m(Icon, { - icon: Icons.Warning, - filled: true, - className: 'pf-qb-warning-icon', - title: 'Click to see error details', - }), - ), - position: PopupPosition.BottomEnd, - showArrow: true, - }, - m( - '.pf-error-details', - selectedNode.state.issues?.getTitle() ?? 'No error details', - ), - ), - ), - m( - '.pf-qb-floating-controls-bottom', - attrs.onUndo && - RoundActionButton({ - icon: Icons.Undo, - title: 'Undo (Ctrl+Z)', - onclick: attrs.onUndo, - disabled: !attrs.canUndo, - }), - attrs.onRedo && - RoundActionButton({ - icon: Icons.Redo, - title: 'Redo (Ctrl+Shift+Z)', - onclick: attrs.onRedo, - disabled: !attrs.canRedo, - }), - ), - ), - m(ResizeHandle, { - direction: 'horizontal', - onResize: (deltaPx) => this.handleSidebarResize(attrs, deltaPx), - }), + // Sidebar panel (explorer + side panel buttons) + const sidebarPanel = m( + '.pf-qb-sidebar', m( '.pf-qb-explorer', { style: { - width: isExplorerCollapsed - ? '0' - : `${sidebarWidth + (selectedNode ? 0 : SIDE_PANEL_WIDTH)}px`, + width: isExplorerCollapsed ? '0' : `${sidebarWidth}px`, }, }, explorer, @@ -530,6 +483,41 @@ }), ), ); + + // Main content: vertical split (graph top, results bottom) + const mainContent = m(SplitPanel, { + direction: 'vertical', + split: {percent: 60}, + minSize: 100, + firstPanel: graphPanel, + secondPanel: resultsPanel, + }); + + // Full layout: horizontal split (main content left, sidebar right) + return m( + '.pf-query-builder-layout', + {className: layoutClasses}, + m(SplitPanel, { + direction: 'horizontal', + split: { + fixed: { + panel: 'second', + size: isExplorerCollapsed + ? SIDE_PANEL_WIDTH + : sidebarWidth + SIDE_PANEL_WIDTH, + }, + }, + minSize: SIDE_PANEL_WIDTH, + firstPanel: mainContent, + secondPanel: sidebarPanel, + onResize: (size: number) => { + const newWidth = size - SIDE_PANEL_WIDTH; + if (newWidth > 0) { + attrs.onSidebarWidthChange?.(newWidth); + } + }, + }), + ); } private resolveNode(
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/graph/graph.scss b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/graph/graph.scss index 0ef4086..45e026f 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/graph/graph.scss +++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/graph/graph.scss
@@ -18,8 +18,7 @@ position: relative; height: 100%; overflow: hidden; - border: 1px solid var(--pf-color-border); - background-color: var(--pf-color-background); + // background-color: var(--pf-color-background); // Ensure NodeGraph takes full height .pf-canvas {
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.scss b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.scss index ef8db03..a25205d 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.scss +++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.scss
@@ -18,7 +18,6 @@ display: flex; flex-direction: column; height: 100%; - border-left: 1px solid var(--pf-color-border); &__header { display: flex;
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.ts b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.ts index 290f8ff..acaa6ba 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.ts +++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/node_explorer.ts
@@ -22,7 +22,7 @@ import {CodeSnippet} from '../../../widgets/code_snippet'; import {AggregationNode} from './nodes/aggregation_node'; import {NodeIssues} from './node_issues'; -import {TabStrip} from '../../../widgets/tabs'; +import {Tabs} from '../../../widgets/tabs'; import {NodeModifyAttrs} from './node_explorer_types'; import {Button, ButtonAttrs, ButtonVariant} from '../../../widgets/button'; import {DataExplorerEmptyState, InfoBox} from './widgets'; @@ -284,7 +284,7 @@ selectedView === SelectedView.kModify && this.renderModifyView(node), selectedView === SelectedView.kResult && m('.', [ - m(TabStrip, { + m(Tabs, { tabs: [ {key: 'sql', title: 'SQL'}, {key: 'proto', title: 'Proto'},
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/styles.scss b/ui/src/plugins/dev.perfetto.ExplorePage/styles.scss index 4f8d731..df026f8 100644 --- a/ui/src/plugins/dev.perfetto.ExplorePage/styles.scss +++ b/ui/src/plugins/dev.perfetto.ExplorePage/styles.scss
@@ -23,7 +23,6 @@ height: 100%; position: relative; overflow: auto; - padding: 0.25rem; &__header { display: flex;
diff --git a/ui/src/plugins/dev.perfetto.PprofProfiles/pprof_page.ts b/ui/src/plugins/dev.perfetto.PprofProfiles/pprof_page.ts index 789c05a..2650820 100644 --- a/ui/src/plugins/dev.perfetto.PprofProfiles/pprof_page.ts +++ b/ui/src/plugins/dev.perfetto.PprofProfiles/pprof_page.ts
@@ -25,7 +25,7 @@ import {Stack, StackAuto, StackFixed} from '../../widgets/stack'; import {EmptyState} from '../../widgets/empty_state'; import {Callout} from '../../widgets/callout'; -import {TabStrip} from '../../widgets/tabs'; +import {Tabs} from '../../widgets/tabs'; import {Icon} from '../../widgets/icon'; import {Flamegraph} from '../../widgets/flamegraph'; import {PprofProfile, PprofPageState} from './types'; @@ -138,7 +138,7 @@ const showViewExplanation = this.shouldShowExplanation( HIDE_VIEW_EXPLANATION_KEY, ); - return m(TabStrip, { + return m(Tabs, { className: 'pf-pprof-page__tabs', tabs: [ {
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss b/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss index 14386cc..6c88e10 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss +++ b/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss
@@ -17,23 +17,40 @@ font-family: var(--pf-font-compact); position: relative; + &__editor-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + .pf-editor { + flex: 1; + min-height: 0; + contain: strict; // See b/388579546 + } + } + + &__results-panel { + height: 100%; + overflow: auto; + } + &__results { - max-height: 500px; + height: 100%; } &__results-summary { + display: inline-flex; + align-items: baseline; margin-left: 0.2em; + gap: 0.5em; } &__toolbar { + flex-shrink: 0; border-bottom: 1px solid var(--pf-color-border); } - .pf-editor { - contain: strict; // See b/388579546 - min-height: 3rem; - } - &__hotkeys { display: inline-flex; color: gray; @@ -50,4 +67,9 @@ font-weight: 300; white-space: pre; } + + &__history { + height: 100%; + border-left: 1px solid var(--pf-color-border); + } }
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts b/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts index 89a261e..f95d456 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts +++ b/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts
@@ -13,8 +13,6 @@ // limitations under the License. import m from 'mithril'; -import {findRef, toHTMLElement} from '../../base/dom_utils'; -import {assertExists} from '../../base/logging'; import {Icons} from '../../base/semantic_icons'; import {QueryResponse} from '../../components/query_table/queries'; import {DataGrid, renderCell} from '../../components/widgets/datagrid/datagrid'; @@ -32,7 +30,6 @@ import {Intent} from '../../widgets/common'; import {Editor} from '../../widgets/editor'; import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs'; -import {ResizeHandle} from '../../widgets/resize_handle'; import {Stack, StackAuto} from '../../widgets/stack'; import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; import {Anchor} from '../../widgets/anchor'; @@ -41,6 +38,8 @@ import {PopupMenu} from '../../widgets/menu'; import {PopupPosition} from '../../widgets/popup'; import {AddDebugTrackMenu} from '../../components/tracks/add_debug_track_menu'; +import {SplitPanel} from '../../widgets/split_panel'; +import {Chip} from '../../widgets/chip'; const HIDE_PERFETTO_SQL_AGENT_BANNER_KEY = 'hidePerfettoSqlAgentBanner'; @@ -57,13 +56,6 @@ export class QueryPage implements m.ClassComponent<QueryPageAttrs> { private dataSource?: DataSource; - private editorHeight: number = 0; - private editorElement?: HTMLElement; - - oncreate({dom}: m.VnodeDOM<QueryPageAttrs>) { - this.editorElement = toHTMLElement(assertExists(findRef(dom, 'editor'))); - this.editorElement.style.height = '200px'; - } onbeforeupdate( vnode: m.Vnode<QueryPageAttrs>, @@ -80,8 +72,8 @@ } view({attrs}: m.CVnode<QueryPageAttrs>) { - return m( - '.pf-query-page', + const editorPanel = m( + '.pf-query-page__editor-panel', m(Box, {className: 'pf-query-page__toolbar'}, [ m(Stack, {orientation: 'horizontal'}, [ m(Button, { @@ -169,33 +161,48 @@ ), ), m(Editor, { - ref: 'editor', + className: 'pf-query-page__editor', language: 'perfetto-sql', text: attrs.editorText, onUpdate: attrs.onEditorContentUpdate, onExecute: attrs.onExecute, }), - m(ResizeHandle, { - onResize: (deltaPx: number) => { - this.editorHeight += deltaPx; - this.editorElement!.style.height = `${this.editorHeight}px`; - }, - onResizeStart: () => { - this.editorHeight = this.editorElement!.clientHeight; - }, - }), + ); + + const resultsPanel = m( + '.pf-query-page__results-panel', this.dataSource && attrs.queryResult && this.renderQueryResult(attrs.trace, attrs.queryResult, this.dataSource), - m(QueryHistoryComponent, { - className: 'pf-query-page__history', - trace: attrs.trace, - runQuery: (query: string) => { - attrs.onExecute?.(query); - }, - setQuery: (query: string) => { - attrs.onEditorContentUpdate?.(query); - }, + ); + + const historyPanel = m(QueryHistoryComponent, { + className: 'pf-query-page__history', + trace: attrs.trace, + runQuery: (query: string) => { + attrs.onExecute?.(query); + }, + setQuery: (query: string) => { + attrs.onEditorContentUpdate?.(query); + }, + }); + + const mainContent = m(SplitPanel, { + direction: 'vertical', + split: {percent: 40}, + minSize: 100, + firstPanel: editorPanel, + secondPanel: resultsPanel, + }); + + return m( + '.pf-query-page', + m(SplitPanel, { + direction: 'horizontal', + split: {fixed: {panel: 'second', size: 300}}, + minSize: 200, + firstPanel: mainContent, + secondPanel: historyPanel, }), ); } @@ -266,7 +273,13 @@ showExportButton: true, toolbarItemsLeft: m( 'span.pf-query-page__results-summary', - `Returned ${queryResult.totalRowCount.toLocaleString()} rows in ${queryTimeString}`, + m( + Chip, + {intent: Intent.Success}, + queryResult.totalRowCount.toLocaleString(), + ' rows', + ), + m('span.pf-muted', queryTimeString), ), toolbarItemsRight: [ m(
diff --git a/ui/src/plugins/dev.perfetto.TraceInfoPage/trace_info_page.ts b/ui/src/plugins/dev.perfetto.TraceInfoPage/trace_info_page.ts index 5289811..e970583 100644 --- a/ui/src/plugins/dev.perfetto.TraceInfoPage/trace_info_page.ts +++ b/ui/src/plugins/dev.perfetto.TraceInfoPage/trace_info_page.ts
@@ -14,7 +14,7 @@ import m from 'mithril'; import {Trace} from '../../public/trace'; -import {TabStrip, TabOption} from '../../widgets/tabs'; +import {Tab, Tabs} from '../../widgets/tabs'; import {EmptyState} from '../../widgets/empty_state'; import type {TabKey} from './utils'; import {isValidTabKey} from './utils'; @@ -87,69 +87,17 @@ 'High-level summary of trace health, metrics, and system information', ), ), - m(TabStrip, { - tabs: this.getTabs(), + m(Tabs, { + tabs: this.getTabs(attrs.trace), currentTabKey: this.currentTab, onTabChange: (key: string) => { this.currentTab = isValidTabKey(key) ? key : 'overview'; }, }), - this.renderCurrentTab(attrs.trace, this.currentTab), ), ); } - private renderCurrentTab(trace: Trace, currentTab: TabKey): m.Children { - if (!this.tabData) { - return m(EmptyState, { - icon: 'hourglass_empty', - title: 'Loading trace info...', - }); - } - switch (currentTab) { - case 'overview': - return m(OverviewTab, { - trace, - data: this.tabData.overview, - onTabChange: (key: TabKey) => { - this.currentTab = key; - }, - }); - case 'config': - return m(ConfigTab, { - data: this.tabData.config, - }); - case 'android': - return m(AndroidTab, { - data: this.tabData.android, - }); - case 'machines': - return m(MachinesTab, { - data: this.tabData.machines, - }); - case 'import_errors': - return m(ImportErrorsTab, { - data: this.tabData.importErrors, - }); - case 'trace_errors': - return m(TraceErrorsTab, { - data: this.tabData.traceErrors, - }); - case 'data_losses': - return m(DataLossesTab, { - data: this.tabData.dataLosses, - }); - case 'ui_loading_errors': - return m(UiLoadingErrorsTab, { - data: this.tabData.uiLoadingErrors, - }); - case 'stats': - return m(StatsTab, { - data: this.tabData.stats, - }); - } - } - private async loadAllData(trace: Trace): Promise<void> { const engine = trace.engine; this.tabData = { @@ -166,33 +114,92 @@ m.redraw(); } - private getTabs(): TabOption[] { - const tabs: TabOption[] = [{key: 'overview', title: 'Overview'}]; - if (this.tabData?.config?.configText) { - tabs.push({key: 'config', title: 'Trace Config'}); + private getTabs(trace: Trace): Tab[] { + if (!this.tabData) { + return [ + { + key: 'overview', + title: 'Overview', + content: m(EmptyState, { + icon: 'hourglass_empty', + title: 'Loading trace info...', + }), + }, + ]; } - if ((this.tabData?.overview?.importErrors ?? 0) > 0) { - tabs.push({key: 'import_errors', title: 'Import Errors'}); + + const tabs: Tab[] = [ + { + key: 'overview', + title: 'Overview', + content: m(OverviewTab, { + trace, + data: this.tabData.overview, + onTabChange: (key: TabKey) => { + this.currentTab = key; + }, + }), + }, + ]; + + if (this.tabData.config?.configText) { + tabs.push({ + key: 'config', + title: 'Trace Config', + content: m(ConfigTab, {data: this.tabData.config}), + }); } - if ((this.tabData?.traceErrors?.errors?.length ?? 0) > 0) { - tabs.push({key: 'trace_errors', title: 'Trace Errors'}); + if ((this.tabData.overview?.importErrors ?? 0) > 0) { + tabs.push({ + key: 'import_errors', + title: 'Import Errors', + content: m(ImportErrorsTab, {data: this.tabData.importErrors}), + }); } - if ((this.tabData?.overview?.dataLosses ?? 0) > 0) { - tabs.push({key: 'data_losses', title: 'Data Losses'}); + if ((this.tabData.traceErrors?.errors?.length ?? 0) > 0) { + tabs.push({ + key: 'trace_errors', + title: 'Trace Errors', + content: m(TraceErrorsTab, {data: this.tabData.traceErrors}), + }); } - if ((this.tabData?.overview?.uiLoadingErrorCount ?? 0) > 0) { - tabs.push({key: 'ui_loading_errors', title: 'UI Loading Errors'}); + if ((this.tabData.overview?.dataLosses ?? 0) > 0) { + tabs.push({ + key: 'data_losses', + title: 'Data Losses', + content: m(DataLossesTab, {data: this.tabData.dataLosses}), + }); + } + if ((this.tabData.overview?.uiLoadingErrorCount ?? 0) > 0) { + tabs.push({ + key: 'ui_loading_errors', + title: 'UI Loading Errors', + content: m(UiLoadingErrorsTab, {data: this.tabData.uiLoadingErrors}), + }); } const hasAndroid = - (this.tabData?.android?.packageList?.length ?? 0) > 0 || - (this.tabData?.android?.gameInterventions?.length ?? 0) > 0; + (this.tabData.android?.packageList?.length ?? 0) > 0 || + (this.tabData.android?.gameInterventions?.length ?? 0) > 0; if (hasAndroid) { - tabs.push({key: 'android', title: 'Android'}); + tabs.push({ + key: 'android', + title: 'Android', + content: m(AndroidTab, {data: this.tabData.android}), + }); } - if ((this.tabData?.machines?.machineCount ?? 0) > 1) { - tabs.push({key: 'machines', title: 'Machines'}); + if ((this.tabData.machines?.machineCount ?? 0) > 1) { + tabs.push({ + key: 'machines', + title: 'Machines', + content: m(MachinesTab, {data: this.tabData.machines}), + }); } - tabs.push({key: 'stats', title: 'Info and Stats (advanced)'}); + tabs.push({ + key: 'stats', + title: 'Info and Stats (advanced)', + content: m(StatsTab, {data: this.tabData.stats}), + }); + return tabs; } }
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/split_panel_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/split_panel_demo.ts index 37ed478..690d60f 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/split_panel_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/split_panel_demo.ts
@@ -13,8 +13,7 @@ // limitations under the License. import m from 'mithril'; -import {Button} from '../../../widgets/button'; -import {SplitPanel, Tab} from '../../../widgets/split_panel'; +import {SplitPanel} from '../../../widgets/split_panel'; import {renderWidgetShowcase} from '../widgets_page_utils'; export function renderSplitPanel(): m.Children { @@ -24,7 +23,7 @@ m('h1', 'SplitPanel'), m( 'p', - 'A resizable split panel container for dividing content into adjustable sections with a draggable divider.', + 'A simple resizable split panel with a draggable handle. Supports both horizontal and vertical layouts, with percentage or fixed-pixel sizing modes.', ), ), renderWidgetShowcase({ @@ -33,38 +32,43 @@ '', { style: { - height: '400px', - width: '400px', - border: 'solid 2px gray', + height: '300px', + width: '500px', + border: '1px solid var(--pf-color-border)', }, }, - m( - SplitPanel, - { - leftHandleContent: [ - opts.leftContent && m(Button, {icon: 'Menu'}), - ], - drawerContent: 'Drawer Content', - tabs: - opts.tabs && - m( - '.pf-split-panel__tabs', - m( - Tab, - {active: true, hasCloseButton: opts.showCloseButtons}, - 'Foo', - ), - m(Tab, {hasCloseButton: opts.showCloseButtons}, 'Bar'), - ), - }, - 'Main Content', - ), + m(SplitPanel(), { + direction: opts.vertical ? 'vertical' : 'horizontal', + split: opts.fixed + ? {fixed: {panel: 'first', size: 150}} + : {percent: 50}, + minSize: 50, + firstPanel: m( + '', + { + style: { + padding: '8px', + background: 'var(--pf-color-background)', + }, + }, + 'First Panel', + ), + secondPanel: m( + '', + { + style: { + padding: '8px', + background: 'var(--pf-color-background-secondary)', + }, + }, + 'Second Panel', + ), + }), ); }, initialOpts: { - leftContent: true, - tabs: true, - showCloseButtons: true, + vertical: false, + fixed: false, }, }), ];
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabstrip_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabs_demo.ts similarity index 61% rename from ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabstrip_demo.ts rename to ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabs_demo.ts index aae8c77..5a1c7bf 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabstrip_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tabs_demo.ts
@@ -13,24 +13,25 @@ // limitations under the License. import m from 'mithril'; -import {TabStrip} from '../../../widgets/tabs'; +import {Tabs} from '../../../widgets/tabs'; import {renderWidgetShowcase} from '../widgets_page_utils'; let currentTab: string = 'foo'; -export function renderTabStrip(): m.Children { +export function renderTabs(): m.Children { return [ m( '.pf-widget-intro', - m('h1', 'TabStrip'), + m('h1', 'Tabs'), m( 'p', - 'A horizontal tab navigation component for switching between different views or sections.', + 'A horizontal tab navigation component for switching between different views or sections. Can optionally contain tab content.', ), ), + m('h2', 'Basic Tabs'), renderWidgetShowcase({ renderWidget: () => { - return m(TabStrip, { + return m(Tabs, { tabs: [ {key: 'foo', title: 'Foo'}, {key: 'bar', title: 'Bar'}, @@ -44,5 +45,22 @@ }, initialOpts: {}, }), + m('h2', 'Tabs with Content'), + renderWidgetShowcase({ + renderWidget: () => { + return m(Tabs, { + tabs: [ + {key: 'foo', title: 'Foo', content: m('p', 'Content for Foo tab')}, + {key: 'bar', title: 'Bar', content: m('p', 'Content for Bar tab')}, + {key: 'baz', title: 'Baz', content: m('p', 'Content for Baz tab')}, + ], + currentTabKey: currentTab, + onTabChange: (key) => { + currentTab = key; + }, + }); + }, + initialOpts: {}, + }), ]; }
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts index 2a30379..f547f92 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -47,7 +47,7 @@ import {renderSpinner} from './demos/spinner_demo'; import {renderSplitPanel} from './demos/split_panel_demo'; import {renderSwitch} from './demos/switch_demo'; -import {renderTabStrip} from './demos/tabstrip_demo'; +import {renderTabs} from './demos/tabs_demo'; import {renderTagInput} from './demos/tag_input_demo'; import {renderTextInput} from './demos/text_input_demo'; import {renderTextParagraph} from './demos/text_paragraph_demo'; @@ -95,9 +95,9 @@ {id: 'segmented-buttons', label: 'SegmentedButtons', view: segmentedButtons}, {id: 'select', label: 'Select', view: renderSelect}, {id: 'spinner', label: 'Spinner', view: renderSpinner}, - {id: 'split-panel', label: 'Split Panel', view: renderSplitPanel}, + {id: 'split-panel', label: 'SplitPanel', view: renderSplitPanel}, {id: 'switch', label: 'Switch', view: renderSwitch}, - {id: 'tabstrip', label: 'TabStrip', view: renderTabStrip}, + {id: 'tabs', label: 'Tabs', view: renderTabs}, {id: 'taginput', label: 'TagInput', view: renderTagInput}, {id: 'textinput', label: 'TextInput', view: renderTextInput}, {id: 'textparagraph', label: 'TextParagraph', view: renderTextParagraph},
diff --git a/ui/src/test/perfetto_ui_test_helper.ts b/ui/src/test/perfetto_ui_test_helper.ts index a93fe1a..e58e436 100644 --- a/ui/src/test/perfetto_ui_test_helper.ts +++ b/ui/src/test/perfetto_ui_test_helper.ts
@@ -174,7 +174,9 @@ } async switchToTab(text: string | RegExp) { - await this.page.locator('.pf-split-panel__tab', {hasText: text}).click(); + await this.page + .locator('.pf-collapsible-split-panel__tab', {hasText: text}) + .click(); } async scheduleFullRedraw(): Promise<void> {
diff --git a/ui/src/widgets/chip.ts b/ui/src/widgets/chip.ts index ac6d474..5dcd0c5 100644 --- a/ui/src/widgets/chip.ts +++ b/ui/src/widgets/chip.ts
@@ -20,7 +20,7 @@ export interface ChipAttrs extends HTMLAttrs { // Chips require a label. - readonly label: m.Children; + readonly label?: m.Children; // Chips can have an optional icon. readonly icon?: string; // Use minimal padding, reducing the overall size of the chip by a few px. @@ -49,7 +49,7 @@ } export class Chip implements m.ClassComponent<ChipAttrs> { - view({attrs}: m.CVnode<ChipAttrs>) { + view({attrs, children}: m.CVnode<ChipAttrs>) { const { icon, compact, @@ -83,7 +83,7 @@ icon: icon, filled: iconFilled, }), - m('span.pf-chip__label', label), + m('span.pf-chip__label', label, children), removable && m(Button, { compact: true,
diff --git a/ui/src/widgets/split_panel.ts b/ui/src/widgets/split_panel.ts index 276dffc..26c867a 100644 --- a/ui/src/widgets/split_panel.ts +++ b/ui/src/widgets/split_panel.ts
@@ -13,269 +13,174 @@ // limitations under the License. import m from 'mithril'; -import {DisposableStack} from '../base/disposable_stack'; -import {toHTMLElement} from '../base/dom_utils'; -import {DragGestureHandler} from '../base/drag_gesture_handler'; -import {assertExists, assertUnreachable} from '../base/logging'; -import {Button, ButtonBar} from './button'; import {classNames} from '../base/classnames'; -import {HTMLAttrs} from './common'; -import {Icons} from '../base/semantic_icons'; -export interface TabAttrs extends HTMLAttrs { - // Is this tab currently active? - readonly active?: boolean; - // Whether to show a close button on the tab. - readonly hasCloseButton?: boolean; - // What happens when the close button is clicked. - readonly onClose?: () => void; -} +/** Split configuration - either percentage or fixed pixel mode */ +export type SplitConfig = + | {readonly percent: number} + | { + readonly fixed: { + readonly panel: 'first' | 'second'; + readonly size: number; + }; + }; -export class Tab implements m.ClassComponent<TabAttrs> { - view({attrs, children}: m.CVnode<TabAttrs>): m.Children { - const {active, hasCloseButton, ...rest} = attrs; - return m( - '.pf-split-panel__tab', - { - ...rest, - className: classNames(active && 'pf-split-panel__tab--active'), - onauxclick: () => { - attrs.onClose?.(); - }, - }, - m('.pf-split-panel__tab-title', children), - hasCloseButton && - m(Button, { - compact: true, - icon: Icons.Close, - onclick: (e) => { - e.stopPropagation(); - attrs.onClose?.(); - }, - }), - ); - } -} - -export enum SplitPanelDrawerVisibility { - VISIBLE, - FULLSCREEN, - COLLAPSED, -} - -export interface TabbedSplitPanelAttrs { - // Content to put to the left of the tabs on the split handle. - readonly leftHandleContent?: m.Children; - - // Tabs to display on the split handle. - readonly tabs?: m.Children; - - // Content to display inside the drawer. - readonly drawerContent?: m.Children; - - // Whether the drawer is currently visible or not (when in controlled mode). - readonly visibility?: SplitPanelDrawerVisibility; - - // Extra classes applied to the root element. +export interface SplitPanelAttrs { + readonly direction?: 'horizontal' | 'vertical'; + /** Split configuration - defaults to { percent: 50 } */ + readonly split?: SplitConfig; + /** Minimum size in pixels for each panel */ + readonly minSize?: number; readonly className?: string; - - // What height should the drawer be initially? - readonly startingHeight?: number; - - // Called when the active tab is changed. - onTabChange?(key: string): void; - - // Called when the drawer visibility is changed. - onVisibilityChange?(visibility: SplitPanelDrawerVisibility): void; + readonly firstPanel: m.Children; + readonly secondPanel: m.Children; + readonly onResize?: (size: number) => void; } -/** - * A container that fills its parent container, splitting into two adjustable - * horizontal sections. The upper half is reserved for the main content and any - * children are placed here, and the lower half should be considered a drawer, - * the `drawerContent` attribute can be used to define what goes here. - * - * The drawer features a handle that can be dragged to adjust the height of the - * drawer, and also features buttons to maximize and minimise the drawer. - * - * Content can also optionally be displayed on the handle itself to the left of - * the buttons. - * - * The layout looks like this: - * - * ┌──────────────────────────────────────────────────────────────────┐ - * │pf-split-panel │ - * │┌────────────────────────────────────────────────────────────────┐| - * ││pf-split-panel__main ││ - * |└────────────────────────────────────────────────────────────────┘| - * │┌────────────────────────────────────────────────────────────────┐| - * ││pf-split-panel__handle ││ - * │|┌─────────────────┐┌─────────────────────┐┌────────────────────┐|| - * |||leftHandleContent||.pf-split-panel__tabs||.pf-button-bar ||| - * ||└─────────────────┘└─────────────────────┘└────────────────────┘|| - * |└────────────────────────────────────────────────────────────────┘| - * │┌────────────────────────────────────────────────────────────────┐| - * ││pf-split-panel__drawer ││ - * |└────────────────────────────────────────────────────────────────┘| - * └──────────────────────────────────────────────────────────────────┘ - */ -export class SplitPanel implements m.ClassComponent<TabbedSplitPanelAttrs> { - private readonly trash = new DisposableStack(); - - // The actual height of the vdom node. It matches resizableHeight if VISIBLE, - // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN. - private height = 0; - - // The height when the panel is 'VISIBLE'. - private resizableHeight: number; - - // The height when the panel is 'FULLSCREEN'. - private fullscreenHeight = 0; - - // Current visibility state (if not controlled). - private visibility = SplitPanelDrawerVisibility.VISIBLE; - - constructor({attrs}: m.CVnode<TabbedSplitPanelAttrs>) { - this.resizableHeight = attrs.startingHeight ?? 100; - } - - view({attrs, children}: m.CVnode<TabbedSplitPanelAttrs>) { - const { - leftHandleContent, - drawerContent, - visibility = this.visibility, - className, - onVisibilityChange, - tabs, - } = attrs; - - switch (visibility) { - case SplitPanelDrawerVisibility.VISIBLE: - this.height = Math.min( - Math.max(this.resizableHeight, 0), - this.fullscreenHeight, - ); - break; - case SplitPanelDrawerVisibility.FULLSCREEN: - this.height = this.fullscreenHeight; - break; - case SplitPanelDrawerVisibility.COLLAPSED: - this.height = 0; - break; - } - - return m( - '.pf-split-panel', - { - className, - }, - m('.pf-split-panel__main', children), - m('.pf-split-panel__handle', [ - leftHandleContent, - m('.pf-split-panel__tabs', tabs), - this.renderTabResizeButtons(visibility, onVisibilityChange), - ]), - m( - '.pf-split-panel__drawer', - { - style: {height: `${this.height}px`}, - }, - drawerContent, - ), - ); - } - - oncreate(vnode: m.VnodeDOM<TabbedSplitPanelAttrs, this>) { - let dragStartY = 0; - let heightWhenDragStarted = 0; - - const handle = toHTMLElement( - assertExists(vnode.dom.querySelector('.pf-split-panel__handle')), - ); - - this.trash.use( - new DragGestureHandler( - handle, - /* onDrag */ (_x, y) => { - const deltaYSinceDragStart = dragStartY - y; - this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart; - m.redraw(); - }, - /* onDragStarted */ (_x, y) => { - this.resizableHeight = this.height; - heightWhenDragStarted = this.height; - dragStartY = y; - this.updatePanelVisibility( - SplitPanelDrawerVisibility.VISIBLE, - vnode.attrs.onVisibilityChange, - ); - }, - /* onDragFinished */ () => {}, - ), - ); - - const parent = assertExists(vnode.dom.parentElement); - this.fullscreenHeight = parent.clientHeight; - const resizeObs = new ResizeObserver(() => { - this.fullscreenHeight = parent.clientHeight; - m.redraw(); - }); - resizeObs.observe(parent); - this.trash.defer(() => resizeObs.disconnect()); - } - - onremove() { - this.trash.dispose(); - } - - private renderTabResizeButtons( - visibility: SplitPanelDrawerVisibility, - setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, - ): m.Child { - const isClosed = visibility === SplitPanelDrawerVisibility.COLLAPSED; - return m( - ButtonBar, - m(Button, { - title: 'Open fullscreen', - disabled: visibility === SplitPanelDrawerVisibility.FULLSCREEN, - icon: 'vertical_align_top', - onclick: () => { - this.updatePanelVisibility( - SplitPanelDrawerVisibility.FULLSCREEN, - setVisibility, - ); - }, - }), - m(Button, { - onclick: () => { - this.updatePanelVisibility( - toggleVisibility(visibility), - setVisibility, - ); - }, - title: isClosed ? 'Show panel' : 'Hide panel', - icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', - }), - ); - } - - private updatePanelVisibility( - visibility: SplitPanelDrawerVisibility, - setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, - ) { - this.visibility = visibility; - setVisibility?.(visibility); - } +// Type guard for fixed mode +function isFixedConfig( + split: SplitConfig, +): split is {fixed: {panel: 'first' | 'second'; size: number}} { + return 'fixed' in split; } -export function toggleVisibility(visibility: SplitPanelDrawerVisibility) { - switch (visibility) { - case SplitPanelDrawerVisibility.COLLAPSED: - case SplitPanelDrawerVisibility.FULLSCREEN: - return SplitPanelDrawerVisibility.VISIBLE; - case SplitPanelDrawerVisibility.VISIBLE: - return SplitPanelDrawerVisibility.COLLAPSED; - default: - assertUnreachable(visibility); - } +// Factory function to create SplitPanel instances with their own state +export function SplitPanel(): m.Component<SplitPanelAttrs> { + let splitPercent = 50; + let isResizing = false; + + return { + oninit(vnode) { + const split = vnode.attrs.split ?? {percent: 50}; + if (!isFixedConfig(split) && 'percent' in split) { + splitPercent = split.percent; + } + }, + + view(vnode) { + const { + direction = 'horizontal', + minSize = 50, + split = {percent: 50}, + firstPanel, + secondPanel, + } = vnode.attrs; + + const fixedPanel = isFixedConfig(split) ? split.fixed.panel : null; + const fixedSize = isFixedConfig(split) ? split.fixed.size : 0; + + const containerClasses = classNames( + 'pf-split-panel', + `pf-split-${direction}`, + vnode.attrs.className, + ); + const handleSize = 4; + + let firstStyle: Record<string, string>; + let secondStyle: Record<string, string>; + + if (fixedPanel === 'first') { + firstStyle = {flex: `0 0 ${fixedSize}px`}; + secondStyle = {flex: '1 1 0'}; + } else if (fixedPanel === 'second') { + firstStyle = {flex: '1 1 0'}; + secondStyle = {flex: `0 0 ${fixedSize}px`}; + } else { + // Percentage mode + firstStyle = {flex: `0 0 calc(${splitPercent}% - ${handleSize / 2}px)`}; + secondStyle = { + flex: `0 0 calc(${100 - splitPercent}% - ${handleSize / 2}px)`, + }; + } + + const onPointerDown = (e: PointerEvent) => { + e.preventDefault(); + const handle = e.currentTarget as HTMLElement; + handle.setPointerCapture(e.pointerId); + isResizing = true; + document.body.style.cursor = + direction === 'horizontal' ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + handle.classList.add('pf-split-handle--active'); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!isResizing) return; + + const handle = e.currentTarget as HTMLElement; + const container = handle.parentElement!; + const rect = container.getBoundingClientRect(); + const containerSize = + direction === 'horizontal' ? rect.width : rect.height; + + if (fixedPanel) { + // Fixed pixel mode + let pos: number; + if (direction === 'horizontal') { + pos = e.clientX - rect.left; + } else { + pos = e.clientY - rect.top; + } + + let newSize: number; + if (fixedPanel === 'first') { + newSize = pos; + } else { + newSize = containerSize - pos; + } + + // Clamp to min/max + newSize = Math.max( + minSize, + Math.min(containerSize - minSize - handleSize, newSize), + ); + + if (vnode.attrs.onResize) { + vnode.attrs.onResize(newSize); + } + } else { + // Percentage mode + let newPercent: number; + if (direction === 'horizontal') { + const x = e.clientX - rect.left; + newPercent = (x / rect.width) * 100; + } else { + const y = e.clientY - rect.top; + newPercent = (y / rect.height) * 100; + } + + const minPercent = (minSize / containerSize) * 100; + const maxPercent = 100 - minPercent; + newPercent = Math.max(minPercent, Math.min(maxPercent, newPercent)); + splitPercent = newPercent; + + if (vnode.attrs.onResize) { + vnode.attrs.onResize(newPercent); + } + } + }; + + const onPointerUp = (e: PointerEvent) => { + if (isResizing) { + const handle = e.currentTarget as HTMLElement; + handle.releasePointerCapture(e.pointerId); + isResizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + handle.classList.remove('pf-split-handle--active'); + } + }; + + return m('div', {class: containerClasses}, [ + m('.pf-split-panel__first', {style: firstStyle}, firstPanel), + m('.pf-split-panel__handle', { + onpointerdown: onPointerDown, + onpointermove: onPointerMove, + onpointerup: onPointerUp, + onpointercancel: onPointerUp, + }), + m('.pf-split-panel__second', {style: secondStyle}, secondPanel), + ]); + }, + }; }
diff --git a/ui/src/widgets/tabs.ts b/ui/src/widgets/tabs.ts index 5c2f488..f805daa 100644 --- a/ui/src/widgets/tabs.ts +++ b/ui/src/widgets/tabs.ts
@@ -13,61 +13,101 @@ // limitations under the License. import m from 'mithril'; +import {Icons} from '../base/semantic_icons'; +import {Button} from './button'; import {Icon} from './icon'; +import {isEmptyVnodes} from '../base/mithril_utils'; -export interface TabOption { +export interface Tab { readonly key: string; readonly title: string; + readonly content?: m.Children; readonly leftIcon?: string | m.Children; readonly rightIcon?: string | m.Children; + readonly hasCloseButton?: boolean; + readonly onClose?: () => void; } -export interface TabStripAttrs { +export interface TabsAttrs { readonly className?: string; - readonly tabs: ReadonlyArray<TabOption>; + readonly tabs: ReadonlyArray<Tab>; readonly currentTabKey: string; onTabChange(key: string): void; + // Content to render to the left of the tabs + readonly leftContent?: m.Children; + // Content to render to the right of the tabs + readonly rightContent?: m.Children; + // If true, the tabs container will fill the available height (100%) + readonly fillHeight?: boolean; } -export class TabStrip implements m.ClassComponent<TabStripAttrs> { - view({attrs}: m.CVnode<TabStripAttrs>) { - const {tabs, currentTabKey, onTabChange, className} = attrs; +export class Tabs implements m.ClassComponent<TabsAttrs> { + view({attrs}: m.CVnode<TabsAttrs>) { + const { + tabs, + currentTabKey, + onTabChange, + className, + leftContent, + rightContent, + fillHeight, + } = attrs; + const currentTab = tabs.find((t) => t.key === currentTabKey); + return m( '.pf-tabs', - {className}, + { + className, + style: fillHeight ? {height: '100%'} : undefined, + }, m( - '.pf-tabs__tabs', - tabs.map((tab) => { - const {key, title, leftIcon, rightIcon} = tab; - const renderIcon = ( - icon: string | m.Children | undefined, - className: string, - ) => { - if (icon === undefined) { - return undefined; - } - if (typeof icon === 'string') { - return m(Icon, {icon, className}); - } - return m('.pf-tabs__tab-icon', {className}, icon); - }; - return m( - '.pf-tabs__tab', - { - active: currentTabKey === key, - key, - onclick: () => { - onTabChange(key); + '.pf-tabs__header', + !isEmptyVnodes(leftContent) && m('.pf-tabs__left-content', leftContent), + m( + '.pf-tabs__tabs', + tabs.map((tab) => { + const {key, title, leftIcon, rightIcon, hasCloseButton, onClose} = + tab; + const isActive = currentTabKey === key; + const renderIcon = ( + icon: string | m.Children | undefined, + cls: string, + ) => { + if (icon === undefined) return undefined; + if (typeof icon === 'string') { + return m(Icon, {icon, className: cls}); + } + return m('.pf-tabs__tab-icon', {className: cls}, icon); + }; + return m( + '.pf-tabs__tab', + { + active: isActive, + key, + onclick: () => onTabChange(key), + onauxclick: () => onClose?.(), }, - }, - [ - renderIcon(leftIcon, 'pf-tabs__tab-icon--left'), - m('span.pf-tabs__tab-title', title), - renderIcon(rightIcon, 'pf-tabs__tab-icon--right'), - ], - ); - }), + [ + renderIcon(leftIcon, 'pf-tabs__tab-icon--left'), + m('span.pf-tabs__tab-title', title), + renderIcon(rightIcon, 'pf-tabs__tab-icon--right'), + hasCloseButton && + m(Button, { + compact: true, + icon: Icons.Close, + onclick: (e: Event) => { + e.stopPropagation(); + onClose?.(); + }, + }), + ], + ); + }), + ), + !isEmptyVnodes(rightContent) && + m('.pf-tabs__right-content', rightContent), ), + m('.pf-tabs__content', currentTab?.content), ); } }