Add low level tree component
Bug: 273238466
Change-Id: Iacf886864de99b3d4882afcce42fea070519a285
diff --git a/ui/src/assets/details.scss b/ui/src/assets/details.scss
index e9cf324..9ba93b3 100644
--- a/ui/src/assets/details.scss
+++ b/ui/src/assets/details.scss
@@ -614,6 +614,10 @@
}
}
+.pf-details-table {
+ margin: 10px;
+}
+
.ftrace-panel {
display: grid;
grid-template-rows: auto 1fr;
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index ca79a97..79c8350 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -36,3 +36,4 @@
@import "widgets/select";
@import "widgets/menu";
@import "widgets/spinner";
+@import "widgets/tree";
diff --git a/ui/src/assets/widgets/anchor.scss b/ui/src/assets/widgets/anchor.scss
index 425a16b..0d54a00 100644
--- a/ui/src/assets/widgets/anchor.scss
+++ b/ui/src/assets/widgets/anchor.scss
@@ -29,7 +29,7 @@
// For some reason, floating this icon results in the most pleasing vertical
// alignment.
float: right;
- margin: 0;
+ margin: 0 0 0 2px;
font-size: inherit;
line-height: inherit;
color: inherit;
diff --git a/ui/src/assets/widgets/tree.scss b/ui/src/assets/widgets/tree.scss
new file mode 100644
index 0000000..569ad9a
--- /dev/null
+++ b/ui/src/assets/widgets/tree.scss
@@ -0,0 +1,126 @@
+@import "theme";
+
+$indent: 20px;
+
+.pf-tree-left {
+ min-width: max-content;
+ padding: 2px 8px 2px 4px;
+ font-weight: 600;
+}
+
+.pf-tree-right {
+ padding: 2px 4px;
+}
+
+// In tree mode, the values and keys are represented simply using a nested set
+// of divs, where each child is pushed in with a left margin which creates the
+// effect of indenting nested subtrees.
+.pf-ptree {
+ .pf-tree-children {
+ padding-left: $indent;
+ border-left: dotted 1px gray;
+ }
+
+ .pf-tree-node {
+ display: grid;
+ width: max-content;
+ grid-template-columns: [left]auto [right]1fr;
+ border-radius: $pf-border-radius;
+
+ &:hover {
+ background: lightgray;
+ }
+
+ .pf-tree-left {
+ grid-column: left;
+ &:after {
+ content: ":";
+ font-weight: 600;
+ padding-left: 4px;
+ padding-right: 8px;
+ }
+ }
+
+ .pf-tree-right {
+ grid-column: right;
+ }
+ }
+}
+
+// In grid mode, right elements should be horizontally aligned, regardless
+// of indentation level.
+// "Subgrid" is a convenient tool for aligning nested grids to an outer grid's
+// columns, but it is not supported in Chrome as of March 2023.
+// See https://caniuse.com/css-subgrid
+// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout/Subgrid
+//
+// For future reference - this is what a subgrid implementation might look like:
+//
+// .pf-ptree-grid {
+// display: grid;
+// grid-template-columns: auto 1fr;
+//
+// .pf-tree-children {
+// display: grid;
+// grid-column: span 2;
+// grid-template-columns: subgrid;
+// padding-left: $indent;
+// border-left: dotted 1px gray;
+// }
+
+// .pf-tree-node {
+// display: grid;
+// grid-column: span 2;
+// grid-template-columns: subgrid;
+// width: max-content;
+// border-radius: $pf-border-radius;
+
+// &:hover {
+// background: lightgray;
+// }
+// }
+// }
+
+@mixin indentation($max, $level) {
+ @if $level <= $max {
+ .pf-tree-children {
+ .pf-tree-left {
+ margin-left: $level * $indent;
+ }
+ @include indentation($max, $level + 1);
+ }
+ }
+}
+
+.pf-ptree-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+
+ .pf-tree-children {
+ display: contents;
+ }
+
+ .pf-tree-node {
+ display: contents;
+
+ &:hover {
+ background: lightgray;
+ }
+
+ .pf-tree-left {
+ background: inherit;
+ border-radius: $pf-border-radius 0 0 $pf-border-radius;
+ }
+
+ .pf-tree-right {
+ background: inherit;
+ border-radius: 0 $pf-border-radius $pf-border-radius 0;
+ }
+ }
+
+ @include indentation(16, 1);
+}
+
+.pf-tree-children.pf-pgrid-hidden {
+ display: none;
+}
diff --git a/ui/src/assets/widgets_page.scss b/ui/src/assets/widgets_page.scss
index 40b4c76..96286bc 100644
--- a/ui/src/assets/widgets_page.scss
+++ b/ui/src/assets/widgets_page.scss
@@ -57,12 +57,13 @@
.widget-container {
display: flex;
- width: 300px;
- height: 250px;
+ min-width: 300px;
+ min-height: 250px;
border-radius: 3px;
box-shadow: inset 2px 2px 10px #00000020;
border: dashed 1px gray;
margin: 10px 0 10px 0;
+ padding: 16px;
& > * {
margin: auto;
@@ -71,6 +72,6 @@
}
.widget-container-wide {
- width: 450px;
+ min-width: 450px;
}
}
diff --git a/ui/src/frontend/widgets/tree.ts b/ui/src/frontend/widgets/tree.ts
new file mode 100644
index 0000000..90c8faf
--- /dev/null
+++ b/ui/src/frontend/widgets/tree.ts
@@ -0,0 +1,128 @@
+import * as m from 'mithril';
+import {classNames} from '../classnames';
+import {globals} from '../globals';
+import {Button} from './button';
+import {hasChildren} from './utils';
+
+export enum TreeLayout {
+ // Classic heirachical tree layout with no columnar alignment.
+ // Example:
+ // foo: bar
+ // ├ baz: qux
+ // └ quux: corge
+ // grault: garply
+ Tree = 'tree',
+
+ // Heirachical tree layout but right values are horizontally aligned.
+ // Example:
+ // foo bar
+ // ├ baz qux
+ // └ quux corge
+ // grault garply
+ Grid = 'grid',
+}
+
+interface TreeAttrs {
+ // The style of layout.
+ // Defaults to grid.
+ layout?: TreeLayout;
+ // Space delimited class list applied to our tree element.
+ className?: string;
+}
+
+export class Tree implements m.ClassComponent<TreeAttrs> {
+ view({attrs, children}: m.Vnode<TreeAttrs>): m.Children {
+ const {
+ layout: style = TreeLayout.Grid,
+ className = '',
+ } = attrs;
+
+ if (style === TreeLayout.Grid) {
+ return m('.pf-ptree-grid', {class: className}, children);
+ } else if (style === TreeLayout.Tree) {
+ return m('.pf-ptree', {class: className}, children);
+ } else {
+ return null;
+ }
+ }
+}
+
+interface TreeNodeAttrs {
+ // Content to display on the left hand column.
+ // If omitted, this side will be blank.
+ left?: m.Child;
+ // Content to display on the right hand column.
+ // If omitted, this side will be left blank.
+ right?: m.Child;
+ // Whether this node is collapsed or not.
+ // If omitted, collapsed state 'uncontrolled' - i.e. controlled internally.
+ collapsed?: boolean;
+ // Called when the collapsed state is changed, mainly used in controlled mode.
+ onCollapseChanged?: (collapsed: boolean, attrs: TreeNodeAttrs) => void;
+}
+
+export class TreeNode implements m.ClassComponent<TreeNodeAttrs> {
+ private collapsed = false;
+ view(vnode: m.CVnode<TreeNodeAttrs>): m.Children {
+ return [
+ m(
+ '.pf-tree-node',
+ this.renderLeft(vnode),
+ this.renderRight(vnode),
+ ),
+ hasChildren(vnode) && this.renderChildren(vnode),
+ ];
+ }
+
+ private renderLeft(vnode: m.CVnode<TreeNodeAttrs>) {
+ const {
+ attrs: {left},
+ } = vnode;
+
+ return m(
+ '.pf-tree-left',
+ left,
+ hasChildren(vnode) && this.renderCollapseButton(vnode),
+ );
+ }
+
+ private renderRight({attrs: {right}}: m.CVnode<TreeNodeAttrs>) {
+ return m('.pf-tree-right', right);
+ }
+
+ private renderChildren(vnode: m.CVnode<TreeNodeAttrs>) {
+ const {children} = vnode;
+
+ return m(
+ '.pf-tree-children',
+ {
+ class: classNames(this.isCollapsed(vnode) && 'pf-pgrid-hidden'),
+ },
+ children,
+ );
+ }
+
+ private renderCollapseButton(vnode: m.Vnode<TreeNodeAttrs>) {
+ const {attrs, attrs: {onCollapseChanged = () => {}}} = vnode;
+
+ return m(Button, {
+ icon: this.isCollapsed(vnode) ? 'chevron_right' : 'expand_more',
+ minimal: true,
+ compact: true,
+ onclick: () => {
+ this.collapsed = !this.isCollapsed(vnode);
+ onCollapseChanged(this.collapsed, attrs);
+ globals.rafScheduler.scheduleFullRedraw();
+ },
+ });
+ }
+
+ private isCollapsed({attrs}: m.Vnode<TreeNodeAttrs>): boolean {
+ // If collapsed is omitted, use our local collapsed state instead.
+ const {
+ collapsed = this.collapsed,
+ } = attrs;
+
+ return collapsed;
+ }
+}
diff --git a/ui/src/frontend/widgets/utils.ts b/ui/src/frontend/widgets/utils.ts
index 49f0a8c..1bca9ec 100644
--- a/ui/src/frontend/widgets/utils.ts
+++ b/ui/src/frontend/widgets/utils.ts
@@ -39,6 +39,6 @@
}
// Check if a mithril component vnode has children
-export function hasChildren({children}: m.CVnode<any>): boolean {
+export function hasChildren({children}: m.Vnode<any>): boolean {
return Array.isArray(children) && children.length > 0;
}
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index 41fc347..8c7c0c2 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -32,6 +32,7 @@
import {Select} from './widgets/select';
import {Spinner} from './widgets/spinner';
import {TextInput} from './widgets/text_input';
+import {Tree, TreeLayout, TreeNode} from './widgets/tree';
const options: {[key: string]: boolean} = {
foobar: false,
@@ -502,6 +503,64 @@
easing: false,
},
}),
+ m('h2', 'Tree'),
+ m(WidgetShowcase, {
+ renderWidget: (opts) => m(
+ Tree,
+ opts,
+ m(TreeNode, {left: 'Name', right: 'my_event'}),
+ m(TreeNode, {left: 'CPU', right: '2'}),
+ m(TreeNode, {
+ left: 'SQL',
+ right: m(
+ PopupMenu2,
+ {
+ trigger: m(Anchor, {
+ text: 'SELECT * FROM raw WHERE id = 123',
+ icon: 'unfold_more',
+ }),
+ },
+ m(MenuItem, {
+ label: 'Copy SQL Query',
+ icon: 'content_copy',
+ }),
+ m(MenuItem, {
+ label: 'Execute Query in new tab',
+ icon: 'open_in_new',
+ }),
+ ),
+ }),
+ m(TreeNode, {
+ left: 'Thread',
+ right: m(Anchor, {text: 'my_thread[456]', icon: 'open_in_new'}),
+ }),
+ m(TreeNode, {
+ left: 'Process',
+ right: m(Anchor, {text: '/bin/foo[789]', icon: 'open_in_new'}),
+ }),
+ m(
+ TreeNode,
+ {left: 'Args', right: 'foo: bar, baz: qux'},
+ m(TreeNode, {left: 'foo', right: 'bar'}),
+ m(TreeNode, {left: 'baz', right: 'qux'}),
+ m(
+ TreeNode,
+ {left: 'quux'},
+ m(TreeNode, {left: '[0]', right: 'corge'}),
+ m(TreeNode, {left: '[1]', right: 'grault'}),
+ m(TreeNode, {left: '[2]', right: 'garply'}),
+ m(TreeNode, {left: '[3]', right: 'waldo'}),
+ ),
+ ),
+ ),
+ initialOpts: {
+ layout: new EnumOption(
+ TreeLayout.Grid,
+ Object.values(TreeLayout),
+ ),
+ },
+ wide: true,
+ }),
);
},
});