ui: Move various components into public/lib

- Move slice_details, thread_details, duration.ts, and timestamp.ts
  into public/lib as they are used by plugins.
- Make timestamp/duration format settings available on App/Trace.
- Refactored Timestamp/DurationWidget widgets a little.

Note: These sorts of settings (timestamp/duration format) are begging
for a generic settings infrastructure, similar to flags but with more
more options, but this is out of scope for this CL.

Change-Id: If507c083bff017e17948bbde36418eb7332f896c
diff --git a/python/tools/check_imports.py b/python/tools/check_imports.py
index 6f66efe..07b6843 100755
--- a/python/tools/check_imports.py
+++ b/python/tools/check_imports.py
@@ -106,7 +106,6 @@
     ('/*plugins/*', [
         '/frontend/slice_layout',
         '/frontend/slice_args',
-        '/frontend/slice_details',
         '/frontend/checkerboard',
         '/common/track_helper',
         '/common/track_data',
@@ -134,6 +133,12 @@
         '/frontend/tracks/generic_slice_details_tab',
     ]),
 
+    # TODO(stevegolton): It's too much effort to change all the callsites of
+    # Timestamp and Duration widgets in order to inject trace into them.
+    ('/public/lib/widgets/*', [
+        '/core/app_impl',
+    ]),
+
     # TODO(primiano): controller-related tech debt.
     ('/frontend/index', '/controller/*'),
     ('/controller/*', ['/base/*', '/core/*', '/common/*']),
diff --git a/ui/src/core/timeline.ts b/ui/src/core/timeline.ts
index bc8a613..0efc660 100644
--- a/ui/src/core/timeline.ts
+++ b/ui/src/core/timeline.ts
@@ -12,14 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertTrue} from '../base/logging';
+import {assertTrue, assertUnreachable} from '../base/logging';
 import {Time, time, TimeSpan} from '../base/time';
 import {HighPrecisionTimeSpan} from '../base/high_precision_time_span';
 import {Area} from '../public/selection';
 import {raf} from './raf_scheduler';
 import {HighPrecisionTime} from '../base/high_precision_time';
-import {Timeline} from '../public/timeline';
-import {timestampFormat, TimestampFormat} from './timestamp_format';
+import {DurationPrecision, Timeline, TimestampFormat} from '../public/timeline';
+import {
+  durationPrecision,
+  setDurationPrecision,
+  setTimestampFormat,
+  timestampFormat,
+} from './timestamp_format';
 import {TraceInfo} from '../public/trace_info';
 
 const MIN_DURATION = 10;
@@ -183,7 +188,7 @@
     switch (fmt) {
       case TimestampFormat.Timecode:
       case TimestampFormat.Seconds:
-      case TimestampFormat.Milliseoncds:
+      case TimestampFormat.Milliseconds:
       case TimestampFormat.Microseconds:
         return this.traceInfo.start;
       case TimestampFormat.TraceNs:
@@ -194,8 +199,7 @@
       case TimestampFormat.TraceTz:
         return this.traceInfo.traceTzOffset;
       default:
-        const x: never = fmt;
-        throw new Error(`Unsupported format ${x}`);
+        assertUnreachable(fmt);
     }
   }
 
@@ -203,4 +207,20 @@
   toDomainTime(ts: time): time {
     return Time.sub(ts, this.timestampOffset());
   }
+
+  get timestampFormat() {
+    return timestampFormat();
+  }
+
+  set timestampFormat(format: TimestampFormat) {
+    setTimestampFormat(format);
+  }
+
+  get durationPrecision() {
+    return durationPrecision();
+  }
+
+  set durationPrecision(precision: DurationPrecision) {
+    setDurationPrecision(precision);
+  }
 }
diff --git a/ui/src/core/timestamp_format.ts b/ui/src/core/timestamp_format.ts
index f4d7d80..25a8ba1 100644
--- a/ui/src/core/timestamp_format.ts
+++ b/ui/src/core/timestamp_format.ts
@@ -13,17 +13,7 @@
 // limitations under the License.
 
 import {isEnumValue} from '../base/object_utils';
-
-export enum TimestampFormat {
-  Timecode = 'timecode',
-  TraceNs = 'traceNs',
-  TraceNsLocale = 'traceNsLocale',
-  Seconds = 'seconds',
-  Milliseoncds = 'milliseconds',
-  Microseconds = 'microseconds',
-  UTC = 'utc',
-  TraceTz = 'traceTz',
-}
+import {DurationPrecision, TimestampFormat} from '../public/timeline';
 
 let timestampFormatCached: TimestampFormat | undefined;
 
@@ -49,11 +39,6 @@
   localStorage.setItem(TIMESTAMP_FORMAT_KEY, format);
 }
 
-export enum DurationPrecision {
-  Full = 'full',
-  HumanReadable = 'human_readable',
-}
-
 let durationFormatCached: DurationPrecision | undefined;
 
 const DURATION_FORMAT_KEY = 'durationFormat';
diff --git a/ui/src/frontend/aggregation_panel.ts b/ui/src/frontend/aggregation_panel.ts
index 3d441ba..2e1d00f 100644
--- a/ui/src/frontend/aggregation_panel.ts
+++ b/ui/src/frontend/aggregation_panel.ts
@@ -20,7 +20,7 @@
   isEmptyData,
 } from '../public/aggregation';
 import {colorForState} from '../public/lib/colorizer';
-import {DurationWidget} from './widgets/duration';
+import {DurationWidget} from '../public/lib/widgets/duration';
 import {EmptyState} from '../widgets/empty_state';
 import {Anchor} from '../widgets/anchor';
 import {Icons} from '../base/semantic_icons';
diff --git a/ui/src/frontend/named_slice_track.ts b/ui/src/frontend/named_slice_track.ts
index 07043e9..6d79af2 100644
--- a/ui/src/frontend/named_slice_track.ts
+++ b/ui/src/frontend/named_slice_track.ts
@@ -26,10 +26,10 @@
   SLICE_FLAGS_INSTANT,
 } from './base_slice_track';
 import {ThreadSliceDetailsPanel} from './thread_slice_details_tab';
-import {renderDuration} from './widgets/duration';
 import {TraceImpl} from '../core/trace_impl';
 import {assertIsInstance} from '../base/logging';
 import {SourceDataset, Dataset} from '../trace_processor/dataset';
+import {formatDuration} from '../public/lib/time_utils';
 import {Trace} from '../public/trace';
 
 export const NAMED_ROW = {
@@ -66,7 +66,7 @@
     } else if (flags & SLICE_FLAGS_INSTANT) {
       duration = 'Instant';
     } else {
-      duration = renderDuration(dur);
+      duration = formatDuration(this.trace, dur);
     }
     args.tooltip = [`${title} - [${duration}]`];
   }
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index ac5b015..c4d76b7 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -23,7 +23,7 @@
 import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper';
 import {Size2D} from '../base/geom';
 import {Panel} from './panel_container';
-import {Timestamp} from './widgets/timestamp';
+import {Timestamp} from '../public/lib/widgets/timestamp';
 import {assertUnreachable} from '../base/logging';
 import {DetailsPanel} from '../public/details_panel';
 import {TimeScale} from '../base/time_scale';
