From 19401112d2512638a24c8c975ed473c2e99c0be9 Mon Sep 17 00:00:00 2001 From: Diego Fabricio Date: Fri, 7 Aug 2026 02:51:09 -0500 Subject: [PATCH] feat(grid): add checkbox/dropdown display mode for boolean columns --- apps/desktop/src/components/grid/DataGrid.vue | 93 +++++++++++++++++- .../src/components/layout/ContentArea.vue | 52 +++++++++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../lib/dataGrid/canvasDataGridRenderer.ts | 72 +++++++++++--- .../src/lib/dataGrid/dataGridBooleanColumn.ts | 21 ++++ .../stores/__tests__/settingsStore.spec.ts | 96 +++++++++++++++---- apps/desktop/src/stores/settingsStore.ts | 38 ++++---- .../app-tests/dataGridBooleanColumn.test.ts | 21 +++- 15 files changed, 363 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 4cb4908574..9b556995c4 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -135,7 +135,7 @@ import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, default import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; -import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn"; +import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn"; import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; @@ -273,6 +273,8 @@ const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize) const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH); const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose); const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns); +const booleanCellsUseCheckbox = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode === "checkbox"); +const booleanDisplayMode = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode); const { isDark, themePalette } = useTheme(); const { toast } = useToast(); // 外键单元格跳转复用导航入口;对话框引用传 stub(同 AiAssistant 模式) @@ -3179,6 +3181,7 @@ function showReadonlyCellDetailsOnDblClick(item: RowItem, rowIndex: number, visi } function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) { + if (booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; if (showReadonlyCellDetailsOnDblClick(item, rowIndex, visibleColIdx, actualColIdx)) return; startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event); } @@ -3218,6 +3221,19 @@ function measureCellTextWidth(text: string, font: string): number { return width; } +const cellTextWidthCache = new Map(); + +function measureCellTextWidthCached(text: string, font: string): number { + const key = `${font}|${text}`; + let width = cellTextWidthCache.get(key); + if (width === undefined) { + width = measureCellTextWidth(text, font); + if (cellTextWidthCache.size >= 200) cellTextWidthCache.clear(); + cellTextWidthCache.set(key, width); + } + return width; +} + const tableColumnsByResultIndex = computed(() => resolveDataGridColumnsByResultIndex({ resultColumns: props.result.columns, @@ -3282,6 +3298,17 @@ const booleanEditorModelValue = computed({ }, }); +function booleanCellChecked(value: unknown): boolean { + return normalizeBooleanCellValue(value) === true; +} + +function cycleBooleanGridCell(item: RowItem | undefined, actualColIdx: number, event?: MouseEvent) { + if (!item || !isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return; + event?.stopPropagation(); + const next = nextBooleanCellValue(item.data[actualColIdx], isBooleanGridColumnNullable(actualColIdx)); + applyCellValue(item.id, actualColIdx, next === null ? null : next ? "true" : "false"); +} + function cellEditInputModeForColumn(columnIndex: number): "decimal" | "numeric" | undefined { const dataType = normalizedColumnDataType(tableColumnForGridColumn(columnIndex)); if (isIntegerColumnType(dataType)) return "numeric"; @@ -5133,7 +5160,9 @@ function onCanvasMouseMove(event: MouseEvent) { const next = hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null; const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined; if (canvasRef.value) { - canvasRef.value.style.cursor = hit?.rowNumber ? "default" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; + const overBooleanInteractive = + booleanCellsUseCheckbox.value && hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event); + canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; } if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) { return; @@ -5176,6 +5205,43 @@ function clearCanvasDetailHover(event?: MouseEvent) { onCanvasMouseLeave(); } +function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (item.data[actualColIdx] === null) return false; + const canvas = canvasRef.value; + const canvasRect = canvas?.getBoundingClientRect(); + const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); + if (!canvasRect || !cellRect) return false; + return isPointInBooleanCheckbox({ x: event.clientX - canvasRect.left, y: event.clientY - canvasRect.top }, { left: cellRect.left, top: cellRect.top, width: cellRect.width, height: cellRect.height }); +} + +function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (item.data[actualColIdx] !== null) return false; + const canvas = canvasRef.value; + const canvasRect = canvas?.getBoundingClientRect(); + const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); + if (!canvasRect || !cellRect) return false; + const text = firstLineCellDisplayValue(formatCellCached(item.data[actualColIdx], actualColIdx)); + if (!text) return false; + const textWidth = measureCellTextWidthCached(text, `italic 400 ${tableFontSize.value}px ${tableFontFamily.value}`); + if (textWidth <= 0) return false; + const left = cellRect.left + (cellRect.width - textWidth) / 2 - 2; + const right = left + textWidth + 4; + const x = event.clientX - canvasRect.left; + const y = event.clientY - canvasRect.top; + return x >= left && x <= right && y >= cellRect.top && y <= cellRect.top + cellRect.height; +} + +function booleanInteractiveHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + return item.data[actualColIdx] === null ? booleanNullTextHitFromCanvasEvent(item, hit, actualColIdx, event) : booleanCheckboxHitFromCanvasEvent(item, hit, actualColIdx, event); +} + +function tryCycleBooleanCheckboxOnCanvasMouseDown(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (!booleanCellsUseCheckbox.value || !isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return false; + if (!booleanInteractiveHitFromCanvasEvent(item, hit, actualColIdx, event)) return false; + cycleBooleanGridCell(item, actualColIdx); + return true; +} + function onCanvasMouseDown(event: MouseEvent) { if (event.button !== 0) return; const hit = canvasHitTest(event); @@ -5192,6 +5258,7 @@ function onCanvasMouseDown(event: MouseEvent) { onRowNumberMouseDown(item, event); } else { handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event); + if (actualColIdx !== undefined) tryCycleBooleanCheckboxOnCanvasMouseDown(item, hit, actualColIdx, event); } gridRef.value?.focus({ preventScroll: true }); scheduleCanvasDraw(); @@ -5224,6 +5291,7 @@ function onCanvasDblClick(event: MouseEvent) { const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx]; if (!item || actualColIdx === undefined) return; if (showReadonlyCellDetailsOnDblClick(item, item.displayIndex, hit.visibleColIdx, actualColIdx)) return; + if (booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx)); } @@ -5399,6 +5467,8 @@ function drawCanvasGrid() { frozenColumnCount: frozenColumnCount.value, columnAligns: columnAligns.value, rightAlignedActionCell: canvasRightAlignedActionCell.value, + columnIsBoolean: isBooleanGridColumn, + booleanDisplayMode: booleanDisplayMode.value, }); } @@ -5413,6 +5483,7 @@ watch( ); watch(showDataGridTopbar, () => nextTick(observeDataGridTopbarWidth), { immediate: true }); watch(columnAligns, () => scheduleCanvasDraw()); +watch(booleanDisplayMode, () => scheduleCanvasDraw()); watch( [ displayRowRefs, @@ -9660,7 +9731,8 @@ const gridContextMenuItems = computed(() => { 'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx), 'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(item.displayIndex, col.actualColIdx), 'tabular-nums': typeof item.data[col.actualColIdx] === 'number', - 'cursor-text hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx), + 'cursor-text hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx) && !(booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null), + 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx), 'line-through': item.isDeleted, 'overflow-visible z-20 border-r-transparent': editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx, 'overflow-hidden': !(editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx), @@ -9738,6 +9810,21 @@ const gridContextMenuItems = computed(() => { + +
settingsStore.editorSettings.dataGridBooleanDisplayMode); +const setBooleanDisplayMode = (mode: "checkbox" | "dropdown") => settingsStore.updateEditorSettings({ dataGridBooleanDisplayMode: mode }); const { toast } = useToast(); const DEFAULT_QUERY_RESULTS_PANE_SIZE = 68; @@ -1292,6 +1294,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
+
+
+ + {{ t("grid.booleanDisplayMode") }} +
+
+ + +
+
@@ -1751,6 +1777,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
+
+
+ + {{ t("grid.booleanDisplayMode") }} +
+
+ + +
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 389de23a2b..902f461a6e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1392,6 +1392,9 @@ export default { numericColumnAlign: "Numeric Column Align", numericColumnAlignLeft: "Left", numericColumnAlignRight: "Right", + booleanDisplayMode: "Boolean display", + booleanDisplayCheckbox: "Check", + booleanDisplayDropdown: "Dropdown", moreValues: "{count} more values, keep typing to narrow results", filterByValue: "Filter by This Value", filterExcludeValue: "Exclude This Value", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ac515b9b07..ddad5d63c4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1557,6 +1557,9 @@ export default withEnglishFallback({ numericColumnAlign: "Alineación de columna numérica", numericColumnAlignLeft: "Alineación izquierda", numericColumnAlignRight: "Alineación derecha", + booleanDisplayMode: "Visualización de booleanos", + booleanDisplayCheckbox: "Check", + booleanDisplayDropdown: "Lista", batchAppendPasteNotEditable: "El resultado actual no se puede editar.", batchAppendPasteInvalidTarget: "Primero seleccione una celda de número de fila de una nueva fila vacía y luego pegue.", batchAppendPasteTargetNotEmpty: "La nueva fila seleccionada ya contiene datos.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 85de8f261c..5dc36bcbc1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1555,6 +1555,9 @@ export default withEnglishFallback({ numericColumnAlign: "Allineamento colonna numerica", numericColumnAlignLeft: "Allineamento a sinistra", numericColumnAlignRight: "Allineamento a destra", + booleanDisplayMode: "Visualizzazione booleani", + booleanDisplayCheckbox: "Check", + booleanDisplayDropdown: "Dropdown", batchAppendPasteNotEditable: "Il risultato corrente non è modificabile.", batchAppendPasteInvalidTarget: "Seleziona prima una cella vuota della riga nuova, poi incolla.", batchAppendPasteTargetNotEmpty: "La riga nuova selezionata contiene già dati.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 71fb3ff666..18749dcb2a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1577,6 +1577,9 @@ export default withEnglishFallback({ numericColumnAlign: "数値列の配置", numericColumnAlignLeft: "左揃え", numericColumnAlignRight: "右揃え", + booleanDisplayMode: "ブール値の表示", + booleanDisplayCheckbox: "チェック", + booleanDisplayDropdown: "ドロップダウン", batchAppendPasteNotEditable: "現在の結果は編集できません。", batchAppendPasteInvalidTarget: "空の新規行の行番号セルを選択してから貼り付けてください。", batchAppendPasteTargetNotEmpty: "選択した新規行には既にデータが含まれています。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 06c2a91edf..599a2a533f 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1296,6 +1296,9 @@ export default withEnglishFallback({ numericColumnAlign: "숫자 컬럼 정렬", numericColumnAlignLeft: "왼쪽", numericColumnAlignRight: "오른쪽", + booleanDisplayMode: "불리언 표시", + booleanDisplayCheckbox: "체크", + booleanDisplayDropdown: "드롭다운", moreValues: "{count}개 값 더, 결과를 좁히려면 계속 입력하세요", filterByValue: "이 값으로 필터", filterExcludeValue: "이 값 제외", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index caad6ee168..6d21b5571a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1557,6 +1557,9 @@ export default withEnglishFallback({ numericColumnAlign: "Alinhamento de colunas numéricas", numericColumnAlignLeft: "Alinhamento à esquerda", numericColumnAlignRight: "Alinhamento à direita", + booleanDisplayMode: "Exibição booleana", + booleanDisplayCheckbox: "Check", + booleanDisplayDropdown: "Dropdown", batchAppendPasteNotEditable: "O resultado atual não é editável.", batchAppendPasteInvalidTarget: "Selecione primeiro o número da linha de uma nova linha vazia e depois cole.", batchAppendPasteTargetNotEmpty: "A nova linha selecionada já contém dados.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 77784ceb7b..bae2054ae7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1392,6 +1392,9 @@ export default withEnglishFallback({ numericColumnAlign: "数值列对齐", numericColumnAlignLeft: "左对齐", numericColumnAlignRight: "右对齐", + booleanDisplayMode: "布尔值显示", + booleanDisplayCheckbox: "复选框", + booleanDisplayDropdown: "下拉列表", moreValues: "还有 {count} 个值,输入关键词继续缩小范围", filterByValue: "筛选此值", filterExcludeValue: "排除此值", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a2966405fc..1b60acda1b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1557,6 +1557,9 @@ export default withEnglishFallback({ numericColumnAlign: "數值列對齊", numericColumnAlignLeft: "左對齊", numericColumnAlignRight: "右對齊", + booleanDisplayMode: "布林值顯示", + booleanDisplayCheckbox: "核取方塊", + booleanDisplayDropdown: "下拉清單", batchAppendPasteNotEditable: "目前結果不可編輯。", batchAppendPasteInvalidTarget: "請先選取一個空白新增行的行號儲存格,再進行貼上。", batchAppendPasteTargetNotEmpty: "選取的新增行已包含資料。", diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index 4b9dd77140..5ca7175442 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -1,4 +1,5 @@ import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { BOOLEAN_CHECKBOX_SIZE, isBooleanCellValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn"; import type { RowStatus } from "@/lib/dataGrid/gridRowStatus"; import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme"; @@ -73,6 +74,7 @@ export interface DrawCanvasDataGridOptions { searchMatchKeys: ReadonlySet; currentSearchMatch: CanvasSearchMatch | null; formatCell: (value: CellValue, columnIndex: number) => string; + columnIsBoolean?: (columnIndex: number) => boolean; draftCellPlaceholder?: string; isRowActive: (rowIndex: number) => boolean; rowCellsUseSelectionVisual: (rowId: number) => boolean; @@ -83,6 +85,7 @@ export interface DrawCanvasDataGridOptions { frozenColumnCount?: number; columnAligns?: readonly ("left" | "right")[]; rightAlignedActionCell?: CanvasRightAlignedActionCell | null; + booleanDisplayMode?: "checkbox" | "dropdown"; } type NumericCanvasContext = CanvasRenderingContext2D & { @@ -229,6 +232,29 @@ function alignCanvasPixel(value: number, dpr: number): number { return Math.round(value * dpr) / dpr; } +function drawBooleanCheckbox(ctx: CanvasRenderingContext2D, options: { drawX: number; y: number; colWidth: number; scaleX: number; scaleY: number; theme: DataGridPaintTheme; checked: boolean }): void { + const { drawX, y, colWidth, scaleX, scaleY, theme, checked } = options; + const size = BOOLEAN_CHECKBOX_SIZE; + const boxX = alignCanvasPixel(drawX + (colWidth - size) / 2, scaleX); + const boxY = alignCanvasPixel(y + (CANVAS_DATA_GRID_ROW_HEIGHT - size) / 2, scaleY); + ctx.lineWidth = 1; + if (checked) { + ctx.fillStyle = theme.primary; + ctx.fillRect(boxX, boxY, size, size); + ctx.strokeStyle = theme.background; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(boxX + 3, boxY + size / 2); + ctx.lineTo(boxX + size / 2 - 0.5, boxY + size - 3.5); + ctx.lineTo(boxX + size - 2.5, boxY + 3); + ctx.stroke(); + ctx.lineWidth = 1; + } else { + ctx.strokeStyle = theme.mutedForeground; + ctx.strokeRect(boxX + 0.5, boxY + 0.5, size - 1, size - 1); + } +} + function crispCanvasLine(value: number, dpr: number): number { return alignCanvasPixel(value, dpr) + 0.5 / dpr; } @@ -313,6 +339,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { frozenColumnCount = 0, columnAligns, rightAlignedActionCell, + columnIsBoolean, + booleanDisplayMode = "dropdown", } = options; const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1); const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({ @@ -479,26 +507,44 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT); ctx.clip(); const value = item.data[actualColIdx]; + const isBooleanCell = columnIsBoolean?.(actualColIdx) === true && isBooleanCellValue(value); const isRightAlign = columnAligns?.[visibleColIdx] === "right"; const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx; - ctx.textAlign = isRightAlign ? "right" : "left"; + const isBooleanNullCell = booleanDisplayMode === "checkbox" && isBooleanCell && value === null && !isEditingThisCell; + const shouldRenderBooleanCheckbox = booleanDisplayMode === "checkbox" && isBooleanCell && value !== null && !isEditingThisCell; + ctx.textAlign = isBooleanNullCell ? "center" : isRightAlign ? "right" : "left"; ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground; ctx.font = value === null ? italicFont : tabularFont; setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums"); const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0; const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr: scaleX, isRightAlign, reservedWidth }); - const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx); - const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText); - const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isRightAlign ? "right" : "left"); - ctx.fillText(text, textAnchorX, textY); - if (item.isDeleted && text) { - const textWidth = ctx.measureText(text).width; - const lineStartX = isRightAlign ? textAnchorX - textWidth : textAnchorX; - ctx.strokeStyle = theme.foreground; - ctx.beginPath(); - ctx.moveTo(lineStartX, textY); - ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY); - ctx.stroke(); + if (shouldRenderBooleanCheckbox) { + drawBooleanCheckbox(ctx, { drawX, y, colWidth, scaleX, scaleY, theme, checked: normalizeBooleanCellValue(value) === true }); + if (item.isDeleted) { + const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, scaleX); + const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, scaleY); + ctx.strokeStyle = theme.foreground; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(boxX - 1, strikeY); + ctx.lineTo(alignCanvasPixel(boxX + BOOLEAN_CHECKBOX_SIZE + 1, scaleX), strikeY); + ctx.stroke(); + } + } else { + const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx); + const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText); + const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isBooleanNullCell ? "left" : isRightAlign ? "right" : "left"); + const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, scaleX) : textAnchorX; + ctx.fillText(text, anchorX, textY); + if (item.isDeleted && text) { + const textWidth = ctx.measureText(text).width; + const lineStartX = isBooleanNullCell ? anchorX - textWidth / 2 : isRightAlign ? textAnchorX - textWidth : textAnchorX; + ctx.strokeStyle = theme.foreground; + ctx.beginPath(); + ctx.moveTo(lineStartX, textY); + ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY); + ctx.stroke(); + } } ctx.restore(); setCanvasNumericVariant(ctx, "normal"); diff --git a/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts b/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts index 2d29dcef9e..74dbc0eb33 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts @@ -1,5 +1,6 @@ import type { DatabaseType } from "@/types/database"; +export const BOOLEAN_CHECKBOX_SIZE = 13; export const BOOLEAN_CELL_EDITOR_VALUES = ["true", "false"]; export function isBooleanColumnType(dataType: string | undefined, databaseType?: DatabaseType): boolean { @@ -27,6 +28,13 @@ export function isBooleanCellValue(value: unknown): boolean { return value === null || normalizeBooleanCellValue(value) !== null; } +export function nextBooleanCellValue(current: unknown, nullable: boolean): boolean | null { + const normalized = normalizeBooleanCellValue(current); + if (normalized === true) return false; + if (normalized === false) return nullable ? null : true; + return true; +} + export function booleanCellEditorValue(value: unknown): string { const normalized = normalizeBooleanCellValue(value); if (normalized === true) return "true"; @@ -40,3 +48,16 @@ export function parseBooleanCellEditorValue(value: string | null): boolean | nul if (value === "false") return false; return undefined; } + +export function booleanCheckboxRect(cell: { left: number; top: number; width: number; height: number }): { left: number; top: number; size: number } { + return { + left: cell.left + (cell.width - BOOLEAN_CHECKBOX_SIZE) / 2, + top: cell.top + (cell.height - BOOLEAN_CHECKBOX_SIZE) / 2, + size: BOOLEAN_CHECKBOX_SIZE, + }; +} + +export function isPointInBooleanCheckbox(point: { x: number; y: number }, cell: { left: number; top: number; width: number; height: number }): boolean { + const rect = booleanCheckboxRect(cell); + return point.x >= rect.left && point.x <= rect.left + rect.size && point.y >= rect.top && point.y <= rect.top + rect.size; +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 6cdf936c13..02c4386ecc 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -1,7 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { enforceRightSidebarPanelExclusivity, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeAiConfig, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, transitionRightSidebarPanels, type RightSidebarPanelState } from "@/stores/settingsStore"; import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { isProxy } from "vue"; +import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, enforceRightSidebarPanelExclusivity, normalizeAiConfig, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, type RightSidebarPanelState, transitionRightSidebarPanels } from "@/stores/settingsStore"; import type { AiConfigItem } from "@/types/ai"; describe("normalizeEditorSettings", () => { @@ -31,7 +31,12 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ sidebarTableCommentLayout: "hidden" } as any).sidebarObjectInfoMode).toBe("hidden"); expect(normalizeEditorSettings({ sidebarHideTableComments: false } as any).sidebarObjectInfoMode).toBe("comment-inline"); expect(normalizeEditorSettings({ sidebarHideTableComments: true } as any).sidebarObjectInfoMode).toBe("hidden"); - expect(normalizeEditorSettings({ sidebarHideTableComments: true, sidebarShowDatabaseSizes: true } as any).sidebarObjectInfoMode).toBe("hidden"); + expect( + normalizeEditorSettings({ + sidebarHideTableComments: true, + sidebarShowDatabaseSizes: true, + } as any).sidebarObjectInfoMode, + ).toBe("hidden"); expect(normalizeEditorSettings({ sidebarShowDatabaseSizes: true } as any).sidebarObjectInfoMode).toBe("size"); expect(normalizeEditorSettings({ sidebarObjectInfoMode: "invalid" } as any).sidebarObjectInfoMode).toBe("comment-inline"); }); @@ -39,7 +44,12 @@ describe("normalizeEditorSettings", () => { it("defaults SQL execution to the current statement and migrates legacy execute-all settings", () => { expect(normalizeEditorSettings({}).executeMode).toBe("current"); expect(normalizeEditorSettings({ executeMode: "all" }).executeMode).toBe("current"); - expect(normalizeEditorSettings({ executeMode: "all", executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION }).executeMode).toBe("all"); + expect( + normalizeEditorSettings({ + executeMode: "all", + executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION, + }).executeMode, + ).toBe("all"); }); it("enables automatic table aliases by default", () => { @@ -142,8 +152,17 @@ describe("normalizeEditorSettings", () => { it("normalizes persistent extractor configuration fail-fast defaults", () => { const defaults = normalizeEditorSettings({}).dataGridExtractorOptions; - expect(defaults.dsv).toMatchObject({ columnSeparator: ",", rowSeparator: "\n", quote: '"', quotePolicy: "minimal" }); - expect(defaults.sql).toMatchObject({ skipComputedColumns: true, skipGeneratedColumns: true, insertMode: "merged" }); + expect(defaults.dsv).toMatchObject({ + columnSeparator: ",", + rowSeparator: "\n", + quote: '"', + quotePolicy: "minimal", + }); + expect(defaults.sql).toMatchObject({ + skipComputedColumns: true, + skipGeneratedColumns: true, + insertMode: "merged", + }); const configured = normalizeEditorSettings({ dataGridExtractorOptions: { @@ -168,14 +187,25 @@ describe("normalizeEditorSettings", () => { const defaults = normalizeEditorSettings({}); expect(defaults.dataGridMultiRowTranspose).toBe(false); expect(defaults.dataGridHideNullColumns).toBe(false); + expect(defaults.dataGridBooleanDisplayMode).toBe("dropdown"); - const enabled = normalizeEditorSettings({ dataGridMultiRowTranspose: true, dataGridHideNullColumns: true }); + const enabled = normalizeEditorSettings({ + dataGridMultiRowTranspose: true, + dataGridHideNullColumns: true, + dataGridBooleanDisplayMode: "dropdown", + }); expect(enabled.dataGridMultiRowTranspose).toBe(true); expect(enabled.dataGridHideNullColumns).toBe(true); + expect(enabled.dataGridBooleanDisplayMode).toBe("dropdown"); - const invalid = normalizeEditorSettings({ dataGridMultiRowTranspose: "true" as any, dataGridHideNullColumns: 1 as any }); + const invalid = normalizeEditorSettings({ + dataGridMultiRowTranspose: "true" as any, + dataGridHideNullColumns: 1 as any, + dataGridBooleanDisplayMode: "invalid" as any, + }); expect(invalid.dataGridMultiRowTranspose).toBe(false); expect(invalid.dataGridHideNullColumns).toBe(false); + expect(invalid.dataGridBooleanDisplayMode).toBe("dropdown"); }); it("defaults the data grid font and preserves a custom font family", () => { @@ -372,7 +402,10 @@ describe("settingsStore AI API key normalization", () => { const { useSettingsStore } = await import("@/stores/settingsStore"); const store = useSettingsStore(); - const config = makeTestConfig({ id: "trimmed-key", apiKey: " \tsecret\r\n" }); + const config = makeTestConfig({ + id: "trimmed-key", + apiKey: " \tsecret\r\n", + }); await store.createAiConfig(config); @@ -411,7 +444,10 @@ describe("settingsStore MCP policy persistence", () => { }; store.mcpGlobalPolicy = previous; - const update = store.updateMcpGlobalPolicy({ readOnly: false, allowedConnectionIds: [] }); + const update = store.updateMcpGlobalPolicy({ + readOnly: false, + allowedConnectionIds: [], + }); expect(store.mcpGlobalPolicy).toEqual({ readOnly: false, allowDangerousSql: false, @@ -611,7 +647,13 @@ describe("settingsStore activeModel lifecycle", () => { loadAiChatSelection: vi.fn().mockResolvedValue({ version: 1, active: { configId: "c1", modelId: "runtime-model" }, - effortPreferences: [{ configId: "c1", modelId: "runtime-model", selection: { kind: "enum", value: "high" } }], + effortPreferences: [ + { + configId: "c1", + modelId: "runtime-model", + selection: { kind: "enum", value: "high" }, + }, + ], }), saveAiChatSelection: vi.fn().mockResolvedValue(undefined), })); @@ -620,7 +662,10 @@ describe("settingsStore activeModel lifecycle", () => { const store = useSettingsStore(); await store.initAiConfigs(); - expect(store.activeModel).toEqual({ configId: "c1", modelId: "runtime-model" }); + expect(store.activeModel).toEqual({ + configId: "c1", + modelId: "runtime-model", + }); expect(store.activeEffort).toEqual({ kind: "enum", value: "high" }); }); @@ -654,7 +699,13 @@ describe("settingsStore activeModel lifecycle", () => { store.updateActiveEffort({ kind: "enum", value: "high" }); await store.updateAiConfigItem("c1", { provider: "gemini" }); - await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenLastCalledWith({ version: 1, active: undefined, effortPreferences: [] })); + await vi.waitFor(() => + expect(saveAiChatSelection).toHaveBeenLastCalledWith({ + version: 1, + active: undefined, + effortPreferences: [], + }), + ); expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", provider: "gemini" })); expect(store.activeModel).toBeNull(); @@ -675,9 +726,16 @@ describe("settingsStore activeModel lifecycle", () => { store.updateActiveModel({ configId: "c1", modelId: "gpt-5" }); store.updateActiveEffort({ kind: "enum", value: "high" }); - await store.updateAiConfigItem("c1", { endpoint: "https://gateway.example/v1" }); + await store.updateAiConfigItem("c1", { + endpoint: "https://gateway.example/v1", + }); - expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", endpoint: "https://gateway.example/v1" })); + expect(saveAiConfigItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: "c1", + endpoint: "https://gateway.example/v1", + }), + ); expect(store.activeModel).toEqual({ configId: "c1", modelId: "gpt-5" }); expect(store.activeEffort).toEqual({ kind: "enum", value: "high" }); }); @@ -707,7 +765,13 @@ describe("settingsStore activeModel lifecycle", () => { expect(saveAiChatSelection.mock.calls[1][0]).toEqual({ version: 1, active: { configId: "c1", modelId: "model-a" }, - effortPreferences: [{ configId: "c1", modelId: "model-a", selection: { kind: "enum", value: "high" } }], + effortPreferences: [ + { + configId: "c1", + modelId: "model-a", + selection: { kind: "enum", value: "high" }, + }, + ], }); }); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index b1234b2a1e..42c535a49e 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -1,29 +1,27 @@ import { defineStore } from "pinia"; -import { ref, computed } from "vue"; +import { computed, ref } from "vue"; +import { aiConfigToItem, generateId, getConfigKey } from "@/lib/ai/aiConfigList"; +import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; import * as api from "@/lib/backend/api"; -import { generateId, getConfigKey, aiConfigToItem } from "@/lib/ai/aiConfigList"; -import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter"; -import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; +import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; +import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; +import { type ColumnFormatterConfig, type CustomColumnFormatterConfig, normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern } from "@/lib/dataGrid/columnFormatter"; +import { type DataGridCopyPreference, type DataGridExtractorOptions, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; -import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, type DataGridCopyPreference, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; -import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; +import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; +import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; +import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; +import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick"; +import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion"; import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax"; -import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; -import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick"; -import { DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode, type DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; -import type { SqlSnippet, TableInfoTab } from "@/types/database"; -import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion"; -import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; -import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; -import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; -import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection, AiModelEffortPreference } from "@/types/ai"; +import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; +import type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiModelEffortPreference, AiProvider, AiReasoningLevel, AiTestConnectionResult } from "@/types/ai"; +import type { SqlSnippet, TableInfoTab } from "@/types/database"; -export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection }; -export type { SavedSqlOpenTargetMode }; -export type { DataTabReuseMode }; +export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, DataTabReuseMode, SavedSqlOpenTargetMode }; export interface DesktopSettings { show_tray_icon: boolean; @@ -481,6 +479,7 @@ export interface EditorSettings { dataGridAutoTransposeSingleRow: boolean; dataGridMultiRowTranspose: boolean; dataGridHideNullColumns: boolean; + dataGridBooleanDisplayMode: "dropdown" | "checkbox"; numericColumnRightAlign: boolean; tableFontFamily: string; tableFontSize: number; @@ -660,6 +659,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { dataGridAutoTransposeSingleRow: false, dataGridMultiRowTranspose: false, dataGridHideNullColumns: false, + dataGridBooleanDisplayMode: "dropdown", numericColumnRightAlign: true, tableFontFamily: DEFAULT_DATA_GRID_FONT_FAMILY, tableFontSize: TABLE_FONT_SIZE_DEFAULT, @@ -970,6 +970,7 @@ export function normalizeEditorSettings(settings: Partial, exist dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true, dataGridMultiRowTranspose: settings.dataGridMultiRowTranspose === true, dataGridHideNullColumns: settings.dataGridHideNullColumns === true, + dataGridBooleanDisplayMode: settings.dataGridBooleanDisplayMode === "checkbox" ? "checkbox" : "dropdown", numericColumnRightAlign: typeof settings.numericColumnRightAlign === "boolean" ? settings.numericColumnRightAlign : DEFAULT_EDITOR_SETTINGS.numericColumnRightAlign, tableFontFamily: normalizeFontFamily(settings.tableFontFamily, DEFAULT_EDITOR_SETTINGS.tableFontFamily), tableFontSize: normalizeTableFontSize(settings.tableFontSize), @@ -1464,6 +1465,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true; if (partial.dataGridMultiRowTranspose !== undefined) editorSettings.value.dataGridMultiRowTranspose = partial.dataGridMultiRowTranspose === true; if (partial.dataGridHideNullColumns !== undefined) editorSettings.value.dataGridHideNullColumns = partial.dataGridHideNullColumns === true; + if (partial.dataGridBooleanDisplayMode !== undefined) editorSettings.value.dataGridBooleanDisplayMode = partial.dataGridBooleanDisplayMode === "dropdown" ? "dropdown" : "checkbox"; if (partial.numericColumnRightAlign !== undefined) editorSettings.value.numericColumnRightAlign = partial.numericColumnRightAlign === true; if (partial.tableFontFamily !== undefined) editorSettings.value.tableFontFamily = normalizeFontFamily(partial.tableFontFamily, DEFAULT_EDITOR_SETTINGS.tableFontFamily); if (partial.tableFontSize !== undefined) editorSettings.value.tableFontSize = normalizeTableFontSize(partial.tableFontSize); diff --git a/packages/app-tests/dataGridBooleanColumn.test.ts b/packages/app-tests/dataGridBooleanColumn.test.ts index 59f793ce71..1c1db58293 100644 --- a/packages/app-tests/dataGridBooleanColumn.test.ts +++ b/packages/app-tests/dataGridBooleanColumn.test.ts @@ -93,14 +93,29 @@ test("indexes table metadata once and resolves source-column aliases", () => { assert.equal(resolved[2], undefined); }); -test("uses the enum editor for boolean cells without checkbox rendering or click cycling", () => { +test("keeps the enum editor as the default boolean edit path and gates checkbox interaction behind the checkbox display mode", () => { const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); const rendererSource = readFileSync("apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts", "utf8"); assert.match(gridSource, /v-else-if="isBooleanGridCell\([^\n]+"[\s\S]*?v-model="booleanEditorModelValue"[\s\S]*?:values="BOOLEAN_CELL_EDITOR_VALUES"/); assert.match(gridSource, /@commit="commitBooleanGridEdit"/); - assert.doesNotMatch(gridSource, /cycleBooleanCellValue|tryCycleBooleanCheckboxOnCanvasMouseDown|booleanCellChecked/); - assert.doesNotMatch(rendererSource, /drawBooleanCheckbox|BOOLEAN_CHECKBOX_SIZE|columnIsBoolean/); + // The editor-side cycle helper the old checkbox implementation depended on stays removed. + assert.doesNotMatch(gridSource, /cycleBooleanCellValue/); + // Checkbox rendering and click cycling only exist behind the checkbox display mode. + assert.match(gridSource, /booleanCellsUseCheckbox\.value/); + assert.match(rendererSource, /booleanDisplayMode === "checkbox"/); +}); + +test("DOM checkbox mode renders a clickable placeholder for null boolean cells so they can be cycled like canvas", () => { + const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + // Canvas surfaces null booleans via booleanNullTextHitFromCanvasEvent (click the NULL text to cycle). + // The DOM path must offer the same affordance: a null boolean cell in checkbox mode renders its NULL + // text with a click handler that triggers cycleBooleanGridCell, instead of falling through to the + // static v-else text (which cannot be cycled and is short-circuited by onDomCellDblClick). + const domCellBranch = gridSource.match(/