From d345e2c33b727d80b0263bf523bc625daea036d9 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Mon, 3 Jun 2024 09:51:35 +0200 Subject: [PATCH] Introduce group-level selection and navigation for table - Disable row and cell navigation and selection - Introduce custom selection and navigation handling based on groups - Select a group by hitting 'Enter' - Edit a group by hitting 'Space' (only available for data groups) - Support copying data from the groups using Ctrl+C and Ctrl+X - Only allow single selection for now --- media/memory-table.css | 67 ++++- package.json | 1 + src/webview/columns/address-column.tsx | 21 +- .../{ascii-column.ts => ascii-column.tsx} | 31 +- .../columns/column-contribution-service.ts | 14 +- src/webview/columns/data-column.tsx | 237 +++++++++++---- src/webview/columns/table-group.tsx | 269 ++++++++++++++++++ src/webview/components/memory-table.tsx | 65 +++-- src/webview/variables/variable-decorations.ts | 20 +- yarn.lock | 5 + 10 files changed, 604 insertions(+), 126 deletions(-) rename src/webview/columns/{ascii-column.ts => ascii-column.tsx} (51%) create mode 100644 src/webview/columns/table-group.tsx diff --git a/media/memory-table.css b/media/memory-table.css index 476dfb6..7323f32 100644 --- a/media/memory-table.css +++ b/media/memory-table.css @@ -27,8 +27,9 @@ white-space: nowrap; } -.memory-inspector-table .column-data .byte-group.editable:hover { - border-bottom: 1px dotted var(--vscode-editorHoverWidget-border); +.p-datatable :focus-visible { + outline-style: dotted; + outline-offset: -1px; } /* == MoreMemorySelect == */ @@ -161,22 +162,60 @@ color: var(--vscode-debugTokenExpression-name); } -/* == Data Edit == */ +/* Cell Styles */ -.byte-group { - font-family: var(--vscode-editor-font-family); - margin-right: 2px; - padding: 0 1px; /* we use this padding to balance out the 2px that are needed for the editing */ +.p-datatable .p-datatable-tbody > tr > td[data-column="address"][role="cell"], +.p-datatable .p-datatable-tbody > tr > td[data-column="ascii"][role="cell"] { + padding: 0; +} + +.p-datatable .p-datatable-tbody > tr > td[data-column="data"][role="cell"], +.p-datatable .p-datatable-tbody > tr > td[data-column="variables"][role="cell"] { + padding: 0 12px; + vertical-align: middle; +} + +/* Group Styles */ + +[role='group']:hover { + border-bottom: 0px; + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-list-focusOutline); +} + +[role='group'][data-group-selected='true'] { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-list-focusOutline); } -.byte-group:last-child { +[data-column="address"][role="group"], +[data-column="ascii"][role="group"] { + padding: 4px 12px; + display: flex; + outline-offset: -1px; +} + +[data-column="data"][role="group"], +[data-column="variables"][role="group"] { + padding: 4px 1px; + line-height: 23.5px; + outline-offset: -1px; +} + +/* Data Column */ + + +/* == Data Edit == */ + +[data-column="data"][role="group"]:last-child { margin-right: 0px; } -.byte-group:has(> .data-edit) { +[data-column="data"][role="group"]:has(> .data-edit) { outline: 1px solid var(--vscode-inputOption-activeBorder); - outline-offset: 1px; - padding: 0px; /* editing takes two more pixels cause the input field will cut off the characters otherwise. */ + padding-left: 0px; /* editing takes two more pixels cause the input field will cut off the characters otherwise. */ + padding-right: 0px; /* editing takes two more pixels cause the input field will cut off the characters otherwise. */ } .data-edit { @@ -193,5 +232,11 @@ .data-edit:enabled:focus { outline: none; border: none; + color: var(--vscode-list-activeSelectionForeground); text-indent: 1px; } + +.p-datatable .p-datatable-tbody > tr > td.p-highlight:has(>.selected) { + background: transparent; + outline: none; +} \ No newline at end of file diff --git a/package.json b/package.json index f7e3609..7e237b5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@vscode/codicons": "^0.0.32", + "deepmerge": "^4.3.1", "fast-deep-equal": "^3.1.3", "formik": "^2.4.5", "lodash": "^4.17.21", diff --git a/src/webview/columns/address-column.tsx b/src/webview/columns/address-column.tsx index ad6388a..54d8d34 100644 --- a/src/webview/columns/address-column.tsx +++ b/src/webview/columns/address-column.tsx @@ -15,9 +15,10 @@ ********************************************************************************/ import React, { ReactNode } from 'react'; -import { Memory } from '../../common/memory'; -import { BigIntMemoryRange, getAddressString, getRadixMarker } from '../../common/memory-range'; -import { ColumnContribution, ColumnFittingType, TableRenderOptions } from './column-contribution-service'; +import { getAddressString, getRadixMarker } from '../../common/memory-range'; +import { MemoryRowData } from '../components/memory-table'; +import { ColumnContribution, ColumnFittingType, ColumnRenderProps } from './column-contribution-service'; +import { createDefaultSelection, groupAttributes, SelectionProps } from './table-group'; export class AddressColumn implements ColumnContribution { static ID = 'address'; @@ -28,10 +29,16 @@ export class AddressColumn implements ColumnContribution { fittingType: ColumnFittingType = 'content-width'; - render(range: BigIntMemoryRange, _: Memory, options: TableRenderOptions): ReactNode { - return - {options.showRadixPrefix && {getRadixMarker(options.addressRadix)}} - {getAddressString(range.startAddress, options.addressRadix, options.effectiveAddressLength)} + render(columnIndex: number, row: MemoryRowData, config: ColumnRenderProps): ReactNode { + const selectionProps: SelectionProps = { + createSelection: (event, position) => createDefaultSelection(event, position, AddressColumn.ID, row), + getSelection: () => config.selection, + setSelection: config.setSelection + }; + const groupProps = groupAttributes({ columnIndex, rowIndex: row.rowIndex, groupIndex: 0, maxGroupIndex: 0 }, selectionProps); + return + {config.tableConfig.showRadixPrefix && {getRadixMarker(config.tableConfig.addressRadix)}} + {getAddressString(row.startAddress, config.tableConfig.addressRadix, config.tableConfig.effectiveAddressLength)} ; } } diff --git a/src/webview/columns/ascii-column.ts b/src/webview/columns/ascii-column.tsx similarity index 51% rename from src/webview/columns/ascii-column.ts rename to src/webview/columns/ascii-column.tsx index e350b73..cac72fe 100644 --- a/src/webview/columns/ascii-column.ts +++ b/src/webview/columns/ascii-column.tsx @@ -14,11 +14,12 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import * as manifest from '../../common/manifest'; -import { Memory } from '../../common/memory'; -import { BigIntMemoryRange, toOffset } from '../../common/memory-range'; -import { ColumnContribution, TableRenderOptions } from './column-contribution-service'; +import { toOffset } from '../../common/memory-range'; +import { MemoryRowData } from '../components/memory-table'; +import { ColumnContribution, ColumnRenderProps } from './column-contribution-service'; +import { createDefaultSelection, groupAttributes, SelectionProps } from './table-group'; function isPrintableAsAscii(input: number): boolean { return input >= 32 && input < (128 - 1); @@ -30,17 +31,25 @@ function getASCIIForSingleByte(byte: number | undefined): string { } export class AsciiColumn implements ColumnContribution { - readonly id = manifest.CONFIG_SHOW_ASCII_COLUMN; + static ID = manifest.CONFIG_SHOW_ASCII_COLUMN; + readonly id = AsciiColumn.ID; readonly label = 'ASCII'; readonly priority = 3; - render(range: BigIntMemoryRange, memory: Memory, options: TableRenderOptions): ReactNode { - const mauSize = options.bytesPerMau * 8; - const startOffset = toOffset(memory.address, range.startAddress, mauSize); - const endOffset = toOffset(memory.address, range.endAddress, mauSize); + + render(columnIndex: number, row: MemoryRowData, config: ColumnRenderProps): ReactNode { + const selectionProps: SelectionProps = { + createSelection: (event, position) => createDefaultSelection(event, position, AsciiColumn.ID, row), + getSelection: () => config.selection, + setSelection: config.setSelection + }; + const groupProps = groupAttributes({ columnIndex, rowIndex: row.rowIndex, groupIndex: 0, maxGroupIndex: 0 }, selectionProps); + const mauSize = config.tableConfig.bytesPerMau * 8; + const startOffset = toOffset(config.memory.address, row.startAddress, mauSize); + const endOffset = toOffset(config.memory.address, row.endAddress, mauSize); let result = ''; for (let i = startOffset; i < endOffset; i++) { - result += getASCIIForSingleByte(memory.bytes[i]); + result += getASCIIForSingleByte(config.memory.bytes[i]); } - return result; + return {result}; } } diff --git a/src/webview/columns/column-contribution-service.ts b/src/webview/columns/column-contribution-service.ts index e94579f..19eda5b 100644 --- a/src/webview/columns/column-contribution-service.ts +++ b/src/webview/columns/column-contribution-service.ts @@ -14,14 +14,23 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { ColumnPassThroughOptions } from 'primereact/column'; import type * as React from 'react'; import { Memory } from '../../common/memory'; -import { BigIntMemoryRange } from '../../common/memory-range'; import { ReadMemoryArguments } from '../../common/messaging'; +import { MemoryRowData, MemoryTableSelection, MemoryTableState } from '../components/memory-table'; import type { Disposable, MemoryState, SerializedTableRenderOptions, UpdateExecutor } from '../utils/view-types'; export type ColumnFittingType = 'content-width'; +export interface ColumnRenderProps { + memory: Memory; + tableConfig: TableRenderOptions; + groupsPerRowToRender: number; + selection?: MemoryTableSelection; + setSelection: (selection?: MemoryTableSelection) => void; +} + export interface ColumnContribution { readonly id: string; readonly className?: string; @@ -30,7 +39,8 @@ export interface ColumnContribution { fittingType?: ColumnFittingType; /** Sorted low to high. If omitted, sorted alphabetically by ID after all contributions with numbers. */ priority?: number; - render(range: BigIntMemoryRange, memory: Memory, options: TableRenderOptions): React.ReactNode + pt?(columnIndex: number, state: MemoryTableState): ColumnPassThroughOptions; + render(columnIdx: number, row: MemoryRowData, config: ColumnRenderProps): React.ReactNode /** Called when fetching new memory or when activating the column. */ fetchData?(currentViewParameters: ReadMemoryArguments): Promise; /** Called when the user reveals the column */ diff --git a/src/webview/columns/data-column.tsx b/src/webview/columns/data-column.tsx index 4a929c2..29300f6 100644 --- a/src/webview/columns/data-column.tsx +++ b/src/webview/columns/data-column.tsx @@ -14,99 +14,167 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { ColumnPassThroughOptions } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; import * as React from 'react'; import { HOST_EXTENSION } from 'vscode-messenger-common'; import { Memory } from '../../common/memory'; import { BigIntMemoryRange, isWithin, toHexStringWithRadixMarker, toOffset } from '../../common/memory-range'; import { writeMemoryType } from '../../common/messaging'; -import type { MemorySizeOptions } from '../components/memory-table'; +import type { MemoryRowData, MemorySizeOptions, MemoryTableSelection, MemoryTableState } from '../components/memory-table'; import { decorationService } from '../decorations/decoration-service'; import { Disposable, FullNodeAttributes } from '../utils/view-types'; import { createGroupVscodeContext } from '../utils/vscode-contexts'; import { characterWidthInContainer, elementInnerWidth } from '../utils/window'; import { messenger } from '../view-messenger'; -import { ColumnContribution, TableRenderOptions } from './column-contribution-service'; +import { AddressColumn } from './address-column'; +import { ColumnContribution, ColumnRenderProps } from './column-contribution-service'; +import { + findGroup, + getDefaultSearchContext, + getGroupPosition, + groupAttributes, + GroupPosition, handleGroupNavigation, + handleGroupSelection, + SelectionProps +} from './table-group'; + +export interface DataColumnSelection extends MemoryTableSelection { + selectedRange: BigIntMemoryRange; + editingRange?: BigIntMemoryRange; +} + +export namespace DataColumnSelection { + export function is(selection?: MemoryTableSelection): selection is DataColumnSelection { + return !!selection && 'selectedRange' in selection; + } +} export class DataColumn implements ColumnContribution { + static ID = 'data'; static CLASS_NAME = 'column-data'; - readonly id = 'data'; + readonly id = DataColumn.ID; readonly className = DataColumn.CLASS_NAME; readonly label = 'Data'; readonly priority = 1; - render(range: BigIntMemoryRange, memory: Memory, options: TableRenderOptions): React.ReactNode { - return ; + protected focusGroupInstead(event: React.FocusEvent): void { + const previous = event.relatedTarget as HTMLOrSVGElement | null; + if (previous?.dataset['column'] === AddressColumn.ID) { + (event.target.firstElementChild as unknown as HTMLOrSVGElement)?.focus?.(); + event.stopPropagation(); + } + if (!!previous?.dataset['column']) { + (event.target.lastElementChild as unknown as HTMLOrSVGElement)?.focus?.(); + event.stopPropagation(); + } } -} -export interface EditableDataColumnRowProps { - range: BigIntMemoryRange; - memory: Memory; - options: TableRenderOptions; + pt(_columnIndex: number, _state: MemoryTableState): ColumnPassThroughOptions { + return { + root: { + onFocus: event => this.focusGroupInstead(event) + } + }; + } + + render(columnIndex: number, row: MemoryRowData, config: ColumnRenderProps): React.ReactNode { + return ; + } } -export interface EditableDataColumnRowState { - editedRange?: BigIntMemoryRange; +export function getAddressRange(element: HTMLOrSVGElement): BigIntMemoryRange | undefined { + const start = element.dataset['rangeStart']; + const end = element.dataset['rangeEnd']; + if (!start || !end) { return undefined; } + return { startAddress: BigInt(start), endAddress: BigInt(end) }; +}; + +export interface EditableDataColumnRowProps { + row: MemoryRowData; + columnIndex: number; + config: ColumnRenderProps; } -export class EditableDataColumnRow extends React.Component { - state: EditableDataColumnRowState = {}; +export class EditableDataColumnRow extends React.Component { protected inputText = React.createRef(); protected toDisposeOnUnmount?: Disposable; + protected selectionProps: SelectionProps = + { + createSelection: (event, position) => this.createSelection(event, position), + getSelection: () => this.props.config.selection, + setSelection: this.props.config.setSelection + }; + render(): React.ReactNode { return this.renderGroups(); } protected renderGroups(): React.ReactNode { - const { range, options, memory } = this.props; + const { row, config } = this.props; const groups = []; let maus: React.ReactNode[] = []; - let address = range.startAddress; + let address = row.startAddress; let groupStartAddress = address; - while (address < range.endAddress) { - maus.push(this.renderMau(memory, options, address)); + let groupIdx = 0; + while (address < row.endAddress) { + maus.push(this.renderMau(config, address)); const next = address + 1n; - if (maus.length % options.mausPerGroup === 0) { - this.applyEndianness(maus, options); - groups.push(this.renderGroup(maus, groupStartAddress, next)); + if (maus.length % config.tableConfig.mausPerGroup === 0) { + this.applyEndianness(maus, config); + groups.push(this.renderGroup(maus, groupStartAddress, next, groupIdx++)); groupStartAddress = next; maus = []; } address = next; } - if (maus.length) { groups.push(this.renderGroup(maus, groupStartAddress, range.endAddress)); } + if (maus.length) { groups.push(this.renderGroup(maus, groupStartAddress, row.endAddress, groupIdx)); } return groups; } - protected renderGroup(maus: React.ReactNode, startAddress: bigint, endAddress: bigint): React.ReactNode { + protected renderGroup(maus: React.ReactNode, startAddress: bigint, endAddress: bigint, idx: number): React.ReactNode { + const { config, row, columnIndex } = this.props; + const groupProps = groupAttributes({ + rowIndex: row.rowIndex, + columnIndex: columnIndex, + groupIndex: idx, + maxGroupIndex: this.props.config.groupsPerRowToRender - 1 + }, this.selectionProps); return {maus} ; } - protected renderMau(memory: Memory, options: TableRenderOptions, currentAddress: bigint): React.ReactNode { - if (currentAddress === this.state.editedRange?.startAddress) { - return this.renderEditingGroup(this.state.editedRange); - } else if (this.state.editedRange && isWithin(currentAddress, this.state.editedRange)) { - return; + protected renderMau(props: ColumnRenderProps, currentAddress: bigint): React.ReactNode { + if (DataColumnSelection.is(props.selection)) { + if (currentAddress === props.selection.editingRange?.startAddress) { + // render editable text field + return this.renderEditingGroup(props.selection.editingRange); + } else if (props.selection.editingRange && isWithin(currentAddress, props.selection.editingRange)) { + // covered by the editable text field + return; + } } - const initialOffset = toOffset(memory.address, currentAddress, options.bytesPerMau * 8); - const finalOffset = initialOffset + options.bytesPerMau; + const initialOffset = toOffset(props.memory.address, currentAddress, props.tableConfig.bytesPerMau * 8); + const finalOffset = initialOffset + props.tableConfig.bytesPerMau; const bytes: React.ReactNode[] = []; for (let i = initialOffset; i < finalOffset; i++) { - bytes.push(this.renderEightBits(memory, currentAddress, i)); + bytes.push(this.renderEightBits(props.memory, currentAddress, i)); } - this.applyEndianness(bytes, options); + this.applyEndianness(bytes, props); return {bytes}; } @@ -131,9 +199,9 @@ export class EditableDataColumnRow extends React.Component(group: T[], options: TableRenderOptions): T[] { + protected applyEndianness(group: T[], options: ColumnRenderProps): T[] { // Assume data from the DAP comes in Big Endian so we need to revert the order if we use Little Endian - return options.endianness === 'Big Endian' ? group : group.reverse(); + return options.tableConfig.endianness === 'Big Endian' ? group : group.reverse(); } protected renderEditingGroup(editedRange: BigIntMemoryRange): React.ReactNode { @@ -150,72 +218,119 @@ export class EditableDataColumnRow extends React.Component; } protected createEditingGroupDefaultValue(editedRange: BigIntMemoryRange): string { - const bitsPerMau = this.props.options.bytesPerMau * 8; - const startOffset = toOffset(this.props.memory.address, editedRange.startAddress, bitsPerMau); + const bitsPerMau = this.props.config.tableConfig.bytesPerMau * 8; + + const startOffset = toOffset(this.props.config.memory.address, editedRange.startAddress, bitsPerMau); const numBytes = toOffset(editedRange.startAddress, editedRange.endAddress, bitsPerMau); - const area = Array.from(this.props.memory.bytes.slice(startOffset, startOffset + numBytes)); - this.applyEndianness(area, this.props.options); + const area = Array.from(this.props.config.memory.bytes.slice(startOffset, startOffset + numBytes)); + this.applyEndianness(area, this.props.config); return area.map(byte => byte.toString(16).padStart(2, '0')).join(''); } - protected onBlur: React.FocusEventHandler = () => { - this.submitChanges(); + protected onBlur: React.FocusEventHandler = event => { + this.submitChanges(event); }; protected onKeyDown: React.KeyboardEventHandler = event => { + switch (event.key) { + case ' ': { + this.setGroupEdit(event); + break; + } + } + handleGroupNavigation(event); + handleGroupSelection(event, this.selectionProps); + event.stopPropagation(); + }; + + protected onEditKeyDown: React.KeyboardEventHandler = event => { switch (event.key) { case 'Escape': { - this.disableEdit(); + this.disableEdit(event); break; } case 'Enter': { - this.submitChanges(); + this.submitChanges(event); + break; } } event.stopPropagation(); }; - protected setGroupEdit: React.MouseEventHandler = event => { + protected setGroupEdit = (event: React.MouseEvent | React.KeyboardEvent) => { event.stopPropagation(); - const range = event.currentTarget.dataset.range; - if (!range) { return; } - const [startAddress, endAddress] = range.split('-').map(BigInt); - this.setState({ editedRange: { startAddress, endAddress } }); + const position = getGroupPosition(event.currentTarget); + if (!position) { + return; + } + const selection = this.createSelection(event, position); + if (selection) { + selection.editingRange = selection.selectedRange; + this.props.config.setSelection(selection); + } }; - protected disableEdit(): void { - this.setState({ editedRange: undefined }); + protected createSelection(event: React.BaseSyntheticEvent, position: GroupPosition): DataColumnSelection | undefined { + const range = getAddressRange(event.currentTarget); + if (!position || !range) { + return undefined; + } + return { + row: this.props.row, + column: { columnIndex: position.columnIndex, id: DataColumn.ID }, + group: { groupIndex: position.groupIndex }, + textContent: event.currentTarget.textContent ?? event.currentTarget.innerText, + selectedRange: range, + editingRange: undefined, + }; + } + + protected disableEdit(event: React.BaseSyntheticEvent): void { + const selection = this.props.config.selection; + if (DataColumnSelection.is(selection)) { + selection.editingRange = undefined; + this.props.config.setSelection({ ...selection }); + } + // restore focus + const parent = event.currentTarget.parentElement; + if (parent) { + const position = getGroupPosition(parent); + if (position) { + const context = getDefaultSearchContext(parent); + setTimeout(() => findGroup(position, context)?.focus()); + } + } } - protected async submitChanges(): Promise { - if (!this.inputText.current || !this.state.editedRange) { return; } + protected async submitChanges(event: React.BaseSyntheticEvent): Promise { + if (!this.inputText.current || !DataColumnSelection.is(this.props.config.selection) || !this.props.config.selection.editingRange) { return; } - const originalData = this.createEditingGroupDefaultValue(this.state.editedRange); + const originalData = this.createEditingGroupDefaultValue(this.props.config.selection.editingRange); if (originalData !== this.inputText.current.value) { - const newMemoryValue = this.processData(this.inputText.current.value, this.state.editedRange); + const newMemoryValue = this.processData(this.inputText.current.value, this.props.config.selection.editingRange); const converted = Buffer.from(newMemoryValue, 'hex').toString('base64'); await messenger.sendRequest(writeMemoryType, HOST_EXTENSION, { - memoryReference: toHexStringWithRadixMarker(this.state.editedRange.startAddress), + memoryReference: toHexStringWithRadixMarker(this.props.config.selection.editingRange.startAddress), data: converted }).catch(() => { }); } - this.disableEdit(); + this.disableEdit(event); } protected processData(data: string, editedRange: BigIntMemoryRange): string { - const characters = toOffset(editedRange.startAddress, editedRange.endAddress, this.props.options.bytesPerMau * 8) * 2; + const characters = toOffset(editedRange.startAddress, editedRange.endAddress, this.props.config.tableConfig.bytesPerMau * 8) * 2; // Revert Endianness - if (this.props.options.endianness === 'Little Endian') { + if (this.props.config.tableConfig.endianness === 'Little Endian') { const chunks = data.padStart(characters, '0').match(/.{2}/g) || []; return chunks.reverse().join(''); } diff --git a/src/webview/columns/table-group.tsx b/src/webview/columns/table-group.tsx new file mode 100644 index 0000000..75037ef --- /dev/null +++ b/src/webview/columns/table-group.tsx @@ -0,0 +1,269 @@ +/******************************************************************************** + * Copyright (C) 2024 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { DOMAttributes, HTMLAttributes } from 'react'; +import { MemoryRowData, MemoryTableSelection } from '../components/memory-table'; + +export interface GroupPosition { + columnIndex: number; + rowIndex: number; + groupIndex: number; + maxGroupIndex: number; +} + +export namespace GroupPosition { + export namespace Attributes { + export const ColumnIndex = 'data-column-index'; + export const RowIndex = 'data-row-index'; + export const GroupIndex = 'data-group-index'; + export const MaxGroupIndex = 'data-max-group-index'; + export const GroupSelected = 'data-group-selected'; + } + + export namespace DataSet { + export const ColumnIndex = 'columnIndex'; + export const RowIndex = 'rowIndex'; + export const GroupIndex = 'groupIndex'; + export const MaxGroupIndex = 'maxGroupIndex'; + export const GroupSelected = 'groupSelected'; + } +} + +export interface GroupPositionHTMLAttributes { + [GroupPosition.Attributes.ColumnIndex]?: number | undefined; + [GroupPosition.Attributes.RowIndex]?: number | undefined; + [GroupPosition.Attributes.GroupIndex]?: number | undefined; + [GroupPosition.Attributes.MaxGroupIndex]?: number | undefined; + [GroupPosition.Attributes.GroupSelected]?: boolean | undefined; +} + +export interface GroupDOMStringMap extends DOMStringMap { + [GroupPosition.DataSet.ColumnIndex]: string | undefined; + [GroupPosition.DataSet.RowIndex]: string | undefined; + [GroupPosition.DataSet.GroupIndex]: string | undefined; + [GroupPosition.DataSet.MaxGroupIndex]: string | undefined; + [GroupPosition.DataSet.GroupSelected]: string | undefined; +} + +export function groupAttributes(position: GroupPosition, selection?: SelectionProps): HTMLAttributes & DOMAttributes & Record { + return { + role: 'group', + tabIndex: 0, + onKeyDown: event => handleGroupNavigation(event) || selection && handleGroupSelection(event, selection), + onClick: event => selection && handleGroupSelection(event, selection), + onCopy: handleCopy, + onCut: handleCut, + [GroupPosition.Attributes.ColumnIndex]: position.columnIndex, + [GroupPosition.Attributes.RowIndex]: position.rowIndex, + [GroupPosition.Attributes.GroupIndex]: position.groupIndex, + [GroupPosition.Attributes.MaxGroupIndex]: position.maxGroupIndex, + [GroupPosition.Attributes.GroupSelected]: isGroupSelected(position, selection?.getSelection()) + }; +} + +export function isGroupSelected(position: GroupPosition, selection?: MemoryTableSelection): boolean { + return selection?.column.columnIndex === position.columnIndex + && selection?.row.rowIndex === position.rowIndex + && selection?.group.groupIndex === position.groupIndex; +} + +export function getGroupPosition(element: Element): GroupPosition | undefined { + const data = (element as unknown as HTMLOrSVGElement).dataset as GroupDOMStringMap; + const columnIndex = data.columnIndex; + const rowIndex = data.rowIndex; + const groupIndex = data.groupIndex; + const maxGroupIndex = data.maxGroupIndex; + if (!columnIndex || !rowIndex || !groupIndex) { return undefined; } + return { columnIndex: Number(columnIndex), rowIndex: Number(rowIndex), groupIndex: Number(groupIndex), maxGroupIndex: Number(maxGroupIndex) }; +} + +export function findGroup(position: GroupPosition, element?: Element | null): T | undefined { + const context = element ?? document.documentElement; + // eslint-disable-next-line max-len + const group = context.querySelector(`[${GroupPosition.Attributes.ColumnIndex}="${position.columnIndex}"][${GroupPosition.Attributes.RowIndex}="${position.rowIndex}"][${GroupPosition.Attributes.GroupIndex}="${position.groupIndex}"]`); + return !group ? undefined : group; +} + +export function handleGroupNavigation(event: React.KeyboardEvent): boolean { + switch (event.key) { + case 'ArrowRight': { + const rightGroup = findRightGroup(event.currentTarget); + if (rightGroup) { + rightGroup.focus(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + break; + } + case 'ArrowLeft': { + const leftGroup = findLeftGroup(event.currentTarget); + if (leftGroup) { + leftGroup?.focus?.(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + break; + } + case 'ArrowUp': { + const upGroup = findUpGroup(event.currentTarget); + if (upGroup) { + upGroup?.focus?.(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + break; + } + case 'ArrowDown': { + const downGroup = findDownGroup(event.currentTarget); + if (downGroup) { + downGroup?.focus?.(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + break; + } + case 'c': { + if (event.ctrlKey) { + handleCopy(event); + } + break; + } + case 'x': { + if (event.ctrlKey) { + handleCut(event); + } + break; + } + } + return false; +} + +export function getDefaultSearchContext(element: Element): Element | null { + return element.closest('p-datatable-tbody'); +} + +export function findLeftGroup(element: Element, searchContext = getDefaultSearchContext(element)): T | undefined { + const position = getGroupPosition(element); + if (!position) { + return undefined; + } + if (position.columnIndex === 0 && position.groupIndex === 0) { + // we are already most left + return undefined; + } + if (position.groupIndex === 0) { + // we need to jump to the end of the previous column + // so we search for the first group which has the necessary information + const firstGroup = findGroup({ ...position, columnIndex: position.columnIndex - 1, groupIndex: 0 }, searchContext); + if (firstGroup) { + const firstGroupPosition = getGroupPosition(firstGroup); + if (firstGroupPosition?.maxGroupIndex !== undefined && firstGroupPosition.maxGroupIndex !== position?.groupIndex) { + return findGroup({ ...position, columnIndex: position.columnIndex - 1, groupIndex: firstGroupPosition.maxGroupIndex }, searchContext); + } + } + return firstGroup; + } else { + return findGroup({ ...position, groupIndex: position.groupIndex - 1 }, searchContext); + } +} + +export function findRightGroup(element: Element, searchContext = getDefaultSearchContext(element)): T | undefined { + const position = getGroupPosition(element); + if (!position) { + return undefined; + } + const nextGroup = findGroup({ ...position, groupIndex: position.groupIndex + 1 }, searchContext); + if (nextGroup) { + return nextGroup; + } + // we try to jump to the start of the next column + return findGroup({ ...position, columnIndex: position.columnIndex + 1, groupIndex: 0 }, searchContext); +} + +export function findUpGroup(element: Element, searchContext = getDefaultSearchContext(element)): T | undefined { + const position = getGroupPosition(element); + return position ? findGroup({ ...position, rowIndex: position.rowIndex - 1 }, searchContext) : undefined; +} + +export function findDownGroup(element: Element, searchContext = getDefaultSearchContext(element)): T | undefined { + const position = getGroupPosition(element); + return position ? findGroup({ ...position, rowIndex: position.rowIndex + 1 }, searchContext) : undefined; +} + +export interface SelectionProps { + createSelection(event: React.MouseEvent | React.KeyboardEvent, position: GroupPosition): MemoryTableSelection | undefined; + getSelection(): MemoryTableSelection | undefined; + setSelection(selection?: MemoryTableSelection): void; +} + +export function createDefaultSelection(event: React.MouseEvent | React.KeyboardEvent, position: GroupPosition, + columnId: string, row: MemoryRowData): MemoryTableSelection { + return { + row, + column: { columnIndex: position.columnIndex, id: columnId }, + group: { groupIndex: position.groupIndex }, + textContent: event.currentTarget.textContent ?? event.currentTarget.innerText + }; +} + +function handleCopy(event: React.ClipboardEvent | React.KeyboardEvent): void { + event.preventDefault(); + const textSelection = window.getSelection()?.toString(); + if (textSelection) { + navigator.clipboard.writeText(textSelection); + } else if (event.currentTarget.textContent) { + navigator.clipboard.writeText(event.currentTarget.textContent); + } else { + navigator.clipboard.writeText(event.currentTarget.innerText); + } +}; + +function handleCut(event: React.ClipboardEvent | React.KeyboardEvent): void { + handleCopy(event); +} + +export function handleGroupSelection(event: React.KeyboardEvent | React.MouseEvent, props: SelectionProps): boolean { + if (!('key' in event) || event.key === 'Enter') { + // mouse event or ENTER key event + return toggleSelection(event, props); + } + return false; +} + +export function toggleSelection(event: React.MouseEvent | React.KeyboardEvent, props: SelectionProps): boolean { + const position = getGroupPosition(event.currentTarget); + if (!position) { + return false; + } + const currentSelection = props.getSelection(); + if (isGroupSelected(position, currentSelection)) { + // group is already selected + if (event.ctrlKey) { + // deselect + props.setSelection(); + } + } else { + props.setSelection(props.createSelection(event, position)); + } + event.stopPropagation(); + event.preventDefault(); + return true; +}; + diff --git a/src/webview/components/memory-table.tsx b/src/webview/components/memory-table.tsx index d3a0493..d3aef9e 100644 --- a/src/webview/components/memory-table.tsx +++ b/src/webview/components/memory-table.tsx @@ -14,11 +14,12 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import deepmerge from 'deepmerge'; import isDeepEqual from 'fast-deep-equal'; import { debounce } from 'lodash'; import memoize from 'memoize-one'; -import { Column } from 'primereact/column'; -import { DataTable, DataTableCellSelection, DataTableProps, DataTableSelectionCellChangeEvent } from 'primereact/datatable'; +import { Column, ColumnPassThroughOptions } from 'primereact/column'; +import { DataTable, DataTableProps } from 'primereact/datatable'; import { ProgressSpinner } from 'primereact/progressspinner'; import { Tooltip } from 'primereact/tooltip'; import { TooltipEvent } from 'primereact/tooltip/tooltipoptions'; @@ -28,7 +29,7 @@ import { Memory } from '../../common/memory'; import { MemoryOptions, ReadMemoryArguments, WebviewSelection } from '../../common/messaging'; import { tryToNumber } from '../../common/typescript'; import { MemoryDataDisplaySettings, ScrollingBehavior } from '../../common/webview-configuration'; -import { TableRenderOptions } from '../columns/column-contribution-service'; +import { ColumnRenderProps, TableRenderOptions } from '../columns/column-contribution-service'; import { DataColumn } from '../columns/data-column'; import type { HoverService } from '../hovers/hover-service'; import { Decoration, isTrigger } from '../utils/view-types'; @@ -146,22 +147,31 @@ interface MemoryRowListOptions { bigMausPerRow: bigint; } -interface MemoryRowData { +export interface MemoryRowData { rowIndex: number; startAddress: bigint; endAddress: bigint; } -export interface MemoryTableCellSelection extends DataTableCellSelection { +export interface MemoryTableSelection { + column: { + columnIndex: number; + id: string; + }, + row: MemoryRowData; + group: { + groupIndex: number; + } textContent: string; } -interface MemoryTableState { + +export interface MemoryTableState { /** * The value coming from {@link MemoryTableProps.groupsPerRow} can have non-numeric values such as `Autofit`. * For this reason, we need to transform the provided value to a numeric one to render correctly. */ groupsPerRowToRender: number; - selection: MemoryTableCellSelection | null; + selection?: MemoryTableSelection; hoverContent: React.ReactNode; } @@ -200,8 +210,6 @@ export class MemoryTable extends React.PureComponent, }; } @@ -239,8 +247,7 @@ export class MemoryTable extends React.PureComponent ({ ...prev, selection: null })); + this.setState(prev => ({ ...prev, selection: undefined })); } // update the groups per row to render if the display options that impact the available width may have changed or we didn't have a memory before @@ -293,6 +300,13 @@ export class MemoryTable extends React.PureComponent c.contribution.fittingType === 'content-width').length; const columnWidth = remainingWidth / (this.props.columnOptions.length); + const columnRenderProps: ColumnRenderProps = { + memory: this.props.memory!, + tableConfig: this.props, + groupsPerRowToRender: this.state.groupsPerRowToRender, + selection: this.state.selection, + setSelection: selection => this.setSelection(selection) + }; return (
- {this.props.columnOptions.map(({ contribution }) => { + {this.props.columnOptions.map(({ contribution }, idx) => { const isContentWidthFit = contribution.fittingType === 'content-width'; const className = classNames(contribution.className, { 'content-width-fit': isContentWidthFit }); - const pt = { root: createColumnVscodeContext(contribution.id) }; - + const pt: ColumnPassThroughOptions = { root: { ...createColumnVscodeContext(contribution.id), ['data-column']: contribution.id } }; return row && contribution.render(row, this.props.memory!, this.props)}> + pt={deepmerge(pt, contribution.pt?.(idx, this.state) ?? {})} + body={(row?: MemoryRowData) => row && contribution.render(idx, row, columnRenderProps)}> {contribution.label} ; })} @@ -336,11 +349,11 @@ export class MemoryTable extends React.PureComponent { return { cellSelection: true, + isDataSelectable: () => false, className: classNames(MemoryTable.TABLE_CLASS, 'overflow-hidden'), header: this.renderHeader(), lazy: true, metaKeySelection: false, - onSelectionChange: this.onSelectionChanged, onColumnResizeEnd: this.onColumnResizeEnd, onContextMenuCapture: this.onContextMenu, onCopy: this.onCopy, @@ -349,7 +362,8 @@ export class MemoryTable extends React.PureComponent) => { - // eslint-disable-next-line no-null/no-null - const value = event.value ? event.value as MemoryTableCellSelection : null; - if (value) { - value.textContent = event.originalEvent.currentTarget?.textContent ?? ''; - } - - this.setState(prev => ({ ...prev, selection: value })); + protected setSelection = (selection?: MemoryTableSelection) => { + this.setState(prev => ({ ...prev, selection: selection })); }; protected onColumnResizeEnd = () => { @@ -436,7 +444,6 @@ export class MemoryTable extends React.PureComponent => { diff --git a/src/webview/variables/variable-decorations.ts b/src/webview/variables/variable-decorations.ts index a547d61..20304a4 100644 --- a/src/webview/variables/variable-decorations.ts +++ b/src/webview/variables/variable-decorations.ts @@ -21,7 +21,9 @@ import * as manifest from '../../common/manifest'; import { areVariablesEqual, BigIntMemoryRange, BigIntVariableRange, compareBigInt, doOverlap } from '../../common/memory-range'; import { getVariablesType, ReadMemoryArguments } from '../../common/messaging'; import { stringifyWithBigInts } from '../../common/typescript'; -import { ColumnContribution } from '../columns/column-contribution-service'; +import { ColumnContribution, ColumnRenderProps } from '../columns/column-contribution-service'; +import { createDefaultSelection, groupAttributes, SelectionProps } from '../columns/table-group'; +import { MemoryRowData } from '../components/memory-table'; import { Decorator } from '../decorations/decoration-service'; import { EventEmitter, IEvent } from '../utils/events'; import { Decoration, MemoryState } from '../utils/view-types'; @@ -37,7 +39,8 @@ const NON_HC_COLORS = [ ] as const; export class VariableDecorator implements ColumnContribution, Decorator { - readonly id = manifest.CONFIG_SHOW_VARIABLES_COLUMN; + static ID = manifest.CONFIG_SHOW_VARIABLES_COLUMN; + readonly id = VariableDecorator.ID; readonly label = 'Variables'; readonly priority = 2; protected active = false; @@ -78,8 +81,14 @@ export class VariableDecorator implements ColumnContribution, Decorator { if (currentVariablesPopulated) { this.onDidChangeEmitter.fire(this.currentVariables = []); } } - render(range: BigIntMemoryRange): ReactNode { - return this.getVariablesInRange(range)?.reduce((result, current, index) => { + render(columnIndex: number, row: MemoryRowData, config: ColumnRenderProps): ReactNode { + const selectionProps: SelectionProps = { + createSelection: (event, position) => createDefaultSelection(event, position, VariableDecorator.ID, row), + getSelection: () => config.selection, + setSelection: config.setSelection + }; + const variables = this.getVariablesInRange(row); + return variables?.reduce((result, current, index) => { if (index > 0) { result.push(', '); } result.push(React.createElement('span', { style: { color: current.color }, @@ -87,7 +96,8 @@ export class VariableDecorator implements ColumnContribution, Decorator { className: 'hoverable', 'data-column': 'variables', 'data-variables': stringifyWithBigInts(current.variable), - ...createVariableVscodeContext(current.variable) + ...createVariableVscodeContext(current.variable), + ...groupAttributes({ columnIndex, rowIndex: row.rowIndex, groupIndex: index, maxGroupIndex: variables.length - 1 }, selectionProps) }, current.variable.name)); return result; }, []); diff --git a/yarn.lock b/yarn.lock index d4ce747..74f9e57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1055,6 +1055,11 @@ deepmerge@^2.1.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + define-properties@^1.1.3, define-properties@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"