diff --git a/ui/src/frontend/overview_timeline_panel.ts b/ui/src/frontend/overview_timeline_panel.ts
index 8eb41ec..0d04ce2 100644
--- a/ui/src/frontend/overview_timeline_panel.ts
+++ b/ui/src/frontend/overview_timeline_panel.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 import {Duration, Time, TimeSpan, duration, time} from '../base/time';
 import {colorForCpu} from '../public/lib/colorizer';
-import {timestampFormat, TimestampFormat} from '../core/timestamp_format';
+import {timestampFormat} from '../core/timestamp_format';
 import {
   OVERVIEW_TIMELINE_NON_VISIBLE_COLOR,
   TRACK_SHELL_WIDTH,
@@ -39,6 +39,8 @@
 import {LONG, NUM} from '../trace_processor/query_result';
 import {raf} from '../core/raf_scheduler';
 import {getOrCreate} from '../base/utils';
+import {assertUnreachable} from '../base/logging';
+import {TimestampFormat} from '../public/timeline';
 
 const tracesData = new WeakMap<TraceImpl, OverviewDataLoader>();
 
@@ -299,15 +301,14 @@
     case TimestampFormat.Seconds:
       ctx.fillText(Time.formatSeconds(time), x, y, minWidth);
       break;
-    case TimestampFormat.Milliseoncds:
+    case TimestampFormat.Milliseconds:
       ctx.fillText(Time.formatMilliseconds(time), x, y, minWidth);
       break;
     case TimestampFormat.Microseconds:
       ctx.fillText(Time.formatMicroseconds(time), x, y, minWidth);
       break;
     default:
-      const z: never = fmt;
-      throw new Error(`Invalid timestamp ${z}`);
+      assertUnreachable(fmt);
   }
 }
 
diff --git a/ui/src/frontend/pivot_table.ts b/ui/src/frontend/pivot_table.ts
index 925cfed..734d2a0 100644
--- a/ui/src/frontend/pivot_table.ts
+++ b/ui/src/frontend/pivot_table.ts
@@ -36,7 +36,7 @@
 } from '../core/pivot_table_query_generator';
 import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
 import {AttributeModalHolder} from './tables/attribute_modal_holder';
-import {DurationWidget} from './widgets/duration';
+import {DurationWidget} from '../public/lib/widgets/duration';
 import {getSqlTableDescription} from './widgets/sql/table/sql_table_registry';
 import {assertExists, assertFalse} from '../base/logging';
 import {Filter, SqlColumn} from './widgets/sql/table/column';
diff --git a/ui/src/frontend/thread_slice_details_tab.ts b/ui/src/frontend/thread_slice_details_tab.ts
index b549971..b872a6d 100644
--- a/ui/src/frontend/thread_slice_details_tab.ts
+++ b/ui/src/frontend/thread_slice_details_tab.ts
@@ -25,14 +25,14 @@
 import {Tree} from '../widgets/tree';
 import {Flow, FlowPoint} from '../core/flow_types';
 import {hasArgs, renderArguments} from './slice_args';
-import {renderDetails} from './slice_details';
+import {renderDetails} from '../public/lib/details/slice_details';
 import {getSlice, SliceDetails} from '../trace_processor/sql_utils/slice';
 import {
   BreakdownByThreadState,
   breakDownIntervalByThreadState,
-} from './sql/thread_state';
+} from '../public/lib/details/thread_state';
 import {asSliceSqlId} from '../trace_processor/sql_utils/core_types';
-import {DurationWidget} from './widgets/duration';
+import {DurationWidget} from '../public/lib/widgets/duration';
 import {SliceRef} from './widgets/slice';
 import {BasicTable} from '../widgets/basic_table';
 import {getSqlTableDescription} from './widgets/sql/table/sql_table_registry';
