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,
+        }),
     );
   },
 });