ui: add support for transforming columns in table viewer

Initial version of supporting transformation of the table columns in the table
viewer. For now, transformations of the string columns are supported:
- length
- substr
- extract_regexp
- remove_prefix / remove_suffix
diff --git a/ui/src/base/semantic_icons.ts b/ui/src/base/semantic_icons.ts
index 9133297..26c350a 100644
--- a/ui/src/base/semantic_icons.ts
+++ b/ui/src/base/semantic_icons.ts
@@ -68,6 +68,7 @@
   static readonly Analyze = 'analytics';
   static readonly Chart = 'bar_chart';
   static readonly Pivot = 'pivot_table_chart';
+  static readonly ApplyFunction = 'function';
 
   static readonly Play = 'play_arrow';
   static readonly Edit = 'edit';
diff --git a/ui/src/components/widgets/sql/table/menus/transform_column_menu.ts b/ui/src/components/widgets/sql/table/menus/transform_column_menu.ts
new file mode 100644
index 0000000..9a8a9da
--- /dev/null
+++ b/ui/src/components/widgets/sql/table/menus/transform_column_menu.ts
@@ -0,0 +1,358 @@
+// 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 {MenuItem} from '../../../../../widgets/menu';
+import {Form, FormLabel} from '../../../../../widgets/form';
+import {TextInput} from '../../../../../widgets/text_input';
+import {Icons} from '../../../../../base/semantic_icons';
+import {TableColumn, RenderedCell, TableManager} from '../table_column';
+import {SqlTableState} from '../state';
+import {
+  PerfettoSqlType,
+  PerfettoSqlTypes,
+  typesEqual,
+} from '../../../../../trace_processor/perfetto_sql_type';
+import {createTableColumn} from '../create_column';
+import {SqlColumn, SqlExpression} from '../sql_column';
+import {SqlValue} from '../../../../../trace_processor/query_result';
+import {uuidv4} from '../../../../../base/uuid';
+import {range} from '../../../../../base/array_utils';
+
+type Transform = {
+  // The SQL expresssion to apply.
+  expression: (colExpr: string, ...params: string[]) => string;
+  // Optional parameters for the transform
+  parameters?: TransformParameter[];
+  requiredType?: PerfettoSqlType;
+  resultType: PerfettoSqlType;
+};
+
+type TransformParameter = {
+  name: string;
+  placeholder: string;
+  defaultValue?: string;
+  validate?: (value: string) => boolean;
+};
+
+const TRANSFORMS = {
+  'length': {
+    expression: (col) => `length(${col})`,
+    requiredType: PerfettoSqlTypes.STRING,
+    resultType: {kind: 'int'},
+  },
+  'substring': {
+    expression: (col, start, length) => {
+      if (length) {
+        return `substr(${col}, ${start}, ${length})`;
+      }
+      return `substr(${col}, ${start})`;
+    },
+    parameters: [
+      {
+        name: 'start',
+        placeholder: '1-based, can be negative (optional)',
+        defaultValue: '1',
+        validate: (value) => {
+          if (value === '') {
+            return true;
+          }
+          const num = parseInt(value);
+          return !isNaN(num);
+        },
+      },
+      {
+        name: 'length',
+        placeholder: 'optional',
+        validate: (value) => {
+          if (value === '') {
+            return true;
+          }
+          const num = parseInt(value);
+          return !isNaN(num) && num > 0;
+        },
+      },
+    ],
+    requiredType: PerfettoSqlTypes.STRING,
+    resultType: PerfettoSqlTypes.STRING,
+  },
+  'extract regex': {
+    expression: (col, pattern) => `regexp_extract(${col}, '${pattern}')`,
+    parameters: [
+      {
+        name: 'pattern',
+        placeholder: 'regex pattern (e.g., \\d+)',
+      },
+    ],
+    requiredType: PerfettoSqlTypes.STRING,
+    resultType: PerfettoSqlTypes.STRING,
+  },
+  'strip prefix': {
+    expression: (col, prefix) =>
+      `CASE WHEN ${col} GLOB '${prefix}*' THEN substr(${col}, ${prefix.length + 1}) ELSE ${col} END`,
+    parameters: [
+      {
+        name: 'prefix',
+        placeholder: 'prefix to remove',
+      },
+    ],
+    requiredType: PerfettoSqlTypes.STRING,
+    resultType: PerfettoSqlTypes.STRING,
+  },
+  'strip suffix': {
+    expression: (col, suffix) =>
+      `CASE WHEN ${col} GLOB '*${suffix}' THEN substr(${col}, 1, length(${col}) - ${suffix.length}) ELSE ${col} END`,
+    parameters: [
+      {
+        name: 'suffix',
+        placeholder: 'suffix to remove',
+      },
+    ],
+    requiredType: PerfettoSqlTypes.STRING,
+    resultType: PerfettoSqlTypes.STRING,
+  },
+} satisfies Record<string, Transform>;
+
+type TransformType = keyof typeof TRANSFORMS;
+
+export class TransformColumn implements TableColumn {
+  public readonly column: SqlColumn;
+  public readonly type: PerfettoSqlType | undefined;
+  constructor(
+    public readonly args: {
+      transformed: TableColumn;
+      source: TableColumn;
+      transformType: TransformType;
+      transformParams: string[];
+      state: SqlTableState;
+    },
+  ) {
+    this.column = args.transformed.column;
+    this.type = TRANSFORMS[args.transformType].resultType;
+  }
+
+  getTitle(): string | undefined {
+    return this.args.transformed.getTitle?.();
+  }
+
+  renderCell(
+    value: SqlValue,
+    tableManager?: TableManager,
+    supportingValues?: {} | undefined,
+  ): RenderedCell {
+    return this.args.transformed.renderCell(
+      value,
+      tableManager,
+      supportingValues,
+    );
+  }
+
+  supportingColumns() {
+    return this.args.transformed.supportingColumns?.() || (() => {});
+  }
+
+  listDerivedColumns(manager: TableManager) {
+    return this.args.transformed.listDerivedColumns?.(manager);
+  }
+
+  getColumnSpecificMenuItems(args: {
+    replaceColumn: (column: TableColumn) => void;
+  }): m.Children {
+    return [
+      this.args.transformParams.length !== 0 &&
+        m(
+          MenuItem,
+          {
+            label: 'Edit transform',
+            icon: Icons.Edit,
+          },
+          m(ConfigureTransformMenu, {
+            column: this.args.source,
+            state: this.args.state,
+            transformType: this.args.transformType,
+            initialValues: this.args.transformParams,
+            onApply: (newColumn: TableColumn) => args.replaceColumn(newColumn),
+            formSubmitLabel: 'Edit',
+          }),
+        ),
+      m(MenuItem, {
+        label: 'Undo transform',
+        icon: Icons.Undo,
+        onclick: () => args.replaceColumn(this.args.source),
+      }),
+    ];
+  }
+}
+
+function applyTransform(args: {
+  column: TableColumn;
+  transformType: TransformType;
+  values: string[];
+  state: SqlTableState;
+}): TableColumn {
+  const transform: Transform = TRANSFORMS[args.transformType];
+  const values = args.values;
+  const transformExpression = (cols: string[]) =>
+    transform.expression(cols[0], ...values);
+
+  return new TransformColumn({
+    source: args.column,
+    transformed: createTableColumn({
+      trace: args.state.trace,
+      column: new SqlExpression(transformExpression, [args.column.column]),
+      type: transform.resultType,
+    }),
+    state: args.state,
+    transformType: args.transformType,
+    transformParams: args.values,
+  });
+}
+
+interface TransformMenuItemAttrs {
+  column: TableColumn;
+  state: SqlTableState;
+  transformType: TransformType;
+  initialValues?: string[];
+  onApply: (newColumn: TableColumn) => void;
+  formSubmitLabel: string;
+}
+
+class ConfigureTransformMenu
+  implements m.ClassComponent<TransformMenuItemAttrs>
+{
+  private paramState: {value: string; error: boolean}[] = [];
+  private readonly uuid = uuidv4();
+
+  view({attrs}: m.Vnode<TransformMenuItemAttrs>) {
+    const transform: Transform = TRANSFORMS[attrs.transformType];
+    const params = transform.parameters ?? [];
+    const initialValues = attrs.initialValues ?? [];
+    if (this.paramState.length !== params.length) {
+      this.paramState = range(params.length).map((index) => {
+        if (index < initialValues.length) {
+          return {value: initialValues[index], error: false};
+        }
+        return {value: '', error: false};
+      });
+    }
+
+    return m(
+      Form,
+      {
+        submitLabel: attrs.formSubmitLabel,
+        onSubmit: (e: Event) => {
+          params.forEach((param, index) => {
+            const value = this.paramState[index].value;
+            this.paramState[index].error = !(param.validate?.(value) ?? true);
+          });
+
+          const hasError = this.paramState.some((state) => state.error);
+          if (!hasError) {
+            attrs.onApply(
+              applyTransform({
+                column: attrs.column,
+                state: attrs.state,
+                transformType: attrs.transformType,
+                values: params.map((param, index) => {
+                  const value = this.paramState[index].value;
+                  if (value === '' && param.defaultValue !== undefined) {
+                    return param.defaultValue;
+                  }
+                  return value;
+                }),
+              }),
+            );
+          } else {
+            e.stopPropagation();
+          }
+        },
+      },
+      params.map((param, index) => [
+        params.length > 1 &&
+          m(FormLabel, {for: `${this.uuid}_param_${index}`}, param.name),
+        m(TextInput, {
+          id: `${this.uuid}_param_${index}`,
+          placeholder: param.placeholder,
+          value: this.paramState[index].value,
+          oninput: (e: InputEvent) => {
+            this.paramState[index].value = (e.target as HTMLInputElement).value;
+            this.paramState[index].error = false;
+          },
+          style: this.paramState[index].error
+            ? {
+                border: '1px solid red',
+                outline: 'none',
+              }
+            : {},
+        }),
+      ]),
+    );
+  }
+}
+
+export function renderTransformColumnMenu(
+  column: TableColumn,
+  columnIndex: number,
+  state: SqlTableState,
+): m.Children {
+  const applicableTransforms = (
+    Object.entries(TRANSFORMS) as [TransformType, Transform][]
+  ).filter(
+    ([_, transform]) =>
+      transform.requiredType === undefined ||
+      (column.type !== undefined &&
+        typesEqual(transform.requiredType, column.type)),
+  );
+
+  // Only show the Transform menu if there are applicable transformations
+  if (applicableTransforms.length === 0) {
+    return null;
+  }
+
+  return m(
+    MenuItem,
+    {label: 'Transform', icon: Icons.ApplyFunction},
+    applicableTransforms.map(([name, transform]) => {
+      const paramCount = transform.parameters?.length ?? 0;
+      return m(
+        MenuItem,
+        {
+          label: name,
+          onclick:
+            paramCount === 0
+              ? () =>
+                  state.addColumn(
+                    applyTransform({
+                      column,
+                      state,
+                      transformType: name,
+                      values: [],
+                    }),
+                    columnIndex,
+                  )
+              : undefined,
+        },
+        paramCount !== 0 &&
+          m(ConfigureTransformMenu, {
+            column,
+            state,
+            transformType: name,
+            onApply: (column: TableColumn) =>
+              state.addColumn(column, columnIndex),
+            formSubmitLabel: 'Add',
+          }),
+      );
+    }),
+  );
+}
diff --git a/ui/src/components/widgets/sql/table/table.ts b/ui/src/components/widgets/sql/table/table.ts
index 99aad64..94d1ae5 100644
--- a/ui/src/components/widgets/sql/table/table.ts
+++ b/ui/src/components/widgets/sql/table/table.ts
@@ -39,6 +39,7 @@
 import {SelectColumnMenu} from './menus/select_column_menu';
 import {renderColumnFilterOptions} from './menus/add_column_filter_menu';
 import {renderCastColumnMenu} from './menus/cast_column_menu';
+import {renderTransformColumnMenu} from './menus/transform_column_menu';
 
 export interface SqlTableConfig {
   readonly state: SqlTableState;
@@ -205,6 +206,7 @@
           {label: 'Cast', icon: Icons.Change},
           renderCastColumnMenu(column, i, this.state),
         ),
+        renderTransformColumnMenu(column, i, this.state),
         m(
           MenuItem,
           {label: 'Add filter', icon: Icons.Filter},
diff --git a/ui/src/components/widgets/sql/table/table_column.ts b/ui/src/components/widgets/sql/table/table_column.ts
index db86203..a5d8df4 100644
--- a/ui/src/components/widgets/sql/table/table_column.ts
+++ b/ui/src/components/widgets/sql/table/table_column.ts
@@ -93,7 +93,12 @@
 }
 
 export function tableColumnAlias(column: TableColumn): string {
-  return tableColumnId(column).replace(/[^a-zA-Z0-9_]/g, '__');
+  return tableColumnId(column).replace(/[^a-zA-Z0-9_]/g, (char) => {
+    if (char === '_') {
+      return '__';
+    }
+    return '_' + char.charCodeAt(0);
+  });
 }
 
 export function columnTitle(column: TableColumn): string {