diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfaf11..96be6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to bbcom are documented here. The format is based on ## [Unreleased] +## [1.2.1] - 2026-09-12 + +### Added + +- Serial sessions support updating connection settings while connected. + +### Fixed + +- Bundle JetBrains Mono Regular and Bold for consistent offline terminal fonts. +- Wait for terminal fonts before measuring character cells and replaying output; + use system monospace on font load failure and skip setup after unmounting. +- Adjust Shell font sizing and line spacing and disable ligatures and kerning + to keep serial output aligned. + ## [1.2.0] - 2026-09-08 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 0a28013..562baa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bbcom" -version = "1.2.0" +version = "1.2.1" dependencies = [ "base64 0.22.1", "bbcom-contracts", diff --git a/index.html b/index.html index f818df5..423c563 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,13 @@ document, fall back to the read-only v1 app-settings blob. main.ts mounts into #app and replaces the skeleton, so there is no layout shift. --> - + + + diff --git a/src/features/sessions/ui/SessionToolbar.vue b/src/features/sessions/ui/SessionToolbar.vue index 0876fae..04e9f4c 100644 --- a/src/features/sessions/ui/SessionToolbar.vue +++ b/src/features/sessions/ui/SessionToolbar.vue @@ -13,12 +13,15 @@ :capture-paused="captureIsPaused" :can-clear="captureCanClear" :sending-break="sendingBreak" + :reconfiguring="reconfiguring" + :settings-disabled="settingsDisabled" @connect="$emit('connect')" @disconnect="$emit('disconnect')" @rebind="$emit('rebind')" @clear="$emit('clear')" @toggle-pause="$emit('toggle-pause')" @send-break="$emit('send-break')" + @settings="$emit('settings')" />
@@ -119,6 +122,8 @@ const props = withDefaults( viewMode: SessionViewMode; /** True while MCUmgr owns the port; connection controls must stay locked. */ connectionLocked?: boolean; + reconfiguring?: boolean; + settingsDisabled?: boolean; }>(), { captureHasData: undefined, capturePaused: undefined }, ); @@ -136,6 +141,7 @@ const emit = defineEmits<{ export: []; 'show-conflicting-session': [sessionId: string]; rebind: []; + settings: []; }>(); const appStore = useAppStore(); diff --git a/src/features/sessions/ui/SessionView.vue b/src/features/sessions/ui/SessionView.vue index 80dd4f4..1c97288 100644 --- a/src/features/sessions/ui/SessionView.vue +++ b/src/features/sessions/ui/SessionView.vue @@ -25,6 +25,8 @@ :connection-conflict="runtime.connectionFailure.value?.conflict" :needs-rebind="Boolean(rebindMetadata)" :sending-break="runtime.sendingBreak.value" + :reconfiguring="runtime.reconfiguring.value" + :settings-disabled="!mutationPolicy.userMutationsAllowed.value" :is-exporting="isExporting" :view-mode="viewMode" :connection-locked="runtime.mcumgr.busy.value" @@ -40,6 +42,15 @@ @export="openExportDialog" @show-conflicting-session="showConflictingSession" @rebind="rebindDialogVisible = true" + @settings="serialSettingsVisible = true" + /> + catalog.workspaceRebindBySessionId.value[props.session.id] ?? null, ); +async function saveSerialSettings(config: PortConfig): Promise { + if (await runtime.updatePortConfig(config)) serialSettingsVisible.value = false; +} + async function connect() { await runtime.connect(); } diff --git a/src/features/terminal/__tests__/serial-shell-panel.test.ts b/src/features/terminal/__tests__/serial-shell-panel.test.ts index a6d0aeb..bc91773 100644 --- a/src/features/terminal/__tests__/serial-shell-panel.test.ts +++ b/src/features/terminal/__tests__/serial-shell-panel.test.ts @@ -2,8 +2,9 @@ * @vitest-environment happy-dom */ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; +import { flushPromises, shallowMount } from '@vue/test-utils'; import type { SerialShellConfig } from '@/types'; +import { logger } from '@/lib/logger'; const xtermMocks = vi.hoisted(() => { const state: { @@ -79,9 +80,32 @@ const config: SerialShellConfig = { backspace: 'bs', }; +const loadFont = vi.fn(); +const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts'); + +function mountShell() { + const shell = { + replay: vi.fn(() => ''), + onOutput: vi.fn(() => vi.fn()), + onReset: vi.fn(() => vi.fn()), + handleTerminalData: vi.fn(), + clear: vi.fn(), + }; + const wrapper = shallowMount(SerialShellPanel, { + props: { sessionId: 'session-shell', config, isConnected: true, shell }, + }); + return { wrapper, shell }; +} + beforeEach(() => { xtermMocks.state.constructorOptions = null; xtermMocks.state.customKeyHandler = null; + loadFont.mockReset().mockResolvedValue([]); + Object.defineProperty(document, 'fonts', { + configurable: true, + value: { load: loadFont }, + }); + document.documentElement.style.setProperty('--font-mono', '"JetBrains Mono", monospace'); vi.stubGlobal( 'ResizeObserver', class { @@ -92,25 +116,17 @@ beforeEach(() => { }); afterEach(() => { + if (originalFonts) Object.defineProperty(document, 'fonts', originalFonts); + else Reflect.deleteProperty(document, 'fonts'); + document.documentElement.style.removeProperty('--font-mono'); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); -test('Shell terminal sends physical Enter directly without changing RX rendering semantics', () => { - const handleTerminalData = vi.fn(); - const wrapper = shallowMount(SerialShellPanel, { - props: { - sessionId: 'session-shell', - config, - isConnected: true, - shell: { - replay: () => '', - onOutput: () => () => undefined, - onReset: () => () => undefined, - handleTerminalData, - clear: vi.fn(), - }, - }, - }); +test('Shell terminal sends physical Enter directly without changing RX rendering semantics', async () => { + const { wrapper, shell } = mountShell(); + const { handleTerminalData } = shell; + await flushPromises(); expect(xtermMocks.state.constructorOptions).not.toHaveProperty('convertEol'); expect(xtermMocks.state.customKeyHandler).not.toBeNull(); @@ -127,3 +143,53 @@ test('Shell terminal sends physical Enter directly without changing RX rendering wrapper.unmount(); }); + +test('Shell waits for regular and bold fonts before measuring cells and replaying output', async () => { + const regular = Promise.withResolvers(); + const bold = Promise.withResolvers(); + loadFont.mockReturnValueOnce(regular.promise).mockReturnValueOnce(bold.promise); + const { wrapper, shell } = mountShell(); + + expect(xtermMocks.state.constructorOptions).toBeNull(); + expect(shell.replay).not.toHaveBeenCalled(); + regular.resolve([]); + await flushPromises(); + expect(xtermMocks.state.constructorOptions).toBeNull(); + + bold.resolve([]); + await flushPromises(); + expect(xtermMocks.state.constructorOptions?.fontFamily).toBe('"JetBrains Mono", monospace'); + expect(shell.replay).toHaveBeenCalledOnce(); + expect(shell.onOutput).toHaveBeenCalledOnce(); + expect(shell.onReset).toHaveBeenCalledOnce(); + wrapper.unmount(); + expect(shell.onOutput.mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(shell.onReset.mock.results[0]?.value).toHaveBeenCalledOnce(); +}); + +test('Shell closed during font loading never creates a terminal or subscribes to output', async () => { + const pending = Promise.withResolvers(); + loadFont.mockReturnValue(pending.promise); + const { wrapper, shell } = mountShell(); + wrapper.unmount(); + + pending.resolve([]); + await flushPromises(); + expect(xtermMocks.state.constructorOptions).toBeNull(); + expect(shell.replay).not.toHaveBeenCalled(); + expect(shell.onOutput).not.toHaveBeenCalled(); + expect(shell.onReset).not.toHaveBeenCalled(); +}); + +test('Shell remains usable with system monospace if a bundled font fails to load', async () => { + const error = new Error('Font unavailable'); + loadFont.mockRejectedValue(error); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const { wrapper, shell } = mountShell(); + await flushPromises(); + + expect(xtermMocks.state.constructorOptions?.fontFamily).toBe('monospace'); + expect(shell.onOutput).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith('Shell font failed to load; using system monospace', error); + wrapper.unmount(); +}); diff --git a/src/features/terminal/ui/SerialShellPanel.vue b/src/features/terminal/ui/SerialShellPanel.vue index f73cf81..eb6912d 100644 --- a/src/features/terminal/ui/SerialShellPanel.vue +++ b/src/features/terminal/ui/SerialShellPanel.vue @@ -163,6 +163,7 @@ import { import type { SessionRuntimeShellController } from '@/features/sessions/runtime/session-runtime-controller'; import { useAppStore } from '@/features/settings/store/app-store'; import { t } from '@/lib/i18n'; +import { logger } from '@/lib/logger'; import type { SerialShellConfig } from '@/types'; const props = defineProps<{ @@ -196,6 +197,7 @@ let searchAddon: SearchAddon | null = null; let resizeObserver: ResizeObserver | null = null; let stopOutput: (() => void) | null = null; let stopReset: (() => void) | null = null; +let disposed = false; const newlineChoices = [ { label: t('shell.newline.none'), value: 'none' as const }, @@ -287,15 +289,32 @@ function handleCustomKey(event: KeyboardEvent): boolean { return true; } -onMounted(() => { +onMounted(async () => { const host = terminalHost.value; if (!host) return; + const fontSize = 14; + let fontFamily = cssVariable('--font-mono') || 'monospace'; + try { + // xterm caches cell measurements on open. Load both weights first so + // fallback metrics cannot leave gaps or misalign ANSI bold output. + await Promise.all([ + document.fonts.load(`400 ${fontSize}px ${fontFamily}`), + document.fonts.load(`700 ${fontSize}px ${fontFamily}`), + ]); + } catch (error) { + logger.warn('Shell font failed to load; using system monospace', error); + fontFamily = 'monospace'; + } + if (disposed || terminalHost.value !== host) return; const term = new Terminal({ cursorBlink: true, scrollback: 5_000, - fontFamily: cssVariable('--font-mono') || 'monospace', - fontSize: 13, - lineHeight: 1.35, + fontFamily, + fontSize, + fontWeight: 400, + fontWeightBold: 700, + letterSpacing: 0, + lineHeight: 1.25, theme: terminalTheme(), }); fitAddon = new FitAddon(); @@ -323,6 +342,7 @@ onActivated(() => { }); onBeforeUnmount(() => { + disposed = true; stopOutput?.(); stopOutput = null; stopReset?.(); @@ -521,6 +541,9 @@ function findPrevious(): void { .shell-body :deep(.xterm) { height: 100%; + font-variant-ligatures: none; + font-kerning: none; + text-rendering: auto; } .shell-body :deep(.xterm-viewport) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4a6df34..faf054d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,14 +2,44 @@ import type { DisplayMode } from '@/types'; import type { ExportFormat as ContractExportFormat } from '@/generated/ipc-contracts'; export const BAUD_RATES = [ + { label: '50', value: 50 }, + { label: '75', value: 75 }, + { label: '110', value: 110 }, + { label: '134', value: 134 }, + { label: '150', value: 150 }, + { label: '200', value: 200 }, + { label: '300', value: 300 }, + { label: '600', value: 600 }, + { label: '1200', value: 1200 }, + { label: '1800', value: 1800 }, + { label: '2400', value: 2400 }, + { label: '4800', value: 4800 }, + { label: '7200', value: 7200 }, { label: '9600', value: 9600 }, + { label: '14400', value: 14400 }, { label: '19200', value: 19200 }, + { label: '28800', value: 28800 }, { label: '38400', value: 38400 }, + { label: '56000', value: 56000 }, { label: '57600', value: 57600 }, + { label: '76800', value: 76800 }, { label: '115200', value: 115200 }, + { label: '128000', value: 128000 }, + { label: '153600', value: 153600 }, { label: '230400', value: 230400 }, + { label: '256000', value: 256000 }, { label: '460800', value: 460800 }, + { label: '500000', value: 500000 }, + { label: '576000', value: 576000 }, { label: '921600', value: 921600 }, + { label: '1000000', value: 1000000 }, + { label: '1152000', value: 1152000 }, + { label: '1500000', value: 1500000 }, + { label: '2000000', value: 2000000 }, + { label: '2500000', value: 2500000 }, + { label: '3000000', value: 3000000 }, + { label: '3500000', value: 3500000 }, + { label: '4000000', value: 4000000 }, ]; export const DATA_BITS_OPTIONS = [ diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 3dbb6e1..8133510 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -239,6 +239,12 @@ const en: Catalog = { 'serial.signals': 'Signals', 'serial.signalControl': 'Signal control', 'serial.signalHint': 'DTR/RTS are used for Arduino reset, ESP32 boot modes, and similar flows', + 'serial.settings.title': 'Serial settings', + 'serial.settings.open': 'Edit serial settings', + 'serial.settings.liveHint': 'Changes are applied immediately without closing the session.', + 'serial.settings.nextOpenHint': 'Changes will be used the next time this session connects.', + 'serial.settings.updated': 'Serial settings updated', + 'serial.settings.updateFailed': 'Could not update serial settings: {error}', 'serial.summary': 'Current connection summary', 'serial.rate': 'Rate', 'serial.format': 'Format', diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index b53c041..affed5a 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -222,6 +222,12 @@ const zh: Catalog = { 'serial.signals': '信号', 'serial.signalControl': '信号控制', 'serial.signalHint': 'DTR/RTS 用于 Arduino 复位、ESP32 启动模式等', + 'serial.settings.title': '串口设置', + 'serial.settings.open': '修改串口设置', + 'serial.settings.liveHint': '保存后立即生效,无需断开当前会话。', + 'serial.settings.nextOpenHint': '设置将在此会话下次连接时生效。', + 'serial.settings.updated': '串口设置已更新', + 'serial.settings.updateFailed': '串口设置更新失败:{error}', 'serial.summary': '当前连接摘要', 'serial.rate': '速率', 'serial.format': '格式',