feat: theme-driven chrome, customizable tab accent, UI polish

- Sidebar/tab bars/dividers/settings dialog follow the terminal theme
- Tab accent color customizable in theme settings (color picker)
- Title bar shows the app icon; Material Dark is the default theme
- Slimmer rectangular tabs (28px), slate scrollbars, visible pane dividers
- Fix pure-black xterm viewport gaps after pane resize
This commit is contained in:
Bill committed 2026-09-08 00:57:56 +08:00
1 parent 0de0eee58d
commit 8cb193fa6a
13 files changed
+241 -66

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.1",
"version": "1.0.2",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+16
View File
@@ -4,6 +4,8 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
/** Custom window title bar: draggable strip themed with the active terminal
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
@@ -15,6 +17,7 @@ function TitleBar(): React.JSX.Element {
className="app-titlebar"
style={{ background: theme.colors.background, color: theme.colors.foreground }}
>
<img className="app-titlebar-icon" src={appIconUrl} alt="" draggable={false} />
<span className="app-titlebar-title">OpenTerminal</span>
</div>
)
@@ -22,12 +25,25 @@ function TitleBar(): React.JSX.Element {
export default function App(): React.JSX.Element {
const hydrate = useSettingsStore((s) => s.hydrate)
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
// Sidebar / tab bars / dividers follow the active terminal theme.
useEffect(() => {
applyChromeTheme(getThemeById(themeId, customThemes).colors)
}, [themeId, customThemes])
// Active-tab accent color (rail indicator, tab outline, SSH badges).
useEffect(() => {
applyTabAccent(tabAccentColor)
}, [tabAccentColor])
return (
<div className="app-root">
<TitleBar />
+1 -1
View File
@@ -346,7 +346,7 @@
}
.connections-local-item.is-active {
background: rgba(63, 185, 80, 0.12);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
}
.connections-local-name {
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import type { AppApi } from '../shared/api'
declare global {
+20 -7
View File
@@ -20,16 +20,16 @@ body,
font-size: 13px;
}
/* App-wide slim dark scrollbars: the native Windows style renders light
/* App-wide slim slate scrollbars: the native Windows style renders light
tracks + arrow buttons that look broken on the dark surface. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
@@ -37,12 +37,17 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-button,
@@ -80,6 +85,14 @@ button {
letter-spacing: 0.2px;
}
.app-titlebar-icon {
width: 16px;
height: 16px;
margin-right: 7px;
border-radius: 4px;
pointer-events: none;
}
.app-titlebar-title {
opacity: 0.75;
font-weight: 500;
+26 -4
View File
@@ -1,12 +1,14 @@
import ReactDOM from 'react-dom/client'
import { useEffect } from 'react'
import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import { mix } from './theme/chrome'
import './global.css'
// Dev convenience: allow opening the renderer in a plain browser (vite page)
@@ -148,13 +150,33 @@ function FontHotkeyListener(): null {
// antd <App> provides the context that App.useApp() consumers (workspace
// message/notification) rely on; without it they get empty stubs and crash.
// ErrorBoundary keeps any render crash from black-screening the whole window.
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
/** antd surfaces (settings dialog, menus, popovers) follow the terminal theme:
* base/container/elevated backgrounds derive from the theme's background. */
function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX.Element {
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const colors = getThemeById(themeId, customThemes).colors
return (
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: { colorBgContainer: '#1f1f1f', colorBgElevated: '#252525', borderRadius: 6 }
token: {
colorBgBase: colors.background,
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? '#1f1f1f',
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? '#252525',
colorTextBase: colors.foreground,
borderRadius: 6
}
}}
>
{children}
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ThemedConfigProvider>
<AntdApp>
<ErrorBoundary>
<App />
@@ -162,5 +184,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ConfigProvider>
</ThemedConfigProvider>
)
+21 -1
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { Button, Popconfirm } from 'antd'
import { Button, ColorPicker, Popconfirm } from 'antd'
import type { TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
@@ -46,6 +46,25 @@ export function ThemeSettingsTab({
}
return (
<div className="theme-pane">
<div className="theme-accent-row">
<div>
<div className="settings-block-label">标签强调色</div>
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
</div>
<div className="theme-accent-controls">
<ColorPicker
value={settings.terminal.tabAccentColor}
onChange={(color) => void updateTerminal({ tabAccentColor: color.toHexString() })}
/>
<Button
size="small"
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
>
恢复默认
</Button>
</div>
</div>
<div className="theme-gallery">
{themeCards.map((t) => {
const selected = t.id === activeTheme.id
@@ -117,5 +136,6 @@ export function ThemeSettingsTab({
<div className="theme-card-new-text">新建主题</div>
</div>
</div>
</div>
)
}
+29 -5
View File
@@ -5,13 +5,13 @@
/* ---------- settings dialog scaffolding ---------- */
.ant-modal-header {
border-bottom: 1px solid #2d2d2d;
border-bottom: 1px solid var(--chrome-border);
padding-bottom: 14px;
}
.ant-modal .ant-tabs-nav {
margin-bottom: 8px;
border-bottom: 1px solid #2d2d2d;
border-bottom: 1px solid var(--chrome-border);
}
.settings-footer {
@@ -116,7 +116,7 @@
margin-top: 14px;
border-radius: 6px;
padding: 12px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
overflow: hidden;
}
@@ -140,6 +140,30 @@
/* ---------- theme gallery ---------- */
/* ---- theme tab: accent picker + gallery ---- */
.theme-pane {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.theme-accent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
}
.theme-accent-controls {
display: flex;
align-items: center;
gap: 8px;
}
.theme-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -333,7 +357,7 @@
.theme-editor-preview-box {
border-radius: 6px;
padding: 10px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
}
@@ -356,7 +380,7 @@
width: 48px;
height: 48px;
border-radius: 12px;
background: #1e2531;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
+9 -2
View File
@@ -44,6 +44,13 @@ const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNe
activeMatchColorOverviewRuler: '#c77800'
}
// xterm's default overviewRulerBorder (#7f7f7f) renders as a light vertical line
// on the dark surface once scrollback exists. Force it transparent; search-match
// marks in the ruler keep their own colors.
function withChromeColors(colors: ITheme): ITheme {
return { ...colors, overviewRulerBorder: '#00000000' }
}
export interface TerminalHandle {
focus(): void
clear(): void
@@ -530,7 +537,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
cursorBlink: tSettings.cursorBlink,
cursorStyle: tSettings.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle,
theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme),
allowProposedApi: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
drawBoldTextInBrightColors: true,
@@ -737,7 +744,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.options.cursorBlink = tSettings.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) }
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme)
scheduleFit()
}, [tSettings, settings.customThemes, scheduleFit])
+21 -7
View File
@@ -13,15 +13,24 @@
height: 100%;
}
/* Dark slim scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. */
/* xterm.css defaults .xterm-viewport to #000; when the pane height isn't a
multiple of the cell height the leftover bottom strip shows that black.
Pin it to the (theme-derived) chrome background so the gap is invisible. */
.terminal-view .xterm .xterm-viewport {
background-color: var(--chrome-bg);
}
/* Slim slate scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. The thumb
sits inset inside a wider grab lane (transparent border + padding-box clip)
so it reads as a soft slate bar, never a white line. */
.terminal-view .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
.terminal-view .xterm-viewport::-webkit-scrollbar {
width: 10px;
width: 12px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-track {
@@ -29,12 +38,17 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-button,
+51
View File
@@ -0,0 +1,51 @@
import type { ThemeColors } from '@shared/theme'
function parseHex(color: string): [number, number, number] | null {
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
/** t=0 → a, t=1 → b */
export function mix(a: string, b: string, t: number): string | null {
const ca = parseHex(a)
const cb = parseHex(b)
if (!ca || !cb) return null
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
}
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
* terminal theme so the whole window follows theme switches instead of only
* the terminal canvas. Values land on :root CSS variables; anything the theme
* can't provide keeps the static fallback in global.css. */
export function applyChromeTheme(colors: ThemeColors): void {
const style = document.documentElement.style
const set = (key: string, value: string | null): void => {
if (value) style.setProperty(key, value)
}
set('--chrome-bg', colors.background)
set('--chrome-fg', colors.foreground)
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
export function applyTabAccent(color: string): void {
const style = document.documentElement.style
// Settings hydrated from an older main process may lack the key; clear the
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
if (!color) {
style.removeProperty('--tab-accent')
style.removeProperty('--tab-accent-fg')
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
}
+39 -36
View File
@@ -55,8 +55,8 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
box-shadow: inset 2px 0 0 #3fb950;
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
}
.workspace-rail-spacer {
@@ -173,19 +173,22 @@
/* Align terminal chrome with the app chrome. */
.dockview-theme-abyss {
--dv-group-view-background-color: var(--chrome-bg);
--dv-tabs-and-actions-container-background-color: #10141a;
--dv-tabs-and-actions-container-height: 32px;
--dv-tabs-and-actions-container-background-color: var(--chrome-bg-deep, #10141a);
--dv-tabs-and-actions-container-height: 28px;
--dv-tab-divider-color: transparent;
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
}
/* ---- tab polish: rounded pills, quiet inactive, green active ---- */
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
.workspace-dockview .dv-tabs-and-actions-container {
padding: 4px 5px 0;
box-sizing: border-box;
}
.workspace-dockview .dv-tab {
border-radius: 9px;
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
@@ -202,14 +205,14 @@
/* XTerminal-style active tab: green outline + tinted bg + green title. */
.workspace-dockview .dv-tab.dv-active-tab {
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.75);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 75%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
.workspace-dockview .dv-tab.dv-active-tab:hover {
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.95);
color: #4ad476;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 95%, transparent);
color: var(--tab-accent-fg, #4ad476);
}
.workspace-dockview .dv-tab .workspace-terminal-tab {
@@ -217,15 +220,15 @@
}
.workspace-dockview .dv-tab .workspace-terminal-tab-title {
font-size: 12.5px;
font-size: 12px;
line-height: 1;
}
/* Close glyph as its own mini outlined pill, mirroring the tab border. */
.workspace-dockview .dv-tab .workspace-terminal-tab-close {
width: 17px;
height: 17px;
border-radius: 4px;
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -233,9 +236,9 @@
.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover {
opacity: 1;
color: #3fd06a;
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
}
/* ---- empty state overlay ---- */
@@ -377,11 +380,11 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-terminal-tab-icon {
flex: 0 0 auto;
font-size: 12px;
color: #3fb950;
color: var(--tab-accent, #3fb950);
}
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950;
border-left: 2px solid var(--tab-accent, #3fb950);
padding-left: 5px !important;
gap: 5px;
}
@@ -397,9 +400,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 1px 6px 2px;
box-sizing: border-box;
border-radius: 9px;
border: 1px solid rgba(63, 185, 80, 0.38);
background: rgba(63, 185, 80, 0.08);
color: #3fb950;
border: 1px solid color-mix(in srgb, var(--tab-accent, #3fb950) 38%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
color: var(--tab-accent, #3fb950);
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
font-size: 10.5px;
line-height: 1;
@@ -407,9 +410,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge {
border-color: rgba(63, 185, 80, 0.6);
background: rgba(63, 185, 80, 0.14);
color: #46c96a;
border-color: color-mix(in srgb, var(--tab-accent, #3fb950) 60%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 14%, transparent);
color: var(--tab-accent-fg, #46c96a);
}
/* ---- per-group "+" right after the last tab ----
@@ -438,9 +441,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-add:hover {
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
@@ -476,15 +479,15 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-btn2.is-active {
color: #3fd06a;
background: rgba(63, 185, 80, 0.12);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.55);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 55%, transparent);
}
.workspace-tabbar-bcast-count {
font-size: 10px;
font-weight: 600;
color: #3fb950;
color: var(--tab-accent, #3fb950);
line-height: 1;
}
@@ -565,9 +568,9 @@ body.ssh-resizing .ssh-bottom-divider {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35);
color: var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 35%, transparent);
user-select: none;
}
+5 -2
View File
@@ -36,6 +36,8 @@ export interface TerminalSettings {
pasteRiskConfirm: boolean
/** inline command suggestion popup while typing (history + library) */
suggestEnabled: boolean
/** accent color for the active tab outline, rail indicator and SSH badges */
tabAccentColor: string
}
export interface SystemSettings {
@@ -95,12 +97,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
cursorBlink: true,
cursorStyle: 'block',
cursorInactiveStyle: 'outline',
themeId: 'default-dark',
themeId: 'material-dark',
rendererMode: 'auto',
autoWrap: true,
copyOnSelect: false,
pasteRiskConfirm: true,
suggestEnabled: true
suggestEnabled: true,
tabAccentColor: '#3fb950'
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,