diff --git a/ui/src/frontend/time_axis_panel.ts b/ui/src/frontend/time_axis_panel.ts
index 143e402..db7cf9c 100644
--- a/ui/src/frontend/time_axis_panel.ts
+++ b/ui/src/frontend/time_axis_panel.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 import {Time, time, toISODateOnly} from '../base/time';
-import {TimestampFormat, timestampFormat} from '../core/timestamp_format';
+import {timestampFormat} from '../core/timestamp_format';
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {
   getMaxMajorTicks,
@@ -27,6 +27,8 @@
 import {TimeScale} from '../base/time_scale';
 import {canvasClip} from '../base/canvas_utils';
 import {Trace} from '../public/trace';
+import {assertUnreachable} from '../base/logging';
+import {TimestampFormat} from '../public/timeline';
 
 export class TimeAxisPanel implements Panel {
   readonly kind = 'panel';
@@ -58,11 +60,14 @@
 
   private renderOffsetTimestamp(ctx: CanvasRenderingContext2D): void {
     const offset = this.trace.timeline.timestampOffset();
-    switch (timestampFormat()) {
+    const timestampFormat = this.trace.timeline.timestampFormat;
+    switch (timestampFormat) {
       case TimestampFormat.TraceNs:
       case TimestampFormat.TraceNsLocale:
         break;
       case TimestampFormat.Seconds:
+      case TimestampFormat.Milliseconds:
+      case TimestampFormat.Microseconds:
       case TimestampFormat.Timecode:
         const width = renderTimestamp(ctx, offset, 6, 10, MIN_PX_PER_STEP);
         ctx.fillText('+', 6 + width + 2, 10, 6);
@@ -83,6 +88,8 @@
         const dateTzStr = toISODateOnly(offsetTzDate);
         ctx.fillText(dateTzStr, 6, 10);
         break;
+      default:
+        assertUnreachable(timestampFormat);
     }
   }
 
@@ -130,7 +137,7 @@
       return renderRawTimestamp(ctx, time.toLocaleString(), x, y, minWidth);
     case TimestampFormat.Seconds:
       return renderRawTimestamp(ctx, Time.formatSeconds(time), x, y, minWidth);
-    case TimestampFormat.Milliseoncds:
+    case TimestampFormat.Milliseconds:
       return renderRawTimestamp(
         ctx,
         Time.formatMilliseconds(time),
diff --git a/ui/src/frontend/time_selection_panel.ts b/ui/src/frontend/time_selection_panel.ts
index 5621ff4..599ed5b 100644
--- a/ui/src/frontend/time_selection_panel.ts
+++ b/ui/src/frontend/time_selection_panel.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 import {time, Time} from '../base/time';
-import {timestampFormat, TimestampFormat} from '../core/timestamp_format';
+import {timestampFormat} from '../core/timestamp_format';
 import {
   BACKGROUND_COLOR,
   FOREGROUND_COLOR,
@@ -23,10 +23,12 @@
 import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper';
 import {Size2D} from '../base/geom';
 import {Panel} from './panel_container';
-import {renderDuration} from './widgets/duration';
 import {canvasClip} from '../base/canvas_utils';
 import {TimeScale} from '../base/time_scale';
 import {TraceImpl} from '../core/trace_impl';
+import {formatDuration} from '../public/lib/time_utils';
+import {TimestampFormat} from '../public/timeline';
+import {assertUnreachable} from '../base/logging';
 
 export interface BBox {
   x: number;
@@ -235,7 +237,7 @@
   ) {
     const xLeft = timescale.timeToPx(start);
     const xRight = timescale.timeToPx(end);
-    const label = renderDuration(end - start);
+    const label = formatDuration(this.trace, end - start);
     drawHBar(
       ctx,
       {
@@ -273,12 +275,11 @@
       return time.toLocaleString();
     case TimestampFormat.Seconds:
       return Time.formatSeconds(time);
-    case TimestampFormat.Milliseoncds:
+    case TimestampFormat.Milliseconds:
       return Time.formatMilliseconds(time);
     case TimestampFormat.Microseconds:
       return Time.formatMicroseconds(time);
     default:
-      const z: never = fmt;
-      throw new Error(`Invalid timestamp ${z}`);
+      assertUnreachable(fmt);
   }
 }
diff --git a/ui/src/frontend/ui_main.ts b/ui/src/frontend/ui_main.ts
index b8d8dda..b28a41a 100644
--- a/ui/src/frontend/ui_main.ts
+++ b/ui/src/frontend/ui_main.ts
@@ -19,10 +19,8 @@
 import {assertExists, assertUnreachable} from '../base/logging';
 import {undoCommonChatAppReplacements} from '../base/string_utils';
 import {
-  DurationPrecision,
   setDurationPrecision,
   setTimestampFormat,
-  TimestampFormat,
 } from '../core/timestamp_format';
 import {raf} from '../core/raf_scheduler';
 import {Command} from '../public/command';
@@ -46,6 +44,7 @@
 import {NotesEditorTab} from './notes_panel';
 import {NotesListEditor} from './notes_list_editor';
 import {getTimeSpanOfSelectionOrVisibleWindow} from '../public/utils';
+import {DurationPrecision, TimestampFormat} from '../public/timeline';
 
 const OMNIBOX_INPUT_REF = 'omnibox';
 
@@ -132,7 +131,7 @@
               displayName: 'Realtime (Trace TZ)',
             },
             {key: TimestampFormat.Seconds, displayName: 'Seconds'},
-            {key: TimestampFormat.Milliseoncds, displayName: 'Milliseconds'},
+            {key: TimestampFormat.Milliseconds, displayName: 'Milliseconds'},
             {key: TimestampFormat.Microseconds, displayName: 'Microseconds'},
             {key: TimestampFormat.TraceNs, displayName: 'Trace nanoseconds'},
             {
diff --git a/ui/src/frontend/widgets/duration.ts b/ui/src/frontend/widgets/duration.ts
deleted file mode 100644
index a623406..0000000
--- a/ui/src/frontend/widgets/duration.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2023 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 {copyToClipboard} from '../../base/clipboard';
-import {Icons} from '../../base/semantic_icons';
-import {Duration, duration} from '../../base/time';
-import {
-  DurationPrecision,
-  durationPrecision,
-  setDurationPrecision,
-  TimestampFormat,
-  timestampFormat,
-} from '../../core/timestamp_format';
-import {raf} from '../../core/raf_scheduler';
-import {Anchor} from '../../widgets/anchor';
-import {MenuDivider, MenuItem, PopupMenu2} from '../../widgets/menu';
-import {menuItemForFormat} from './timestamp';
-
-interface DurationWidgetAttrs {
-  dur: duration;
-  extraMenuItems?: m.Child[];
-}
-
-export class DurationWidget implements m.ClassComponent<DurationWidgetAttrs> {
-  view({attrs}: m.Vnode<DurationWidgetAttrs>) {
-    const {dur} = attrs;
-    if (dur === -1n) {
-      return '(Did not end)';
-    }
-    return m(
-      PopupMenu2,
-      {
-        trigger: m(Anchor, renderDuration(dur)),
-      },
-      m(MenuItem, {
-        icon: Icons.Copy,
-        label: `Copy raw value`,
-        onclick: () => {
-          copyToClipboard(dur.toString());
-        },
-      }),
-      m(
-        MenuItem,
-        {
-          label: 'Set time format',
-        },
-        menuItemForFormat(TimestampFormat.Timecode, 'Timecode'),
-        menuItemForFormat(TimestampFormat.UTC, 'Realtime (UTC)'),
-        menuItemForFormat(TimestampFormat.TraceTz, 'Realtime (Trace TZ)'),
-        menuItemForFormat(TimestampFormat.Seconds, 'Seconds'),
-        menuItemForFormat(TimestampFormat.Milliseoncds, 'Milliseconds'),
-        menuItemForFormat(TimestampFormat.Microseconds, 'Microseconds'),
-        menuItemForFormat(TimestampFormat.TraceNs, 'Raw'),
-        menuItemForFormat(
-          TimestampFormat.TraceNsLocale,
-          'Raw (with locale-specific formatting)',
-        ),
-      ),
-      m(
-        MenuItem,
-        {
-          label: 'Duration precision',
-          disabled: !durationPrecisionHasEffect(),
-          title: 'Not configurable with current time format',
-        },
-        menuItemForPrecision(DurationPrecision.Full, 'Full'),
-        menuItemForPrecision(DurationPrecision.HumanReadable, 'Human readable'),
-      ),
-      attrs.extraMenuItems ? [m(MenuDivider), attrs.extraMenuItems] : null,
-    );
-  }
-}
-
-function menuItemForPrecision(
-  value: DurationPrecision,
-  label: string,
-): m.Children {
-  return m(MenuItem, {
-    label,
-    active: value === durationPrecision(),
-    onclick: () => {
-      setDurationPrecision(value);
-      raf.scheduleFullRedraw();
-    },
-  });
-}
-
-function durationPrecisionHasEffect(): boolean {
-  switch (timestampFormat()) {
-    case TimestampFormat.Timecode:
-    case TimestampFormat.UTC:
-    case TimestampFormat.TraceTz:
-      return true;
-    default:
-      return false;
-  }
-}
-
-export function renderDuration(dur: duration): string {
-  const fmt = timestampFormat();
-  switch (fmt) {
-    case TimestampFormat.UTC:
-    case TimestampFormat.TraceTz:
-    case TimestampFormat.Timecode:
-      return renderFormattedDuration(dur);
-    case TimestampFormat.TraceNs:
-      return dur.toString();
-    case TimestampFormat.TraceNsLocale:
-      return dur.toLocaleString();
-    case TimestampFormat.Seconds:
-      return Duration.formatSeconds(dur);
-    case TimestampFormat.Milliseoncds:
-      return Duration.formatMilliseconds(dur);
-    case TimestampFormat.Microseconds:
-      return Duration.formatMicroseconds(dur);
-    default:
-      const x: never = fmt;
-      throw new Error(`Invalid format ${x}`);
-  }
-}
-
-function renderFormattedDuration(dur: duration): string {
-  const fmt = durationPrecision();
-  switch (fmt) {
-    case DurationPrecision.HumanReadable:
-      return Duration.humanise(dur);
-    case DurationPrecision.Full:
-      return Duration.format(dur);
-    default:
-      const x: never = fmt;
-      throw new Error(`Invalid format ${x}`);
-  }
-}
diff --git a/ui/src/frontend/widgets/sql/details/details.ts b/ui/src/frontend/widgets/sql/details/details.ts
index 069b723..b05ed41 100644
--- a/ui/src/frontend/widgets/sql/details/details.ts
+++ b/ui/src/frontend/widgets/sql/details/details.ts
@@ -30,8 +30,8 @@
 import {SqlRef} from '../../../../widgets/sql_ref';
 import {Tree, TreeNode} from '../../../../widgets/tree';
 import {hasArgs, renderArguments} from '../../../slice_args';
-import {DurationWidget} from '../../../widgets/duration';
-import {Timestamp as TimestampWidget} from '../../../widgets/timestamp';
+import {DurationWidget} from '../../../../public/lib/widgets/duration';
+import {Timestamp as TimestampWidget} from '../../../../public/lib/widgets/timestamp';
 import {sqlIdRegistry} from './sql_ref_renderer_registry';
 import {Trace} from '../../../../public/trace';
 
diff --git a/ui/src/frontend/widgets/sql/table/well_known_columns.ts b/ui/src/frontend/widgets/sql/table/well_known_columns.ts
index 7c1eb98..8ed593e 100644
--- a/ui/src/frontend/widgets/sql/table/well_known_columns.ts
+++ b/ui/src/frontend/widgets/sql/table/well_known_columns.ts
@@ -29,13 +29,13 @@
 import {Anchor} from '../../../../widgets/anchor';
 import {renderError} from '../../../../widgets/error';
 import {MenuDivider, MenuItem, PopupMenu2} from '../../../../widgets/menu';
-import {DurationWidget} from '../../duration';
+import {DurationWidget} from '../../../../public/lib/widgets/duration';
 import {processRefMenuItems, showProcessDetailsMenuItem} from '../../process';
 import {SchedRef} from '../../sched';
 import {SliceRef} from '../../slice';
 import {showThreadDetailsMenuItem, threadRefMenuItems} from '../../thread';
 import {ThreadStateRef} from '../../thread_state';
-import {Timestamp} from '../../timestamp';
+import {Timestamp} from '../../../../public/lib/widgets/timestamp';
 import {
   AggregationConfig,
   SourceTable,
diff --git a/ui/src/frontend/widgets/timestamp.ts b/ui/src/frontend/widgets/timestamp.ts
deleted file mode 100644
index 7fd44f1..0000000
--- a/ui/src/frontend/widgets/timestamp.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2023 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 {copyToClipboard} from '../../base/clipboard';
-import {Icons} from '../../base/semantic_icons';
-import {time, Time} from '../../base/time';
-import {
-  setTimestampFormat,
-  TimestampFormat,
-  timestampFormat,
-} from '../../core/timestamp_format';
-import {raf} from '../../core/raf_scheduler';
-import {Anchor} from '../../widgets/anchor';
-import {MenuDivider, MenuItem, PopupMenu2} from '../../widgets/menu';
-import {Trace} from '../../public/trace';
-import {AppImpl} from '../../core/app_impl';
-import {assertExists} from '../../base/logging';
-
-// import {MenuItem, PopupMenu2} from './menu';
-
-interface TimestampAttrs {
-  // The timestamp to print, this should be the absolute, raw timestamp as
-  // found in trace processor.
-  ts: time;
-  // Custom text value to show instead of the default HH:MM:SS.mmm uuu nnn
-  // formatting.
-  display?: m.Children;
-  extraMenuItems?: m.Child[];
-}
-
-export class Timestamp implements m.ClassComponent<TimestampAttrs> {
-  private readonly trace: Trace;
-
-  constructor() {
-    // TODO(primiano): the Trace object should be injected into the attrs, but
-    // there are too many users of this class and doing so requires a larger
-    // refactoring CL. Either that or we should find a different way to plumb
-    // the hoverCursorTimestamp.
-    this.trace = assertExists(AppImpl.instance.trace);
-  }
-
-  view({attrs}: m.Vnode<TimestampAttrs>) {
-    const {ts} = attrs;
-    const timeline = this.trace.timeline;
-    return m(
-      PopupMenu2,
-      {
-        trigger: m(
-          Anchor,
-          {
-            onmouseover: () => (timeline.hoverCursorTimestamp = ts),
-            onmouseout: () => (timeline.hoverCursorTimestamp = undefined),
-          },
-          attrs.display ?? renderTimestamp(timeline.toDomainTime(ts)),
-        ),
-      },
-      m(MenuItem, {
-        icon: Icons.Copy,
-        label: `Copy raw value`,
-        onclick: () => {
-          copyToClipboard(ts.toString());
-        },
-      }),
-      m(
-        MenuItem,
-        {
-          label: 'Time format',
-        },
-        menuItemForFormat(TimestampFormat.Timecode, 'Timecode'),
-        menuItemForFormat(TimestampFormat.UTC, 'Realtime (UTC)'),
-        menuItemForFormat(TimestampFormat.TraceTz, 'Realtime (Trace TZ)'),
-        menuItemForFormat(TimestampFormat.Seconds, 'Seconds'),
-        menuItemForFormat(TimestampFormat.Milliseoncds, 'Milliseconds'),
-        menuItemForFormat(TimestampFormat.Microseconds, 'Microseconds'),
-        menuItemForFormat(TimestampFormat.TraceNs, 'Raw'),
-        menuItemForFormat(
-          TimestampFormat.TraceNsLocale,
-          'Raw (with locale-specific formatting)',
-        ),
-      ),
-      attrs.extraMenuItems ? [m(MenuDivider), attrs.extraMenuItems] : null,
-    );
-  }
-}
-
-export function menuItemForFormat(
-  value: TimestampFormat,
-  label: string,
-): m.Children {
-  return m(MenuItem, {
-    label,
-    active: value === timestampFormat(),
-    onclick: () => {
-      setTimestampFormat(value);
-      raf.scheduleFullRedraw();
-    },
-  });
-}
-
-function renderTimestamp(domainTime: time): m.Children {
-  const fmt = timestampFormat();
-  switch (fmt) {
-    case TimestampFormat.UTC:
-    case TimestampFormat.TraceTz:
-    case TimestampFormat.Timecode:
-      return renderTimecode(domainTime);
-    case TimestampFormat.TraceNs:
-      return domainTime.toString();
-    case TimestampFormat.TraceNsLocale:
-      return domainTime.toLocaleString();
-    case TimestampFormat.Seconds:
-      return Time.formatSeconds(domainTime);
-    case TimestampFormat.Milliseoncds:
-      return Time.formatMilliseconds(domainTime);
-    case TimestampFormat.Microseconds:
-      return Time.formatMicroseconds(domainTime);
-    default:
-      const x: never = fmt;
-      throw new Error(`Invalid timestamp ${x}`);
-  }
-}
-
-export function renderTimecode(time: time): m.Children {
-  const {dhhmmss, millis, micros, nanos} = Time.toTimecode(time);
-  return m(
-    'span.pf-timecode',
-    m('span.pf-timecode-hms', dhhmmss),
-    '.',
-    m('span.pf-timecode-millis', millis),
-    m('span.pf-timecode-micros', micros),
-    m('span.pf-timecode-nanos', nanos),
-  );
-}
diff --git a/ui/src/plugins/dev.perfetto.AndroidLog/logs_panel.ts b/ui/src/plugins/dev.perfetto.AndroidLog/logs_panel.ts
index 48714ba..5c79fdd 100644
--- a/ui/src/plugins/dev.perfetto.AndroidLog/logs_panel.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidLog/logs_panel.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 import {time, Time, TimeSpan} from '../../base/time';
 import {DetailsShell} from '../../widgets/details_shell';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {Engine} from '../../trace_processor/engine';
 import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
 import {Monitor} from '../../base/monitor';
diff --git a/ui/src/plugins/dev.perfetto.Counter/counter_details_panel.ts b/ui/src/plugins/dev.perfetto.Counter/counter_details_panel.ts
index e0ff568..48649e9 100644
--- a/ui/src/plugins/dev.perfetto.Counter/counter_details_panel.ts
+++ b/ui/src/plugins/dev.perfetto.Counter/counter_details_panel.ts
@@ -27,8 +27,8 @@
 import {GridLayout} from '../../widgets/grid_layout';
 import {Section} from '../../widgets/section';
 import {Tree, TreeNode} from '../../widgets/tree';
-import {Timestamp} from '../../frontend/widgets/timestamp';
-import {DurationWidget} from '../../frontend/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
 import {TrackEventSelection} from '../../public/selection';
 import {hasArgs, renderArguments} from '../../frontend/slice_args';
 import {asArgSetId} from '../../trace_processor/sql_utils/core_types';
diff --git a/ui/src/plugins/dev.perfetto.CpuProfile/cpu_profile_details_panel.ts b/ui/src/plugins/dev.perfetto.CpuProfile/cpu_profile_details_panel.ts
index 060746e..91c6d2b 100644
--- a/ui/src/plugins/dev.perfetto.CpuProfile/cpu_profile_details_panel.ts
+++ b/ui/src/plugins/dev.perfetto.CpuProfile/cpu_profile_details_panel.ts
@@ -18,7 +18,7 @@
   metricsFromTableOrSubquery,
   QueryFlamegraph,
 } from '../../public/lib/query_flamegraph';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {
   TrackEventDetailsPanel,
   TrackEventDetailsPanelSerializeArgs,
diff --git a/ui/src/plugins/dev.perfetto.CpuSlices/sched_details_tab.ts b/ui/src/plugins/dev.perfetto.CpuSlices/sched_details_tab.ts
index f4b3b93..1aaaf1a 100644
--- a/ui/src/plugins/dev.perfetto.CpuSlices/sched_details_tab.ts
+++ b/ui/src/plugins/dev.perfetto.CpuSlices/sched_details_tab.ts
@@ -19,8 +19,8 @@
 import {Section} from '../../widgets/section';
 import {SqlRef} from '../../widgets/sql_ref';
 import {Tree, TreeNode} from '../../widgets/tree';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {asSchedSqlId} from '../../trace_processor/sql_utils/core_types';
 import {
   getSched,
diff --git a/ui/src/plugins/dev.perfetto.Ftrace/ftrace_explorer.ts b/ui/src/plugins/dev.perfetto.Ftrace/ftrace_explorer.ts
index b4036e5..09e45f1 100644
--- a/ui/src/plugins/dev.perfetto.Ftrace/ftrace_explorer.ts
+++ b/ui/src/plugins/dev.perfetto.Ftrace/ftrace_explorer.ts
@@ -22,7 +22,7 @@
   PopupMultiSelect,
 } from '../../widgets/multiselect';
 import {PopupPosition} from '../../widgets/popup';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {FtraceFilter, FtraceStat} from './common';
 import {Engine} from '../../trace_processor/engine';
 import {LONG, NUM, STR, STR_NULL} from '../../trace_processor/query_result';
diff --git a/ui/src/plugins/dev.perfetto.HeapProfile/heap_profile_details_panel.ts b/ui/src/plugins/dev.perfetto.HeapProfile/heap_profile_details_panel.ts
index c3e145e..5b45256 100644
--- a/ui/src/plugins/dev.perfetto.HeapProfile/heap_profile_details_panel.ts
+++ b/ui/src/plugins/dev.perfetto.HeapProfile/heap_profile_details_panel.ts
@@ -21,7 +21,7 @@
   metricsFromTableOrSubquery,
 } from '../../public/lib/query_flamegraph';
 import {convertTraceToPprofAndDownload} from '../../frontend/trace_converter';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {
   TrackEventDetailsPanel,
   TrackEventDetailsPanelSerializeArgs,
diff --git a/ui/src/plugins/dev.perfetto.PerfSamplesProfile/perf_samples_profile_track.ts b/ui/src/plugins/dev.perfetto.PerfSamplesProfile/perf_samples_profile_track.ts
index 06f3244..5157569 100644
--- a/ui/src/plugins/dev.perfetto.PerfSamplesProfile/perf_samples_profile_track.ts
+++ b/ui/src/plugins/dev.perfetto.PerfSamplesProfile/perf_samples_profile_track.ts
@@ -32,7 +32,7 @@
   QueryFlamegraph,
 } from '../../public/lib/query_flamegraph';
 import {DetailsShell} from '../../widgets/details_shell';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {time} from '../../base/time';
 import {TrackEventDetailsPanel} from '../../public/details_panel';
 import {Flamegraph, FLAMEGRAPH_STATE_SCHEMA} from '../../widgets/flamegraph';
diff --git a/ui/src/plugins/dev.perfetto.ThreadState/thread_state_details_panel.ts b/ui/src/plugins/dev.perfetto.ThreadState/thread_state_details_panel.ts
index 0a2287b7..1ba8c3b 100644
--- a/ui/src/plugins/dev.perfetto.ThreadState/thread_state_details_panel.ts
+++ b/ui/src/plugins/dev.perfetto.ThreadState/thread_state_details_panel.ts
@@ -27,8 +27,8 @@
   getThreadStateFromConstraints,
   ThreadState,
 } from '../../trace_processor/sql_utils/thread_state';
-import {DurationWidget, renderDuration} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {getProcessName} from '../../trace_processor/sql_utils/process';
 import {
   getFullThreadName,
@@ -42,6 +42,7 @@
 import {goToSchedSlice} from '../../frontend/widgets/sched';
 import {TrackEventDetailsPanel} from '../../public/details_panel';
 import {Trace} from '../../public/trace';
+import {formatDuration} from '../../public/lib/time_utils';
 
 interface RelatedThreadStates {
   prev?: ThreadState;
@@ -225,7 +226,7 @@
       });
 
     const nameForNextOrPrev = (threadState: ThreadState) =>
-      `${threadState.state} for ${renderDuration(threadState.dur)}`;
+      `${threadState.state} for ${formatDuration(this.trace, threadState.dur)}`;
 
     const renderWaker = (related: RelatedThreadStates) => {
       // Could be absent if:
@@ -274,7 +275,7 @@
           m(TreeNode, {
             left: m(Timestamp, {
               ts: state.ts,
-              display: `+${renderDuration(state.ts - startTs)}`,
+              display: `+${formatDuration(this.trace, state.ts - startTs)}`,
             }),
             right: renderRef(state, getFullThreadName(state.thread)),
           }),
diff --git a/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/startup_details_panel.ts b/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/startup_details_panel.ts
index 0c26623..813e2e6 100644
--- a/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/startup_details_panel.ts
+++ b/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/startup_details_panel.ts
@@ -14,8 +14,8 @@
 
 import m from 'mithril';
 import {duration, Time, time} from '../../base/time';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {LONG, NUM, STR, STR_NULL} from '../../trace_processor/query_result';
 import {DetailsShell} from '../../widgets/details_shell';
 import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
diff --git a/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/web_content_interaction_details_panel.ts b/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/web_content_interaction_details_panel.ts
index 25e49aa..58896c2 100644
--- a/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/web_content_interaction_details_panel.ts
+++ b/ui/src/plugins/org.chromium.ChromeCriticalUserInteractions/web_content_interaction_details_panel.ts
@@ -29,8 +29,8 @@
 import m from 'mithril';
 import {duration, Time, time} from '../../base/time';
 import {asUpid, Upid} from '../../trace_processor/sql_utils/core_types';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {LONG, NUM, STR} from '../../trace_processor/query_result';
 import {DetailsShell} from '../../widgets/details_shell';
 import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
diff --git a/ui/src/plugins/org.chromium.ChromeScrollJank/event_latency_details_panel.ts b/ui/src/plugins/org.chromium.ChromeScrollJank/event_latency_details_panel.ts
index cfcdf87..8d803e4 100644
--- a/ui/src/plugins/org.chromium.ChromeScrollJank/event_latency_details_panel.ts
+++ b/ui/src/plugins/org.chromium.ChromeScrollJank/event_latency_details_panel.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 import {Duration, duration, Time, time} from '../../base/time';
 import {hasArgs, renderArguments} from '../../frontend/slice_args';
-import {renderDetails} from '../../frontend/slice_details';
+import {renderDetails} from '../../public/lib/details/slice_details';
 import {
   getDescendantSliceTree,
   getSlice,
diff --git a/ui/src/plugins/org.chromium.ChromeScrollJank/index.ts b/ui/src/plugins/org.chromium.ChromeScrollJank/index.ts
index 00415e4..24bd4e2 100644
--- a/ui/src/plugins/org.chromium.ChromeScrollJank/index.ts
+++ b/ui/src/plugins/org.chromium.ChromeScrollJank/index.ts
@@ -48,7 +48,7 @@
       INCLUDE PERFETTO MODULE chrome.event_latency;
     `);
 
-    const uri = 'perfetto.ChromeScrollJank#toplevelScrolls';
+    const uri = 'org.chromium.ChromeScrollJank#toplevelScrolls';
     const title = 'Chrome Scrolls';
 
     ctx.tracks.registerTrack({
@@ -157,7 +157,7 @@
     );
     await ctx.engine.query(tableDefSql);
 
-    const uri = 'perfetto.ChromeScrollJank#eventLatency';
+    const uri = 'org.chromium.ChromeScrollJank#eventLatency';
     const title = 'Chrome Scroll Input Latencies';
 
     ctx.tracks.registerTrack({
@@ -178,7 +178,7 @@
       `INCLUDE PERFETTO MODULE chrome.scroll_jank.scroll_jank_intervals`,
     );
 
-    const uri = 'perfetto.ChromeScrollJank#scrollJankV3';
+    const uri = 'org.chromium.ChromeScrollJank#scrollJankV3';
     const title = 'Chrome Scroll Janks';
 
     ctx.tracks.registerTrack({
diff --git a/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_details_panel.ts b/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_details_panel.ts
index eed111f..e9efe97 100644
--- a/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_details_panel.ts
+++ b/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_details_panel.ts
@@ -21,8 +21,8 @@
   TableData,
   widgetColumn,
 } from '../../widgets/table';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {
   LONG,
   LONG_NULL,
diff --git a/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_jank_v3_details_panel.ts b/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_jank_v3_details_panel.ts
index fc51eb4..3265e4f 100644
--- a/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_jank_v3_details_panel.ts
+++ b/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_jank_v3_details_panel.ts
@@ -17,8 +17,8 @@
 import {exists} from '../../base/utils';
 import {getSlice, SliceDetails} from '../../trace_processor/sql_utils/slice';
 import {asSliceSqlId} from '../../trace_processor/sql_utils/core_types';
-import {DurationWidget} from '../../frontend/widgets/duration';
-import {Timestamp} from '../../frontend/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
 import {Engine} from '../../trace_processor/engine';
 import {LONG, NUM, STR} from '../../trace_processor/query_result';
 import {DetailsShell} from '../../widgets/details_shell';
diff --git a/ui/src/plugins/org.kernel.SuspendResumeLatency/suspend_resume_details.ts b/ui/src/plugins/org.kernel.SuspendResumeLatency/suspend_resume_details.ts
index 74bc497..d43f699 100644
--- a/ui/src/plugins/org.kernel.SuspendResumeLatency/suspend_resume_details.ts
+++ b/ui/src/plugins/org.kernel.SuspendResumeLatency/suspend_resume_details.ts
@@ -19,8 +19,8 @@
 import {GridLayout} from '../../widgets/grid_layout';
 import {Section} from '../../widgets/section';
 import {Tree, TreeNode} from '../../widgets/tree';
-import {Timestamp} from '../../frontend/widgets/timestamp';
-import {DurationWidget} from '../../frontend/widgets/duration';
+import {Timestamp} from '../../public/lib/widgets/timestamp';
+import {DurationWidget} from '../../public/lib/widgets/duration';
 import {Anchor} from '../../widgets/anchor';
 import {Engine} from '../../trace_processor/engine';
 import {TrackEventDetailsPanel} from '../../public/details_panel';
diff --git a/ui/src/frontend/slice_details.ts b/ui/src/public/lib/details/slice_details.ts
similarity index 79%
rename from ui/src/frontend/slice_details.ts
rename to ui/src/public/lib/details/slice_details.ts
index 784e5b1..4468660 100644
--- a/ui/src/frontend/slice_details.ts
+++ b/ui/src/public/lib/details/slice_details.ts
@@ -13,27 +13,27 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {BigintMath} from '../base/bigint_math';
-import {sqliteString} from '../base/string_utils';
-import {exists} from '../base/utils';
-import {SliceDetails} from '../trace_processor/sql_utils/slice';
-import {Anchor} from '../widgets/anchor';
-import {MenuItem, PopupMenu2} from '../widgets/menu';
-import {Section} from '../widgets/section';
-import {SqlRef} from '../widgets/sql_ref';
-import {Tree, TreeNode} from '../widgets/tree';
+import {BigintMath} from '../../../base/bigint_math';
+import {sqliteString} from '../../../base/string_utils';
+import {exists} from '../../../base/utils';
+import {SliceDetails} from '../../../trace_processor/sql_utils/slice';
+import {Anchor} from '../../../widgets/anchor';
+import {MenuItem, PopupMenu2} from '../../../widgets/menu';
+import {Section} from '../../../widgets/section';
+import {SqlRef} from '../../../widgets/sql_ref';
+import {Tree, TreeNode} from '../../../widgets/tree';
 import {
   BreakdownByThreadState,
   BreakdownByThreadStateTreeNode,
-} from './sql/thread_state';
-import {DurationWidget} from './widgets/duration';
-import {renderProcessRef} from './widgets/process';
-import {renderThreadRef} from './widgets/thread';
-import {Timestamp} from './widgets/timestamp';
-import {getSqlTableDescription} from './widgets/sql/table/sql_table_registry';
-import {assertExists} from '../base/logging';
-import {Trace} from '../public/trace';
-import {extensions} from '../public/lib/extensions';
+} from './thread_state';
+import {DurationWidget} from '../widgets/duration';
+import {renderProcessRef} from '../../../frontend/widgets/process';
+import {renderThreadRef} from '../../../frontend/widgets/thread';
+import {Timestamp} from '../widgets/timestamp';
+import {getSqlTableDescription} from '../../../frontend/widgets/sql/table/sql_table_registry';
+import {assertExists} from '../../../base/logging';
+import {Trace} from '../../trace';
+import {extensions} from '../extensions';
 
 // Renders a widget storing all of the generic details for a slice from the
 // slice table.
diff --git a/ui/src/frontend/sql/thread_state.ts b/ui/src/public/lib/details/thread_state.ts
similarity index 93%
rename from ui/src/frontend/sql/thread_state.ts
rename to ui/src/public/lib/details/thread_state.ts
index faa464e..6545db0 100644
--- a/ui/src/frontend/sql/thread_state.ts
+++ b/ui/src/public/lib/details/thread_state.ts
@@ -13,16 +13,16 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {duration, TimeSpan} from '../../base/time';
-import {Engine} from '../../trace_processor/engine';
+import {duration, TimeSpan} from '../../../base/time';
+import {Engine} from '../../../trace_processor/engine';
 import {
   LONG,
   NUM_NULL,
   STR,
   STR_NULL,
-} from '../../trace_processor/query_result';
-import {TreeNode} from '../../widgets/tree';
-import {Utid} from '../../trace_processor/sql_utils/core_types';
+} from '../../../trace_processor/query_result';
+import {TreeNode} from '../../../widgets/tree';
+import {Utid} from '../../../trace_processor/sql_utils/core_types';
 import {DurationWidget} from '../widgets/duration';
 
 // An individual node of the thread state breakdown tree.
diff --git a/ui/src/public/lib/time_utils.ts b/ui/src/public/lib/time_utils.ts
new file mode 100644
index 0000000..30a3c6a
--- /dev/null
+++ b/ui/src/public/lib/time_utils.ts
@@ -0,0 +1,66 @@
+// Copyright (C) 2024 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 {Duration, duration, time, Time} from '../../base/time';
+import {Trace} from '../trace';
+import {DurationPrecision, TimestampFormat} from '../timeline';
+
+export function renderTimecode(time: time) {
+  const {dhhmmss, millis, micros, nanos} = Time.toTimecode(time);
+  return m(
+    'span.pf-timecode',
+    m('span.pf-timecode-hms', dhhmmss),
+    '.',
+    m('span.pf-timecode-millis', millis),
+    m('span.pf-timecode-micros', micros),
+    m('span.pf-timecode-nanos', nanos),
+  );
+}
+
+export function formatDuration(trace: Trace, dur: duration): string {
+  const fmt = trace.timeline.timestampFormat;
+  switch (fmt) {
+    case TimestampFormat.UTC:
+    case TimestampFormat.TraceTz:
+    case TimestampFormat.Timecode:
+      return renderFormattedDuration(trace, dur);
+    case TimestampFormat.TraceNs:
+      return dur.toString();
+    case TimestampFormat.TraceNsLocale:
+      return dur.toLocaleString();
+    case TimestampFormat.Seconds:
+      return Duration.formatSeconds(dur);
+    case TimestampFormat.Milliseconds:
+      return Duration.formatMilliseconds(dur);
+    case TimestampFormat.Microseconds:
+      return Duration.formatMicroseconds(dur);
+    default:
+      const x: never = fmt;
+      throw new Error(`Invalid format ${x}`);
+  }
+}
+
+function renderFormattedDuration(trace: Trace, dur: duration): string {
+  const fmt = trace.timeline.durationPrecision;
+  switch (fmt) {
+    case DurationPrecision.HumanReadable:
+      return Duration.humanise(dur);
+    case DurationPrecision.Full:
+      return Duration.format(dur);
+    default:
+      const x: never = fmt;
+      throw new Error(`Invalid format ${x}`);
+  }
+}
diff --git a/ui/src/public/lib/tracks/sql_table_slice_track_details_tab.ts b/ui/src/public/lib/tracks/sql_table_slice_track_details_tab.ts
index 621d9d3..c5c1935 100644
--- a/ui/src/public/lib/tracks/sql_table_slice_track_details_tab.ts
+++ b/ui/src/public/lib/tracks/sql_table_slice_track_details_tab.ts
@@ -24,8 +24,8 @@
   getThreadState,
   ThreadState,
 } from '../../../trace_processor/sql_utils/thread_state';
-import {DurationWidget} from '../../../frontend/widgets/duration';
-import {Timestamp} from '../../../frontend/widgets/timestamp';
+import {DurationWidget} from '../widgets/duration';
+import {Timestamp} from '../widgets/timestamp';
 import {
   ColumnType,
   durationFromSql,
diff --git a/ui/src/public/lib/widgets/duration.ts b/ui/src/public/lib/widgets/duration.ts
new file mode 100644
index 0000000..409af5d
--- /dev/null
+++ b/ui/src/public/lib/widgets/duration.ts
@@ -0,0 +1,68 @@
+// Copyright (C) 2023 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 {copyToClipboard} from '../../../base/clipboard';
+import {assertExists} from '../../../base/logging';
+import {Icons} from '../../../base/semantic_icons';
+import {duration} from '../../../base/time';
+import {AppImpl} from '../../../core/app_impl';
+import {Anchor} from '../../../widgets/anchor';
+import {MenuDivider, MenuItem, PopupMenu2} from '../../../widgets/menu';
+import {Trace} from '../../trace';
+import {formatDuration} from '../time_utils';
+import {DurationPrecisionMenuItem} from './duration_precision_menu_items';
+import {TimestampFormatMenuItem} from './timestamp_format_menu';
+
+interface DurationWidgetAttrs {
+  dur: duration;
+  extraMenuItems?: m.Child[];
+}
+
+export class DurationWidget implements m.ClassComponent<DurationWidgetAttrs> {
+  private readonly trace: Trace;
+
+  constructor() {
+    // TODO(primiano): the Trace object should be injected into the attrs, but
+    // there are too many users of this class and doing so requires a larger
+    // refactoring CL. Either that or we should find a different way to plumb
+    // the hoverCursorTimestamp.
+    this.trace = assertExists(AppImpl.instance.trace);
+  }
+
+  view({attrs}: m.Vnode<DurationWidgetAttrs>) {
+    const {dur} = attrs;
+
+    if (dur === -1n) {
+      return '(Did not end)';
+    }
+
+    return m(
+      PopupMenu2,
+      {
+        trigger: m(Anchor, formatDuration(this.trace, dur)),
+      },
+      m(MenuItem, {
+        icon: Icons.Copy,
+        label: `Copy raw value`,
+        onclick: () => {
+          copyToClipboard(dur.toString());
+        },
+      }),
+      m(TimestampFormatMenuItem, {trace: this.trace}),
+      m(DurationPrecisionMenuItem, {trace: this.trace}),
+      attrs.extraMenuItems ? [m(MenuDivider), attrs.extraMenuItems] : null,
+    );
+  }
+}
diff --git a/ui/src/public/lib/widgets/duration_precision_menu_items.ts b/ui/src/public/lib/widgets/duration_precision_menu_items.ts
new file mode 100644
index 0000000..666acba
--- /dev/null
+++ b/ui/src/public/lib/widgets/duration_precision_menu_items.ts
@@ -0,0 +1,61 @@
+// Copyright (C) 2024 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 {MenuItem} from '../../../widgets/menu';
+import {Trace} from '../../trace';
+import {DurationPrecision, TimestampFormat} from '../../timeline';
+
+interface DurationPrecisionMenuItemAttrs {
+  trace: Trace;
+}
+
+export class DurationPrecisionMenuItem
+  implements m.ClassComponent<DurationPrecisionMenuItemAttrs>
+{
+  view({attrs}: m.Vnode<DurationPrecisionMenuItemAttrs>) {
+    function renderMenuItem(value: DurationPrecision, label: string) {
+      return m(MenuItem, {
+        label,
+        active: value === attrs.trace.timeline.durationPrecision,
+        onclick: () => {
+          attrs.trace.timeline.durationPrecision = value;
+          attrs.trace.scheduleFullRedraw();
+        },
+      });
+    }
+
+    function durationPrecisionHasEffect() {
+      switch (attrs.trace.timeline.timestampFormat) {
+        case TimestampFormat.Timecode:
+        case TimestampFormat.UTC:
+        case TimestampFormat.TraceTz:
+          return true;
+        default:
+          return false;
+      }
+    }
+
+    return m(
+      MenuItem,
+      {
+        label: 'Duration precision',
+        disabled: !durationPrecisionHasEffect(),
+        title: 'Not configurable with current time format',
+      },
+      renderMenuItem(DurationPrecision.Full, 'Full'),
+      renderMenuItem(DurationPrecision.HumanReadable, 'Human readable'),
+    );
+  }
+}
diff --git a/ui/src/public/lib/widgets/timestamp.ts b/ui/src/public/lib/widgets/timestamp.ts
new file mode 100644
index 0000000..ee416f8
--- /dev/null
+++ b/ui/src/public/lib/widgets/timestamp.ts
@@ -0,0 +1,100 @@
+// Copyright (C) 2023 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 {copyToClipboard} from '../../../base/clipboard';
+import {assertExists} from '../../../base/logging';
+import {Icons} from '../../../base/semantic_icons';
+import {time, Time} from '../../../base/time';
+import {AppImpl} from '../../../core/app_impl';
+import {Anchor} from '../../../widgets/anchor';
+import {MenuDivider, MenuItem, PopupMenu2} from '../../../widgets/menu';
+import {Trace} from '../../trace';
+import {TimestampFormatMenuItem} from './timestamp_format_menu';
+import {renderTimecode} from '../time_utils';
+import {TimestampFormat} from '../../timeline';
+
+// import {MenuItem, PopupMenu2} from './menu';
+
+interface TimestampAttrs {
+  // The timestamp to print, this should be the absolute, raw timestamp as
+  // found in trace processor.
+  ts: time;
+  // Custom text value to show instead of the default HH:MM:SS.mmm uuu nnn
+  // formatting.
+  display?: m.Children;
+  extraMenuItems?: m.Child[];
+}
+
+export class Timestamp implements m.ClassComponent<TimestampAttrs> {
+  private readonly trace: Trace;
+
+  constructor() {
+    // TODO(primiano): the Trace object should be injected into the attrs, but
+    // there are too many users of this class and doing so requires a larger
+    // refactoring CL. Either that or we should find a different way to plumb
+    // the hoverCursorTimestamp.
+    this.trace = assertExists(AppImpl.instance.trace);
+  }
+
+  view({attrs}: m.Vnode<TimestampAttrs>) {
+    const {ts} = attrs;
+    const timeline = this.trace.timeline;
+    return m(
+      PopupMenu2,
+      {
+        trigger: m(
+          Anchor,
+          {
+            onmouseover: () => (timeline.hoverCursorTimestamp = ts),
+            onmouseout: () => (timeline.hoverCursorTimestamp = undefined),
+          },
+          attrs.display ?? this.formatTimestamp(timeline.toDomainTime(ts)),
+        ),
+      },
+      m(MenuItem, {
+        icon: Icons.Copy,
+        label: `Copy raw value`,
+        onclick: () => {
+          copyToClipboard(ts.toString());
+        },
+      }),
+      m(TimestampFormatMenuItem),
+      attrs.extraMenuItems ? [m(MenuDivider), attrs.extraMenuItems] : null,
+    );
+  }
+
+  private formatTimestamp(time: time): m.Children {
+    const fmt = this.trace.timeline.timestampFormat;
+    switch (fmt) {
+      case TimestampFormat.UTC:
+      case TimestampFormat.TraceTz:
+      case TimestampFormat.Timecode:
+        return renderTimecode(time);
+      case TimestampFormat.TraceNs:
+        return time.toString();
+      case TimestampFormat.TraceNsLocale:
+        return time.toLocaleString();
+      case TimestampFormat.Seconds:
+        return Time.formatSeconds(time);
+      case TimestampFormat.Milliseconds:
+        return Time.formatMilliseconds(time);
+      case TimestampFormat.Microseconds:
+        return Time.formatMicroseconds(time);
+      default:
+        const x: never = fmt;
+        throw new Error(`Invalid timestamp ${x}`);
+    }
+  }
+}
diff --git a/ui/src/public/lib/widgets/timestamp_format_menu.ts b/ui/src/public/lib/widgets/timestamp_format_menu.ts
new file mode 100644
index 0000000..2b1a351
--- /dev/null
+++ b/ui/src/public/lib/widgets/timestamp_format_menu.ts
@@ -0,0 +1,57 @@
+// Copyright (C) 2024 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 {MenuItem} from '../../../widgets/menu';
+import {Trace} from '../../trace';
+import {TimestampFormat} from '../../timeline';
+
+interface TimestampFormatMenuItemAttrs {
+  trace: Trace;
+}
+
+export class TimestampFormatMenuItem
+  implements m.ClassComponent<TimestampFormatMenuItemAttrs>
+{
+  view({attrs}: m.Vnode<TimestampFormatMenuItemAttrs>) {
+    function renderMenuItem(value: TimestampFormat, label: string) {
+      return m(MenuItem, {
+        label,
+        active: value === attrs.trace.timeline.timestampFormat,
+        onclick: () => {
+          attrs.trace.timeline.timestampFormat = value;
+          attrs.trace.scheduleFullRedraw();
+        },
+      });
+    }
+
+    return m(
+      MenuItem,
+      {
+        label: 'Time format',
+      },
+      renderMenuItem(TimestampFormat.Timecode, 'Timecode'),
+      renderMenuItem(TimestampFormat.UTC, 'Realtime (UTC)'),
+      renderMenuItem(TimestampFormat.TraceTz, 'Realtime (Trace TZ)'),
+      renderMenuItem(TimestampFormat.Seconds, 'Seconds'),
+      renderMenuItem(TimestampFormat.Milliseconds, 'Milliseconds'),
+      renderMenuItem(TimestampFormat.Microseconds, 'Microseconds'),
+      renderMenuItem(TimestampFormat.TraceNs, 'Raw'),
+      renderMenuItem(
+        TimestampFormat.TraceNsLocale,
+        'Raw (with locale-specific formatting)',
+      ),
+    );
+  }
+}
diff --git a/ui/src/public/timeline.ts b/ui/src/public/timeline.ts
index ca881e5..8ebad1e 100644
--- a/ui/src/public/timeline.ts
+++ b/ui/src/public/timeline.ts
@@ -15,6 +15,22 @@
 import {HighPrecisionTimeSpan} from '../base/high_precision_time_span';
 import {time} from '../base/time';
 
+export enum TimestampFormat {
+  Timecode = 'timecode',
+  TraceNs = 'traceNs',
+  TraceNsLocale = 'traceNsLocale',
+  Seconds = 'seconds',
+  Milliseconds = 'milliseconds',
+  Microseconds = 'microseconds',
+  UTC = 'utc',
+  TraceTz = 'traceTz',
+}
+
+export enum DurationPrecision {
+  Full = 'full',
+  HumanReadable = 'human_readable',
+}
+
 export interface Timeline {
   // Bring a timestamp into view.
   panToTimestamp(ts: time): void;
@@ -39,4 +55,8 @@
 
   // Get a time in the current domain as specified by timestampOffset.
   toDomainTime(ts: time): time;
+
+  // These control how timestamps and durations are formatted throughout the UI
+  timestampFormat: TimestampFormat;
+  durationPrecision: DurationPrecision;
 }