diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..e48049aa --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run build:*)", + "Bash(npm test:*)" + ] + } +} diff --git a/src/command/describe.ts b/src/command/describe.ts index 169d9ca4..e1820e7c 100644 --- a/src/command/describe.ts +++ b/src/command/describe.ts @@ -12,14 +12,14 @@ import { TraceType } from '@type/grammar'; /** * Abstract base class for describe commands. */ -abstract class DescribeCommand implements Command { +abstract class AnnounceCommand implements Command { protected readonly context: Context; protected readonly textViewModel: TextViewModel; protected readonly audioService: AudioService; protected readonly textService: TextService; /** - * Creates an instance of DescribeCommand. + * Creates an instance of AnnounceCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -54,9 +54,9 @@ abstract class DescribeCommand implements Command { /** * Command to describe the X-axis label. */ -export class DescribeXCommand extends DescribeCommand { +export class AnnounceXCommand extends AnnounceCommand { /** - * Creates an instance of DescribeXCommand. + * Creates an instance of AnnounceXCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -90,9 +90,9 @@ export class DescribeXCommand extends DescribeCommand { /** * Command to describe the Y-axis label. */ -export class DescribeYCommand extends DescribeCommand { +export class AnnounceYCommand extends AnnounceCommand { /** - * Creates an instance of DescribeYCommand. + * Creates an instance of AnnounceYCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -126,9 +126,9 @@ export class DescribeYCommand extends DescribeCommand { /** * Command to describe the fill property. */ -export class DescribeFillCommand extends DescribeCommand { +export class AnnounceFillCommand extends AnnounceCommand { /** - * Creates an instance of DescribeFillCommand. + * Creates an instance of AnnounceFillCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -162,9 +162,9 @@ export class DescribeFillCommand extends DescribeCommand { /** * Command to describe the title of the figure or subplot. */ -export class DescribeTitleCommand extends DescribeCommand { +export class AnnounceTitleCommand extends AnnounceCommand { /** - * Creates an instance of DescribeTitleCommand. + * Creates an instance of AnnounceTitleCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -253,9 +253,9 @@ export class DescribeTitleCommand extends DescribeCommand { /** * Command to describe the subtitle of the figure. */ -export class DescribeSubtitleCommand extends DescribeCommand { +export class AnnounceSubtitleCommand extends AnnounceCommand { /** - * Creates an instance of DescribeSubtitleCommand. + * Creates an instance of AnnounceSubtitleCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -291,9 +291,9 @@ export class DescribeSubtitleCommand extends DescribeCommand { /** * Command to describe the caption of the figure. */ -export class DescribeCaptionCommand extends DescribeCommand { +export class AnnounceCaptionCommand extends AnnounceCommand { /** - * Creates an instance of DescribeCaptionCommand. + * Creates an instance of AnnounceCaptionCommand. * @param {Context} context - The application context. * @param {TextViewModel} textViewModel - The text view model. * @param {AudioService} audioService - The audio service. @@ -329,13 +329,13 @@ export class DescribeCaptionCommand extends DescribeCommand { /** * Command to describe the current point with audio, braille, and highlight. */ -export class DescribePointCommand extends DescribeCommand { +export class AnnouncePointCommand extends AnnounceCommand { private readonly audio: AudioService; private readonly brailleViewModel: BrailleViewModel; private readonly highlight: HighlightService; /** - * Creates an instance of DescribePointCommand. + * Creates an instance of AnnouncePointCommand. * @param {Context} context - The application context. * @param {AudioService} audioService - The audio service. * @param {HighlightService} highlightService - The highlight service. @@ -382,7 +382,7 @@ export class DescribePointCommand extends DescribeCommand { * Command to announce the current position in the chart. * Formats output based on text mode (terse/verbose) and chart type. */ -export class AnnouncePositionCommand extends DescribeCommand { +export class AnnouncePositionCommand extends AnnounceCommand { /** * Creates an instance of AnnouncePositionCommand. * @param {Context} context - The application context. @@ -403,11 +403,6 @@ export class AnnouncePositionCommand extends DescribeCommand { * Executes the command to announce the current position. */ public execute(): void { - // Check if speech is off - if (this.textService.isOff()) { - return; - } - // Get current state const state = this.context.state; @@ -417,6 +412,11 @@ export class AnnouncePositionCommand extends DescribeCommand { return; } + // Warn if text mode is off instead of announcing position + if (this.textViewModel.warnIfTextOff()) { + return; + } + // Get position from audio.panning (contains x, y, rows, cols) const { panning } = state.audio; const { x, y, rows, cols } = panning; @@ -435,12 +435,25 @@ export class AnnouncePositionCommand extends DescribeCommand { ) { this.announceSegmentedBarPosition(state, x, cols); } else if (traceType === TraceType.SMOOTH) { - // Violin KDE plots: y=violin index, x=position within violin - this.announceViolinPosition(y, rows, x, cols); - } else if (traceType === TraceType.LINE && state.groupCount && state.groupCount > 1) { - // Multi-line plots: x=line index, y=position within line - this.announceMultiLinePosition(x, rows, y, cols); - } else if (this.is2DPlot(rows, cols)) { + if (rows > 1) { + // Multi-violin plots: y=violin index, x=position within violin + this.announceMultiViolinPosition(y, rows, x, cols); + } else { + // Single smooth/violin plot: 1D position within the curve + this.announceSmoothPosition(x, cols); + } + } + // Check for multi plots (multiline, panel, layer, facet) + else if (traceType === TraceType.LINE && state.groupCount && state.groupCount > 1) { + // Multi-line plots: x=position in the line, y=line index + this.announceMultiLinePosition(x, cols, y, rows); + } + else if (traceType === TraceType.SCATTER) { + // Scatter plot: use x/y for column/row position, but don't include 'Position' as it sounds weird + this.announceScatter(x, y, rows, cols); + } + // Default position announcement + else if (this.is2DPlot(rows, cols)) { this.announce2DPosition(x, y, rows, cols); } else { this.announce1DPosition(x, cols); @@ -461,7 +474,7 @@ export class AnnouncePositionCommand extends DescribeCommand { const position = x + 1; const total = cols; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const percent = cols > 1 ? Math.round((x / (cols - 1)) * 100) : 0; this.textViewModel.update(`${percent}%`); } else { @@ -476,7 +489,7 @@ export class AnnouncePositionCommand extends DescribeCommand { const colPos = x + 1; const rowPos = y + 1; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const colPercent = cols > 1 ? Math.round((x / (cols - 1)) * 100) : 0; const rowPercent = rows > 1 ? Math.round((y / (rows - 1)) * 100) : 0; this.textViewModel.update(`${colPercent}%, ${rowPercent}%`); @@ -501,7 +514,7 @@ export class AnnouncePositionCommand extends DescribeCommand { const totalBoxes = braille.values.length; const position = boxIndex + 1; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const percent = totalBoxes > 1 ? Math.round((boxIndex / (totalBoxes - 1)) * 100) : 0; this.textViewModel.update(`${percent}%, ${section.toLowerCase()}`); } else { @@ -523,7 +536,7 @@ export class AnnouncePositionCommand extends DescribeCommand { const totalCandles = braille.values[0].length; const position = candleIndex + 1; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const percent = totalCandles > 1 ? Math.round((candleIndex / (totalCandles - 1)) * 100) : 0; this.textViewModel.update(`${percent}%, ${section.toLowerCase()}`); } else { @@ -540,7 +553,7 @@ export class AnnouncePositionCommand extends DescribeCommand { const position = x + 1; const total = cols; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const percent = cols > 1 ? Math.round((x / (cols - 1)) * 100) : 0; this.textViewModel.update(`${percent}%, ${level}`); } else { @@ -549,10 +562,22 @@ export class AnnouncePositionCommand extends DescribeCommand { } /** - * Announces position for violin plots. - * Shows which violin (row) and position within that violin (col). + * Announces position for smooth/violin plots. + * Treats as 1D plot - only announces position within the curve. */ - private announceViolinPosition( + private announceSmoothPosition( + posIndex: number, + totalPos: number, + ): void { + // Smooth plots are 1D - just use position within the curve + this.announce1DPosition(posIndex, totalPos); + } + + /** + * Announces position for multi-violin plots. + * Shows which violin and position within that violin. + */ + private announceMultiViolinPosition( violinIndex: number, totalViolins: number, posIndex: number, @@ -560,13 +585,13 @@ export class AnnouncePositionCommand extends DescribeCommand { ): void { const violinPos = violinIndex + 1; const pos = posIndex + 1; + const violinPrefix = `Violin ${violinPos} of ${totalViolins}`; - if (this.textService.isTerse()) { - const violinPercent = totalViolins > 1 ? Math.round((violinIndex / (totalViolins - 1)) * 100) : 0; + if (this.textService.isTerse() || this.textService.isOff()) { const posPercent = totalPos > 1 ? Math.round((posIndex / (totalPos - 1)) * 100) : 0; - this.textViewModel.update(`${violinPercent}%, ${posPercent}%`); + this.textViewModel.update(`${violinPrefix}, ${posPercent}%`); } else { - this.textViewModel.update(`Position is ${violinPos} of ${totalViolins}, ${pos} of ${totalPos}`); + this.textViewModel.update(`${violinPrefix}, Position is ${pos} of ${totalPos}`); } } @@ -575,20 +600,38 @@ export class AnnouncePositionCommand extends DescribeCommand { * Always shows "Plot X of Y" prefix, followed by position within the line. */ private announceMultiLinePosition( - lineIndex: number, - totalLines: number, posIndex: number, totalPos: number, + lineIndex: number, + totalLines: number, ): void { const linePos = lineIndex + 1; const pos = posIndex + 1; const plotPrefix = `Plot ${linePos} of ${totalLines}`; - if (this.textService.isTerse()) { + if (this.textService.isTerse() || this.textService.isOff()) { const posPercent = totalPos > 1 ? Math.round((posIndex / (totalPos - 1)) * 100) : 0; this.textViewModel.update(`${plotPrefix}, ${posPercent}%`); } else { this.textViewModel.update(`${plotPrefix}, Position is ${pos} of ${totalPos}`); } } + /** + * Announces position for 2D plots (e.g., heatmaps). + */ + private announceScatter(x: number, y: number, rows: number, cols: number): void { + const colPos = x + 1; + const rowPos = y + 1; + + if (this.textService.isTerse() || this.textService.isOff()) { + const colPercent = cols > 1 ? Math.round((x / (cols - 1)) * 100) : 0; + const rowPercent = rows > 1 ? Math.round((y / (rows - 1)) * 100) : 0; + this.textViewModel.update(`${colPercent}%, ${rowPercent}%`); + } else { + this.textViewModel.update( + `Column ${colPos} of ${cols}, row ${rowPos} of ${rows}`, + ); + } + } + } diff --git a/src/command/factory.ts b/src/command/factory.ts index 29a0fa11..761a464e 100644 --- a/src/command/factory.ts +++ b/src/command/factory.ts @@ -29,13 +29,13 @@ import { } from './autoplay'; import { AnnouncePositionCommand, - DescribeCaptionCommand, - DescribeFillCommand, - DescribePointCommand, - DescribeSubtitleCommand, - DescribeTitleCommand, - DescribeXCommand, - DescribeYCommand, + AnnounceCaptionCommand, + AnnounceFillCommand, + AnnouncePointCommand, + AnnounceSubtitleCommand, + AnnounceTitleCommand, + AnnounceXCommand as AnnounceXCommand, + AnnounceYCommand, } from './describe'; import { GoToExtremaToggleCommand } from './goTo'; import { @@ -225,14 +225,14 @@ export class CommandFactory { return new CommandPaletteSelectCommand(this.commandPaletteViewModel); case 'COMMAND_PALETTE_CLOSE': return new CommandPaletteCloseCommand(this.commandPaletteViewModel); - case 'DESCRIBE_X': - return new DescribeXCommand(this.context, this.textViewModel, this.audioService, this.textService); - case 'DESCRIBE_Y': - return new DescribeYCommand(this.context, this.textViewModel, this.audioService, this.textService); - case 'DESCRIBE_FILL': - return new DescribeFillCommand(this.context, this.textViewModel, this.audioService, this.textService); - case 'DESCRIBE_POINT': - return new DescribePointCommand( + case 'ANNOUNCE_X': + return new AnnounceXCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_Y': + return new AnnounceYCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_FILL': + return new AnnounceFillCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_POINT': + return new AnnouncePointCommand( this.context, this.audioService, this.highlightService, @@ -240,12 +240,12 @@ export class CommandFactory { this.textViewModel, this.textService, ); - case 'DESCRIBE_TITLE': - return new DescribeTitleCommand(this.context, this.textViewModel, this.audioService, this.textService); - case 'DESCRIBE_SUBTITLE': - return new DescribeSubtitleCommand(this.context, this.textViewModel, this.audioService, this.textService); - case 'DESCRIBE_CAPTION': - return new DescribeCaptionCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_TITLE': + return new AnnounceTitleCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_SUBTITLE': + return new AnnounceSubtitleCommand(this.context, this.textViewModel, this.audioService, this.textService); + case 'ANNOUNCE_CAPTION': + return new AnnounceCaptionCommand(this.context, this.textViewModel, this.audioService, this.textService); case 'ANNOUNCE_POSITION': return new AnnouncePositionCommand( this.context, @@ -255,10 +255,11 @@ export class CommandFactory { ); case 'ACTIVATE_FIGURE_LABEL_SCOPE': + return new ToggleScopeCommand(this.context, Scope.FIGURE_LABEL, this.textViewModel); case 'DEACTIVATE_FIGURE_LABEL_SCOPE': return new ToggleScopeCommand(this.context, Scope.FIGURE_LABEL); case 'ACTIVATE_TRACE_LABEL_SCOPE': - return new ToggleScopeCommand(this.context, Scope.TRACE_LABEL); + return new ToggleScopeCommand(this.context, Scope.TRACE_LABEL, this.textViewModel); case 'DEACTIVATE_TRACE_LABEL_SCOPE': return new ToggleScopeCommand(this.context, Scope.TRACE); case 'AUTOPLAY_UPWARD': diff --git a/src/command/toggle.ts b/src/command/toggle.ts index 7e3d10d2..35ab4064 100644 --- a/src/command/toggle.ts +++ b/src/command/toggle.ts @@ -293,21 +293,25 @@ export class CommandPaletteSelectCommand implements Command { export class ToggleScopeCommand implements Command { private readonly context: Context; private readonly scope: Scope; + private readonly textViewModel?: TextViewModel; /** * Creates an instance of ToggleScopeCommand. * @param {Context} context - The application context. * @param {Scope} scope - The scope to toggle. + * @param {TextViewModel} [textViewModel] - Optional text view model for text-off warnings. */ - public constructor(context: Context, scope: Scope) { + public constructor(context: Context, scope: Scope, textViewModel?: TextViewModel) { this.context = context; this.scope = scope; + this.textViewModel = textViewModel; } /** * Toggles the specified scope in the context. */ public execute(): void { + this.textViewModel?.warnIfTextOff(); this.context.toggleScope(this.scope); } } diff --git a/src/controller.ts b/src/controller.ts index da92f33b..9e7b8301 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -134,6 +134,7 @@ export class Controller implements Disposable { this.textService, this.notificationService, this.autoplayService, + this.audioService, ); this.brailleViewModel = new BrailleViewModel(store, this.brailleService); this.goToExtremaViewModel = new GoToExtremaViewModel( diff --git a/src/service/keybinding.ts b/src/service/keybinding.ts index f99df58f..907c0d7e 100644 --- a/src/service/keybinding.ts +++ b/src/service/keybinding.ts @@ -55,7 +55,7 @@ const BRAILLE_KEYMAP = { TOGGLE_SETTINGS: `${Platform.ctrl}+,`, // Description - DESCRIBE_POINT: `space`, + ANNOUNCE_POINT: `space`, ANNOUNCE_POSITION: `p`, // rotor functionality @@ -78,9 +78,9 @@ const FIGURE_LABEL_KEYMAP = { DEACTIVATE_FIGURE_LABEL_SCOPE: `escape`, // Description - DESCRIBE_TITLE: `t`, - DESCRIBE_SUBTITLE: `s`, - DESCRIBE_CAPTION: `c`, + ANNOUNCE_TITLE: `t`, + ANNOUNCE_SUBTITLE: `s`, + ANNOUNCE_CAPTION: `c`, // Misc TOGGLE_HELP: `${Platform.ctrl}+/`, @@ -101,8 +101,8 @@ const SUBPLOT_KEYMAP = { ACTIVATE_FIGURE_LABEL_SCOPE: `l`, // Description - DESCRIBE_TITLE: `t`, - DESCRIBE_POINT: `space`, + ANNOUNCE_TITLE: `t`, + ANNOUNCE_POINT: `space`, ANNOUNCE_POSITION: `p`, // Navigation @@ -133,12 +133,12 @@ const TRACE_LABEL_KEYMAP = { DEACTIVATE_TRACE_LABEL_SCOPE: `escape`, // Description - DESCRIBE_X: `x`, - DESCRIBE_Y: `y`, - DESCRIBE_FILL: `f`, - DESCRIBE_TITLE: `t`, - DESCRIBE_SUBTITLE: `s`, - DESCRIBE_CAPTION: `c`, + ANNOUNCE_X: `x`, + ANNOUNCE_Y: `y`, + ANNOUNCE_FILL: `f`, + ANNOUNCE_TITLE: `t`, + ANNOUNCE_SUBTITLE: `s`, + ANNOUNCE_CAPTION: `c`, // Misc TOGGLE_HELP: `${Platform.ctrl}+/`, @@ -214,7 +214,7 @@ const TRACE_KEYMAP = { TOGGLE_SETTINGS: `${Platform.ctrl}+,`, // Description - DESCRIBE_POINT: `space`, + ANNOUNCE_POINT: `space`, ANNOUNCE_POSITION: `p`, // Go To functionality diff --git a/src/state/viewModel/textViewModel.ts b/src/state/viewModel/textViewModel.ts index f8b49f08..74abfdb8 100644 --- a/src/state/viewModel/textViewModel.ts +++ b/src/state/viewModel/textViewModel.ts @@ -1,4 +1,5 @@ import type { PayloadAction } from '@reduxjs/toolkit'; +import type { AudioService } from '@service/audio'; import type { AutoplayService } from '@service/autoplay'; import type { NotificationService } from '@service/notification'; import type { TextService } from '@service/text'; @@ -61,6 +62,7 @@ const { update, announceText, toggle, notify, clearMessage, reset } = textSlice. * ViewModel for managing text display, announcements, and notifications. */ export class TextViewModel extends AbstractViewModel { + private readonly audioService: AudioService; private readonly textService: TextService; /** @@ -69,14 +71,17 @@ export class TextViewModel extends AbstractViewModel { * @param text - Service for managing text formatting and updates * @param notification - Service for handling notification messages * @param autoplay - Service for managing autoplay functionality + * @param audio - Audio service for playing warning tones */ public constructor( store: AppStore, text: TextService, notification: NotificationService, autoplay: AutoplayService, + audio: AudioService, ) { super(store); + this.audioService = audio; this.textService = text; this.registerListeners(notification, autoplay); } @@ -166,6 +171,19 @@ export class TextViewModel extends AbstractViewModel { public setAnnounce(enabled: boolean): void { this.store.dispatch(announceText(enabled)); } + + /** + * Warns the user if text mode is off by announcing a message and playing a warning tone. + * @returns True if text mode is off and the warning was issued, false otherwise + */ + public warnIfTextOff(): boolean { + if (!this.textService.isOff()) { + return false; + } + this.notify('Text mode is off. To enable, press the T key.'); + this.audioService.playWarningTone(); + return true; + } } export default textSlice.reducer;