Add TreeGrid widget Change-Id: Ie76338cdd031533c99ecb330a86c5bbc3f296db1
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts new file mode 100644 index 0000000..a47cebc --- /dev/null +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts
@@ -0,0 +1,259 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {TreeGrid, TreeGridRow} from '../../../widgets/tree_grid'; +import {GridCell, GridHeaderCell} from '../../../widgets/grid'; +import {renderWidgetShowcase} from '../widgets_page_utils'; +import {Anchor} from '../../../widgets/anchor'; +import {CodeSnippet} from '../../../widgets/code_snippet'; + +export function renderTreeGrid(): m.Children { + return [ + m( + '.pf-widget-intro', + m('h1', 'TreeGrid'), + m('p', [ + 'TreeGrid is a specialized version of ', + m(Anchor, {href: '#!/widgets/grid'}, 'Grid'), + ' that automatically organizes rows into a tree structure based on slash-separated path keys.', + ]), + m('p', [ + 'Unlike the lower-level Grid component, TreeGrid handles the tree structure automatically. ', + 'You simply provide flat rows with hierarchical paths, and TreeGrid builds the tree, ', + 'manages expand/collapse state, and renders the appropriate indent levels and chevrons.', + ]), + m('ul', [ + m('li', 'Automatic tree building from slash-separated paths'), + m('li', 'Built-in expand/collapse functionality'), + m('li', 'Automatic indent and chevron management'), + m('li', 'Supports virtualization for large datasets'), + m('li', 'All Grid features (column resizing, sorting, etc.)'), + ]), + ), + + m('h2', 'Interactive Demo'), + + renderWidgetShowcase({ + renderWidget: ({virtualize}) => { + const rows: TreeGridRow[] = [ + { + path: 'src/base/logging.cc', + cells: [ + m(GridCell, 'logging.cc'), + m(GridCell, {align: 'right'}, '1,234'), + m(GridCell, 'C++'), + ], + }, + { + path: 'src/base/string_utils.cc', + cells: [ + m(GridCell, 'string_utils.cc'), + m(GridCell, {align: 'right'}, '856'), + m(GridCell, 'C++'), + ], + }, + { + path: 'src/base/utils.cc', + cells: [ + m(GridCell, 'utils.cc'), + m(GridCell, {align: 'right'}, '432'), + m(GridCell, 'C++'), + ], + }, + { + path: 'src/trace_processor/db/table.cc', + cells: [ + m(GridCell, 'table.cc'), + m(GridCell, {align: 'right'}, '2,145'), + m(GridCell, 'C++'), + ], + }, + { + path: 'src/trace_processor/db/column.cc', + cells: [ + m(GridCell, 'column.cc'), + m(GridCell, {align: 'right'}, '987'), + m(GridCell, 'C++'), + ], + }, + { + path: 'ui/src/widgets/grid.ts', + cells: [ + m(GridCell, 'grid.ts'), + m(GridCell, {align: 'right'}, '1,543'), + m(GridCell, 'TypeScript'), + ], + }, + { + path: 'ui/src/widgets/tree_grid.ts', + cells: [ + m(GridCell, 'tree_grid.ts'), + m(GridCell, {align: 'right'}, '267'), + m(GridCell, 'TypeScript'), + ], + }, + { + path: 'ui/src/widgets/button.ts', + cells: [ + m(GridCell, 'button.ts'), + m(GridCell, {align: 'right'}, '321'), + m(GridCell, 'TypeScript'), + ], + }, + { + path: 'ui/src/assets/widgets/grid.scss', + cells: [ + m(GridCell, 'grid.scss'), + m(GridCell, {align: 'right'}, '341'), + m(GridCell, 'SCSS'), + ], + }, + { + path: 'ui/src/assets/theme.scss', + cells: [ + m(GridCell, 'theme.scss'), + m(GridCell, {align: 'right'}, '156'), + m(GridCell, 'SCSS'), + ], + }, + { + path: 'docs/README.md', + cells: [ + m(GridCell, 'README.md'), + m(GridCell, {align: 'right'}, '89'), + m(GridCell, 'Markdown'), + ], + }, + { + path: 'docs/contributing.md', + cells: [ + m(GridCell, 'contributing.md'), + m(GridCell, {align: 'right'}, '432'), + m(GridCell, 'Markdown'), + ], + }, + ]; + + return m(TreeGrid, { + key: virtualize ? 'treegrid-virtualized' : 'treegrid-full', + columns: [ + { + key: 'name', + header: m(GridHeaderCell, 'File'), + }, + { + key: 'lines', + header: m(GridHeaderCell, 'Lines'), + }, + { + key: 'language', + header: m(GridHeaderCell, 'Language'), + }, + ], + rows, + fillHeight: true, + virtualization: virtualize + ? { + rowHeightPx: 24, + } + : undefined, + }); + }, + initialOpts: { + virtualize: false, + }, + noPadding: true, + }), + + m('h2', 'Basic Usage'), + m('p', [ + 'TreeGrid automatically builds a tree from flat rows with slash-separated paths. ', + 'Each row must have a ', + m('code', 'path'), + ' and an array of ', + m('code', 'cells'), + '.', + ]), + m( + 'p', + m(CodeSnippet, { + text: `m(TreeGrid, { + columns: [ + {key: 'name', header: m(GridHeaderCell, 'Name')}, + {key: 'size', header: m(GridHeaderCell, 'Size')}, + ], + rows: [ + { + path: 'root/folder1/file1.txt', + cells: [m(GridCell, 'file1.txt'), m(GridCell, '1.2 KB')], + }, + { + path: 'root/folder1/file2.txt', + cells: [m(GridCell, 'file2.txt'), m(GridCell, '856 B')], + }, + { + path: 'root/folder2/file3.txt', + cells: [m(GridCell, 'file3.txt'), m(GridCell, '3.4 KB')], + }, + ], + fillHeight: true, +});`, + }), + ), + + m('h2', 'How It Works'), + m('p', [ + 'TreeGrid parses the ', + m('code', 'path'), + ' field of each row (using ', + m('code', '/'), + ' as separator by default) and builds a tree structure. ', + 'Intermediate nodes (folders) are created automatically and can be expanded/collapsed. ', + 'Leaf nodes (files) display the provided cell data.', + ]), + m('p', [ + 'For example, the path ', + m('code', '"root/folder1/file.txt"'), + ' creates:', + ]), + m('ul', [ + m('li', [m('code', 'root'), ' - intermediate node (folder)']), + m('li', [m('code', 'folder1'), ' - intermediate node (folder)']), + m('li', [m('code', 'file.txt'), ' - leaf node with your cell data']), + ]), + + m('h2', 'Custom Separator'), + m('p', [ + 'You can use a different separator by setting the ', + m('code', 'separator'), + ' prop:', + ]), + m( + 'p', + m(CodeSnippet, { + text: `m(TreeGrid, { + separator: '.', + rows: [ + { + path: 'com.example.app.MainActivity', + cells: [...], + }, + ], + ... +});`, + }), + ), + ]; +}
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts index 2a30379..2d4e922 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -54,6 +54,7 @@ import {renderTooltip} from './demos/tooltip_demo'; import {renderTrackShell} from './demos/track_shell_demo'; import {renderTree} from './demos/tree_demo'; +import {renderTreeGrid} from './demos/tree_grid_demo'; import {renderTreeTable} from './demos/treetable_demo'; import {renderVegaView} from './demos/vega_view_demo'; import {renderVirtualCanvas} from './demos/virtual_canvas_demo'; @@ -104,6 +105,7 @@ {id: 'tooltip', label: 'Tooltip', view: renderTooltip}, {id: 'trackshell', label: 'TrackShell', view: renderTrackShell}, {id: 'tree', label: 'Tree', view: renderTree}, + {id: 'treegrid', label: 'TreeGrid', view: renderTreeGrid}, {id: 'treetable', label: 'TreeTable', view: renderTreeTable}, {id: 'vegaview', label: 'VegaView', view: renderVegaView}, {id: 'virtualcanvas', label: 'VirtualCanvas', view: renderVirtualCanvas},
diff --git a/ui/src/widgets/tree_grid.ts b/ui/src/widgets/tree_grid.ts new file mode 100644 index 0000000..e36e387 --- /dev/null +++ b/ui/src/widgets/tree_grid.ts
@@ -0,0 +1,278 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Grid, GridCell, GridColumn, GridRow} from './grid'; + +/** + * A row of data with a hierarchical path key and associated cell data. + */ +export interface TreeGridRow { + /** + * Slash-separated path that defines the tree hierarchy. + * Example: "root/child/grandchild" + */ + readonly path: string; + + /** + * Cell data for this row. The first cell will have the tree controls + * (indent, chevron) added automatically. + */ + readonly cells: ReadonlyArray<m.Children>; +} + +/** + * Internal tree node structure. + */ +interface TreeNode { + readonly name: string; + readonly fullPath: string; + readonly depth: number; + readonly children: Map<string, TreeNode>; + cells?: ReadonlyArray<m.Children>; + collapsed: boolean; + parent?: TreeNode; +} + +/** + * TreeGrid component attributes. + */ +export interface TreeGridAttrs { + /** + * Column definitions. The first column will automatically include + * tree controls (indent and chevron). + */ + readonly columns: ReadonlyArray<GridColumn>; + + /** + * Row data with hierarchical paths. + */ + readonly rows: ReadonlyArray<TreeGridRow>; + + /** + * Optional virtualization configuration. + */ + readonly virtualization?: { + readonly rowHeightPx: number; + }; + + /** + * Whether to fill parent container height. + */ + readonly fillHeight?: boolean; + + /** + * Optional CSS class name. + */ + readonly className?: string; + + /** + * Path separator character. Default is '/'. + */ + readonly separator?: string; + + /** + * Content to display when there are no rows. + */ + readonly emptyState?: m.Children; +} + +/** + * TreeGrid - A grid widget that automatically organizes rows into a tree + * structure based on slash-separated path keys. + * + * Example usage: + * ```typescript + * m(TreeGrid, { + * columns: [ + * {key: 'name', header: m(GridHeaderCell, 'Name')}, + * {key: 'value', header: m(GridHeaderCell, 'Value')}, + * ], + * rows: [ + * { + * path: 'root/child1/leaf1', + * cells: [m(GridCell, 'Leaf 1'), m(GridCell, '100')] + * }, + * { + * path: 'root/child1/leaf2', + * cells: [m(GridCell, 'Leaf 2'), m(GridCell, '200')] + * }, + * { + * path: 'root/child2/leaf1', + * cells: [m(GridCell, 'Leaf 1'), m(GridCell, '300')] + * }, + * ], + * }) + * ``` + * + * This will create a tree: + * - root (expandable) + * - child1 (expandable) + * - Leaf 1 (leaf) - 100 + * - Leaf 2 (leaf) - 200 + * - child2 (expandable) + * - Leaf 1 (leaf) - 300 + */ +export class TreeGrid implements m.ClassComponent<TreeGridAttrs> { + private root: TreeNode = this.createRootNode(); + private collapsedPaths: Set<string> = new Set(); + private lastRowsJson = ''; + + private createRootNode(): TreeNode { + return { + name: '', + fullPath: '', + depth: -1, + children: new Map(), + collapsed: false, + }; + } + + /** + * Build the tree structure from flat row data. + * Preserves collapsed state from previous builds. + */ + private buildTree(rows: ReadonlyArray<TreeGridRow>, separator: string): void { + this.root = this.createRootNode(); + + for (const row of rows) { + const parts = row.path.split(separator).filter((p) => p.length > 0); + let currentNode = this.root; + let currentPath = ''; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + currentPath = currentPath ? `${currentPath}${separator}${part}` : part; + + if (!currentNode.children.has(part)) { + const newNode: TreeNode = { + name: part, + fullPath: currentPath, + depth: i, + children: new Map(), + // Restore collapsed state if it was previously collapsed + collapsed: this.collapsedPaths.has(currentPath), + parent: currentNode, + // Only set cells on leaf nodes (last part of path) + cells: i === parts.length - 1 ? row.cells : undefined, + }; + currentNode.children.set(part, newNode); + } + + currentNode = currentNode.children.get(part)!; + } + } + } + + /** + * Flatten the tree into a list of visible rows. + */ + private flattenTree(node: TreeNode, output: TreeNode[]): void { + // Don't add the root node itself + if (node.depth >= 0) { + output.push(node); + } + + // If not collapsed, add children + if (!node.collapsed) { + // Sort children by name for consistent display + const sortedChildren = Array.from(node.children.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + + for (const child of sortedChildren) { + this.flattenTree(child, output); + } + } + } + + view({attrs}: m.Vnode<TreeGridAttrs>) { + const separator = attrs.separator ?? '/'; + + // Only rebuild tree if data has changed + const rowsJson = JSON.stringify(attrs.rows.map((r) => r.path)); + if (rowsJson !== this.lastRowsJson) { + this.lastRowsJson = rowsJson; + this.buildTree(attrs.rows, separator); + } + + // Flatten tree to visible rows + const visibleNodes: TreeNode[] = []; + this.flattenTree(this.root, visibleNodes); + + // Convert tree nodes to grid rows + const gridRows: GridRow[] = visibleNodes.map((node) => { + const row: m.Children[] = []; + const hasChildren = node.children.size > 0; + const isLeaf = !hasChildren; + + // First cell gets tree controls + const firstCell = node.cells?.[0] ?? m(GridCell, node.name); + + // Extract the content from the first cell and add tree controls + const chevron = isLeaf + ? ('leaf' as const) + : node.collapsed + ? ('collapsed' as const) + : ('expanded' as const); + + row.push( + m( + GridCell, + { + indent: node.depth, + chevron, + onChevronClick: hasChildren + ? () => { + node.collapsed = !node.collapsed; + // Update collapsed paths set + if (node.collapsed) { + this.collapsedPaths.add(node.fullPath); + } else { + this.collapsedPaths.delete(node.fullPath); + } + m.redraw(); + } + : undefined, + ...((firstCell as m.Vnode).attrs ?? {}), + }, + (firstCell as m.Vnode).children ?? node.name, + ), + ); + + // Add remaining cells + if (node.cells) { + for (let i = 1; i < node.cells.length; i++) { + row.push(node.cells[i]); + } + } else { + // For intermediate nodes without data, add empty cells + for (let i = 1; i < attrs.columns.length; i++) { + row.push(m(GridCell, '')); + } + } + + return row; + }); + + return m(Grid, { + columns: attrs.columns, + rowData: gridRows, + virtualization: attrs.virtualization, + fillHeight: attrs.fillHeight, + className: attrs.className, + emptyState: attrs.emptyState, + }); + } +}