diff --git a/docs/design/0147-unmaterialized-file-frontier.md b/docs/design/0147-unmaterialized-file-frontier.md
index 56fdbecb..e7ed3b2a 100644
--- a/docs/design/0147-unmaterialized-file-frontier.md
+++ b/docs/design/0147-unmaterialized-file-frontier.md
@@ -210,13 +210,13 @@ The buffer is real even before disk exists.
The editor opens immediately with an empty buffer. The footer names the state:
```text
-/repo/foo.txt [clean | main | fs:unmaterialized | target:main]
+/repo/foo.txt [basis:reading | head:basis | worldline:main | export:pending | admit:main | tick:t0]
```
After typing:
```text
-/repo/foo.txt [dirty | main+local | fs:unmaterialized | target:main]
+/repo/foo.txt [basis:reading | head:local | worldline:main | export:pending | admit:main | tick:t0]
```
The history/worldline drawer shows:
@@ -224,10 +224,10 @@ The history/worldline drawer shows:
```text
Worldlines
projection: canonical@t0 + local optimistic | braid active | phase:unconfirmed
-s phase r name basis head delta evidence note
-> settled C main main 0 +0/-0 canonical@t0 clear
- unconfirmed L local canonical@t0 - +local/-0 request:3 optimistic
- unconfirmed B visible braid main+local - +local/-0 canonical@t0 active
+s phase r name basis head span evidence note
+> settled C main main 0 tick:t0 canonical@t0 clear
+ unconfirmed L local canonical@t0 - local request:3 optimistic
+ unconfirmed B visible braid main+local - local canonical@t0 active
```
### First Save
@@ -393,7 +393,7 @@ Deliverables:
- split host path observation from file load;
- open missing paths with empty initial text;
-- footer/history label `fs:unmaterialized`;
+- footer/history label `export:pending`;
- command-line regression for `:edit missing.txt`;
- no host file write before save.
@@ -442,7 +442,7 @@ Deliverables:
Lower modes must expose the same facts without relying on color:
-- footer text uses explicit `fs:unmaterialized`, `external-frontier`, and
+- footer text uses explicit `export:pending`, `external-frontier`, and
`braid active` labels;
- history drawer rows include `External Edit` as text, not just color;
- `:why` states whether the visible buffer is canonical, local, external, or
diff --git a/docs/topics/ui/README.md b/docs/topics/ui/README.md
index afc5cc30..c1e040d1 100644
--- a/docs/topics/ui/README.md
+++ b/docs/topics/ui/README.md
@@ -26,7 +26,7 @@ Jim UI documents use three terminal width profiles:
| Page | Surface |
| --- | --- |
| [Title Screen](title-screen.md) | Startup scene, title browser, and first file-open path. |
-| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, dirty state, and cursor position. |
+| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, causal posture, and cursor position. |
| [Settings Menu](settings-menu.md) | F2 settings drawer, keyboard controls, diagnostics, and change feedback. |
| [Command Line And Completions](command-line-and-completions.md) | Normal-mode `:` input, command help, file completion, and inline suggestions. |
| [Drawers And Panels](drawers-and-panels.md) | File tree, Graft outline, Echo history, diagnostics, and inline why panels. |
diff --git a/docs/topics/ui/editor-chrome.md b/docs/topics/ui/editor-chrome.md
index b0b9a7e5..f5251b84 100644
--- a/docs/topics/ui/editor-chrome.md
+++ b/docs/topics/ui/editor-chrome.md
@@ -1,7 +1,7 @@
# Editor Chrome
Editor chrome is the persistent frame around the current buffer: source
-viewport, gutter, header title, footer hints, dirty markers, and coordinate
+viewport, gutter, header title, footer hints, causal markers, and coordinate
readouts.
@@ -44,8 +44,9 @@ Line number modes:
| `Off` | Hide line numbers. |
Future gutter work should add theme-token-controlled dimming and modified-line
-markers. Modified-line markers need a real saved-buffer baseline; they should
-not be faked from the generic dirty flag.
+markers. Modified and removed line markers should be projections of Echo edit
+receipts relative to the current causal basis or checkpoint; they should not be
+derived from Git diff, host-file comparison, or a single projection-changed flag.
## Footer
@@ -54,16 +55,17 @@ The footer is the main low-friction status surface. It should show:
- current mode;
- `line:col` cursor position when a source cursor is active;
- mode-specific hints;
-- dirty and materialization posture;
-- target branch or runtime posture when available;
+- causal basis and head posture;
+- export or materialization posture;
+- worldline, admission, and tick posture when available;
- command-line input when `:` mode is active.
When settings or another non-source overlay owns focus, the footer should show
that surface's focus state instead of leaking the editor cursor coordinate.
The lower-right status segment currently reports workspace and worldline
-posture. It is not a line-diff counter unless the implementation has a
-saved-text baseline to compare against.
+posture. It is not a Git diff counter; modified and removed line evidence must
+come from Echo receipts admitted after the displayed basis.
## Implementation Map
diff --git a/docs/topics/ui/editor-chrome.svg b/docs/topics/ui/editor-chrome.svg
index 681a99e8..08d473bd 100644
--- a/docs/topics/ui/editor-chrome.svg
+++ b/docs/topics/ui/editor-chrome.svg
@@ -23,7 +23,7 @@
this is a change
INSERT 5:17 [text input - esc normal - ctrl+s save]
- [dirty | main | fs:unmaterialized]
+ [basis:reading | head:local | export:pending | tick:t0]
narrow
diff --git a/docs/topics/ui/overview.svg b/docs/topics/ui/overview.svg
index 649443da..edfa3692 100644
--- a/docs/topics/ui/overview.svg
+++ b/docs/topics/ui/overview.svg
@@ -56,7 +56,7 @@
diagnostics can sit beside
the active drawer
NORMAL 6:1 [i insert - : command - F2 settings]
- [dirty | main | fs:materialized]
+ [basis:reading | head:local | export:host | tick:t0]
narrow
diff --git a/spec/source-viewer.spec.mjs b/spec/source-viewer.spec.mjs
index d2a5fce4..38f27c7a 100644
--- a/spec/source-viewer.spec.mjs
+++ b/spec/source-viewer.spec.mjs
@@ -5,12 +5,22 @@ import { pathToFileURL } from "node:url";
import { REPO_ROOT, ensureDistBuilt } from "./dist-helpers.mjs";
const SOURCE_VIEWER_PATH = path.join(REPO_ROOT, "dist", "ui", "source-viewer.js");
+const THEMES_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-themes.js");
+const STYLE_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-theme.js");
async function loadSourceViewerModule() {
await ensureDistBuilt();
return import(pathToFileURL(SOURCE_VIEWER_PATH).href);
}
+async function loadThemesModule() {
+ await ensureDistBuilt();
+ return {
+ style: await import(pathToFileURL(STYLE_PATH).href),
+ themes: await import(pathToFileURL(THEMES_PATH).href),
+ };
+}
+
test("source viewer paints a stable line-number gutter before source text", async () => {
const { createSurface } = await import("@flyingrobots/bijou");
const sourceViewer = await loadSourceViewerModule();
@@ -74,6 +84,42 @@ test("source viewer can paint cursor-relative line numbers", async () => {
assert.equal(rowText(surface, 4).startsWith("+2│ line-5"), true);
});
+test("source viewer keeps light-theme gutter cells on the workspace surface", async () => {
+ const { createSurface } = await import("@flyingrobots/bijou");
+ const [sourceViewer, themeModules] = await Promise.all([
+ loadSourceViewerModule(),
+ loadThemesModule(),
+ ]);
+ const theme = themeModules.themes.resolveInitialJeditTheme("morning");
+ const surface = createSurface(16, 1, { char: ".", empty: false });
+
+ sourceViewer.renderSourceViewer(
+ surface,
+ {
+ lines: ["alpha"],
+ cursorRow: 0,
+ cursorCol: 0,
+ scrollRow: 0,
+ scrollCol: 0,
+ mode: "normal",
+ },
+ undefined,
+ {
+ viewport: { width: 16, height: 1 },
+ leftPad: 0,
+ topPad: 0,
+ theme,
+ },
+ );
+
+ const comment = theme.source.get(themeModules.style.JEDIT_SOURCE_TOKEN.Comment);
+
+ assert.deepEqual(surface.get(0, 0).fgRGB, comment?.fgRGB);
+ assert.deepEqual(surface.get(0, 0).bgRGB, theme.surface.workspace.bgRGB);
+ assert.deepEqual(surface.get(1, 0).bgRGB, theme.surface.workspace.bgRGB);
+ assert.deepEqual(surface.get(2, 0).bgRGB, theme.surface.workspace.bgRGB);
+});
+
function sourceViewerTheme() {
const workspace = token("#f0f6fc", "#0d1117");
const gutter = token("#8b949e", "#0d1117");
diff --git a/spec/theme-switch.spec.mjs b/spec/theme-switch.spec.mjs
index 2fe6f717..6b0868ee 100644
--- a/spec/theme-switch.spec.mjs
+++ b/spec/theme-switch.spec.mjs
@@ -35,6 +35,15 @@ const CANONICAL_VARIABLE_NAMES = [
const LUMINANCE_RED_WEIGHT = 0.2126;
const LUMINANCE_GREEN_WEIGHT = 0.7152;
const LUMINANCE_BLUE_WEIGHT = 0.0722;
+const MIN_SURFACE_TEXT_CONTRAST = 4.5;
+const MIN_ACCENT_TEXT_CONTRAST = 3.0;
+const CONTRAST_LUMINANCE_OFFSET = 0.05;
+const SRGB_CHANNEL_MAX = 255;
+const SRGB_LINEAR_BREAKPOINT = 0.03928;
+const SRGB_LINEAR_DIVISOR = 12.92;
+const SRGB_LINEAR_OFFSET = 0.055;
+const SRGB_LINEAR_SCALE = 1.055;
+const SRGB_LINEAR_EXPONENT = 2.4;
async function loadThemesModule() {
await ensureDistBuilt();
@@ -132,6 +141,30 @@ test("authored light and dark variants override generated companions", async ()
assert.equal(solarizedRoundTrip.name, "solarized-dark");
});
+test("jedit surface text clears contrast for built-in and companion themes", async () => {
+ const { themes } = await loadThemesModule();
+
+ for (const theme of themesWithGeneratedCompanions(themes)) {
+ for (const [surfaceName, token] of Object.entries(theme.surface)) {
+ const ratio = colorContrastRatio(token.fgRGB, token.bgRGB);
+ assert.ok(
+ ratio >= MIN_SURFACE_TEXT_CONTRAST,
+ `${theme.name} ${surfaceName} contrast ratio ${ratio.toFixed(2)}`,
+ );
+ }
+ }
+});
+
+test("jedit rendered accent text clears contrast for built-in and companion themes", async () => {
+ const { themes } = await loadThemesModule();
+
+ for (const theme of themesWithGeneratedCompanions(themes)) {
+ assertTokenGroupContrast(theme.name, "source", theme.source, theme.surface.workspace.bgRGB);
+ assertTokenGroupContrast(theme.name, "markdown", theme.markdown, theme.surface.workspace.bgRGB);
+ assertObjectTokenContrast(theme.name, "chrome", theme.chrome, theme.surface.workspace.bgRGB);
+ }
+});
+
test("built-in jedit theme tokens map back to named variables and effect metadata", async () => {
const { themes, style } = await loadThemesModule();
@@ -193,6 +226,69 @@ function colorLuminance(color) {
);
}
+function themesWithGeneratedCompanions(themes) {
+ const all = new Map();
+ for (const theme of themes.availableJeditThemes()) {
+ all.set(theme.name, theme);
+ const companion = themes.oppositeJeditTheme(theme);
+ all.set(companion.name, companion);
+ }
+ return all.values();
+}
+
+function colorContrastRatio(first, second) {
+ const firstLuminance = relativeColorLuminance(first);
+ const secondLuminance = relativeColorLuminance(second);
+ const lighter = Math.max(firstLuminance, secondLuminance);
+ const darker = Math.min(firstLuminance, secondLuminance);
+ return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET);
+}
+
+function assertTokenGroupContrast(themeName, groupName, tokens, fallbackBackground) {
+ for (const [tokenName, token] of tokens) {
+ assertRenderedTokenContrast(
+ themeName,
+ `${groupName}.${tokenName.description ?? String(tokenName)}`,
+ token,
+ fallbackBackground,
+ );
+ }
+}
+
+function assertObjectTokenContrast(themeName, groupName, tokens, fallbackBackground) {
+ for (const [tokenName, token] of Object.entries(tokens)) {
+ assertRenderedTokenContrast(
+ themeName,
+ `${groupName}.${tokenName}`,
+ token,
+ fallbackBackground,
+ );
+ }
+}
+
+function assertRenderedTokenContrast(themeName, tokenName, token, fallbackBackground) {
+ const ratio = colorContrastRatio(token.fgRGB, token.bgRGB ?? fallbackBackground);
+ assert.ok(
+ ratio >= MIN_ACCENT_TEXT_CONTRAST,
+ `${themeName} ${tokenName} contrast ratio ${ratio.toFixed(2)}`,
+ );
+}
+
+function relativeColorLuminance(color) {
+ return (
+ linearChannel(color[0]) * LUMINANCE_RED_WEIGHT +
+ linearChannel(color[1]) * LUMINANCE_GREEN_WEIGHT +
+ linearChannel(color[2]) * LUMINANCE_BLUE_WEIGHT
+ );
+}
+
+function linearChannel(channel) {
+ const scaled = channel / SRGB_CHANNEL_MAX;
+ return scaled <= SRGB_LINEAR_BREAKPOINT
+ ? scaled / SRGB_LINEAR_DIVISOR
+ : ((scaled + SRGB_LINEAR_OFFSET) / SRGB_LINEAR_SCALE) ** SRGB_LINEAR_EXPONENT;
+}
+
function assertCompleteBasePalette(theme) {
const variableNames = [...theme.variables.keys()];
for (const variableName of CANONICAL_VARIABLE_NAMES) {
diff --git a/spec/workspace-app-echo-cutover.spec.mjs b/spec/workspace-app-echo-cutover.spec.mjs
index e525cc84..59daa30c 100644
--- a/spec/workspace-app-echo-cutover.spec.mjs
+++ b/spec/workspace-app-echo-cutover.spec.mjs
@@ -9,6 +9,7 @@ import {
openedHarness,
twoFileHarness,
} from './workspace-echo-test-utils.mjs';
+import { importDist } from './workspace-helpers.mjs';
test('real workspace app path opens files through production text authority', async () => {
const harness = await createWorkspaceEchoAppHarness({
@@ -367,6 +368,7 @@ test('real workspace app path inserts canonical spacebar token in insert mode',
});
test('real workspace app path saves by exporting and checkpointing production text', async () => {
+ const footerPosture = await importDist('app', 'workspace', 'workspace-footer-posture.js');
const harness = await openedHarness({ exportText: 'saved from Echo' });
await harness.key('i');
@@ -379,6 +381,10 @@ test('real workspace app path saves by exporting and checkpointing production te
assert.equal(harness.calls.checkpoint.length, 1);
assert.equal(harness.model.textAuthority.lastExportReadingId, 'reading:export');
assert.equal(harness.model.textAuthority.lastCheckpointId, 'checkpoint:save');
+ assert.match(
+ footerPosture.workspaceFooterTextPosture(harness.model),
+ /basis:checkpoint \| head:checkpoint \| worldline:main \| export:host/,
+ );
});
test('real workspace app path keeps obstruction honest without retrying', async () => {
diff --git a/spec/workspace-command-line.spec.mjs b/spec/workspace-command-line.spec.mjs
index 0f46e7a3..37241c72 100644
--- a/spec/workspace-command-line.spec.mjs
+++ b/spec/workspace-command-line.spec.mjs
@@ -537,7 +537,10 @@ test("enter dispatches edit for missing paths as unmaterialized buffers", async
assert.equal(opened.textAuthority.materialization, "unmaterialized");
assert.equal(opened.editor.dirty, false);
assert.deepEqual(opened.editor.lines, [""]);
- assert.match(rendered, /\/repo\/foo\.txt\s+\[clean \| main \| fs:unmaterialized/);
+ assert.match(
+ rendered,
+ /\/repo\/foo\.txt\s+\[basis:reading \| head:basis \| worldline:main \| export:pending/,
+ );
});
test("enter dispatches write and wq commands through production save", async () => {
@@ -724,7 +727,7 @@ test("blocked production wq remains open with honest materialization status", as
assert.equal(blockedModel.textAuthority.dirty, true);
assert.equal(blockedModel.textAuthority.materialization, "unmaterialized");
assert.equal(blockedModel.editor.dirty, true);
- assert.match(footer, /dirty \| main \| fs:unmaterialized/);
+ assert.match(footer, /basis:reading \| head:local \| worldline:main \| export:pending/);
});
test("pending production intent queues wq save without arming quit confirmation", async () => {
diff --git a/spec/workspace-footer.spec.mjs b/spec/workspace-footer.spec.mjs
index 6180cecf..6b85f38b 100644
--- a/spec/workspace-footer.spec.mjs
+++ b/spec/workspace-footer.spec.mjs
@@ -159,13 +159,31 @@ test('workspace footer renders command-line hints on the painted secondary row',
assert.equal(rowText(surface, 1).trim(), '[tab accept · enter run · esc cancel]');
});
+test('workspace footer applies theme foreground and background to painted text', async () => {
+ const footer = await loadFooterModule();
+ const token = {
+ fg: '#22272e',
+ fgRGB: [34, 39, 46],
+ bg: '#dedad0',
+ bgRGB: [222, 218, 208],
+ foregroundVariables: [],
+ backgroundVariables: [],
+ };
+ const surface = footer.renderWorkspaceFooter(idleNormalState(), 24, token);
+ const cell = surface.get(0, 0);
+
+ assert.equal(cell.char, 'N');
+ assert.deepEqual(cell.fgRGB, token.fgRGB);
+ assert.deepEqual(cell.bgRGB, token.bgRGB);
+});
+
test('workspace footer pins editor posture to the lower-right corner when it fits', async () => {
const footer = await loadFooterModule();
- const posture = 'clean | main | fs:materialized | target:main | +0/-0';
+ const posture = 'basis:reading | head:basis | worldline:main | export:host | admit:main | tick:t0';
const surface = footer.renderWorkspaceFooter({
...idleNormalState(),
textPosture: posture,
- }, 96, {});
+ }, 132, {});
const secondary = rowText(surface, 1);
assert.equal(secondary.startsWith('/repo/notes/todo.md'), true);
@@ -176,10 +194,10 @@ test('workspace footer pins editor posture to the lower-right corner when it fit
test('workspace footer posture fit uses terminal display width for wide glyphs', async () => {
const footerPosture = await loadFooterPostureModule();
const editorPath = '/repo/界.md';
- const textPosture = 'dirty | main';
+ const textPosture = 'basis:reading | head:local';
const requiredWidth = visibleLength(`${editorPath} [${textPosture}]`);
- assert.equal(requiredWidth, 26);
+ assert.equal(requiredWidth, 40);
assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth - 1), false);
assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth), true);
});
diff --git a/spec/workspace-text-boundaries.spec.mjs b/spec/workspace-text-boundaries.spec.mjs
index 35444a91..f1145810 100644
--- a/spec/workspace-text-boundaries.spec.mjs
+++ b/spec/workspace-text-boundaries.spec.mjs
@@ -333,8 +333,8 @@ test('footer renders production text posture without exposing text authority', a
const footerContext = text.split('\n').at(-1) ?? '';
assert.equal(footerContext.startsWith('/repo/notes.md'), true);
- assert.match(footerContext, /\[clean \| main \| fs:materialized/);
- assert.equal(footerContext.endsWith('target:main | +0/-0]'), true);
+ assert.match(footerContext, /\[basis:reading \| head:basis \| worldline:main \| export:host/);
+ assert.equal(footerContext.endsWith('admit:main | tick:t0]'), true);
});
test('source highlighting consumes reading material after production edit', async () => {
diff --git a/spec/workspace-worldline-ux.spec.mjs b/spec/workspace-worldline-ux.spec.mjs
index 1c535649..e55d32c3 100644
--- a/spec/workspace-worldline-ux.spec.mjs
+++ b/spec/workspace-worldline-ux.spec.mjs
@@ -11,7 +11,8 @@ import { createI18nMock } from "./i18n-mock.mjs";
import { createWorkspaceEchoAppHarness, productionTextObstruction } from "./workspace-echo-app-harness.mjs";
test("ttd commands move the observer without mutating canonical worldline posture", async () => {
- const [keyBindings, titleScreen, editorMode, worldline] = await Promise.all([
+ const [footerPosture, keyBindings, titleScreen, editorMode, worldline] = await Promise.all([
+ importDist("app", "workspace", "workspace-footer-posture.js"),
importDist("app", "workspace", "key-bindings.js"),
importDist("ui", "title-screen.js"),
importDist("app", "workspace", "editor", "mode.js"),
@@ -44,6 +45,10 @@ test("ttd commands move the observer without mutating canonical worldline postur
assert.equal(observing.worldline.posture.kind, "historical");
assert.equal(observing.worldline.posture.observedTick, 2);
assert.equal(worldline.workspaceWorldlinePostureLabel(observing.worldline.posture), "observe:t2");
+ assert.match(
+ footerPosture.workspaceFooterTextPosture(observing),
+ /worldline:observe:t2 \| export:host \| admit:main \| tick:t2/,
+ );
assert.equal(head.worldline.posture.kind, "canonical");
assert.equal(worldline.workspaceWorldlinePostureLabel(head.worldline.posture), "main");
});
@@ -133,7 +138,7 @@ test("workspace footer separates causal posture from filesystem materialization"
importDist("app", "workspace", "worldline-state.js"),
]);
const model = mockTitleScreenModel(titleScreen, {
- columns: 100,
+ columns: 132,
rows: 12,
editor: mockEditor(editorMode, {
dirty: true,
@@ -163,8 +168,11 @@ test("workspace footer separates causal posture from filesystem materialization"
const footerContext = rendered.split("\n").at(-1);
assert.equal(footerContext.startsWith("/repo/notes.md"), true);
- assert.match(footerContext, /\[dirty \| strand:draft \| fs:unmaterialized \| target:main/);
- assert.equal(footerContext.endsWith("+0/-0]"), true);
+ assert.match(
+ footerContext,
+ /\[basis:reading \| head:local \| worldline:strand:draft \| export:pending \| admit:main/,
+ );
+ assert.equal(footerContext.endsWith("tick:t1]"), true);
});
test("worldline drawer shows unconfirmed optimistic braid while Echo edit is in flight", async () => {
@@ -178,9 +186,9 @@ test("worldline drawer shows unconfirmed optimistic braid while Echo edit is in
const rendered = surfaceText(drawers.renderDrawer("history", harness.model, 96, 9));
assert.match(rendered, /projection:\s+canonical@t0 \+ local optimistic \| braid active \| phase:unconfirmed/);
- assert.match(rendered, />\s+settled\s+C\s+main\s+main\s+0\s+\+0\/-0\s+canonical@t0/);
- assert.match(rendered, /unconfirmed\s+L\s+local\s+canonical@t0\s+-\s+\+local\/-0\s+request:2\s+optimistic/);
- assert.match(rendered, /unconfirmed\s+B\s+visible braid\s+main\+local\s+-\s+\+local\/-0\s+canonical@t0\s+active/);
+ assert.match(rendered, />\s+settled\s+C\s+main\s+main\s+0\s+tick:t0\s+canonical@t0/);
+ assert.match(rendered, /unconfirmed\s+L\s+local\s+canonical@t0\s+-\s+local\s+request:2\s+optimistic/);
+ assert.match(rendered, /unconfirmed\s+B\s+visible braid\s+main\+local\s+-\s+local\s+canonical@t0\s+active/);
});
test("worldline drawer keeps conflicted optimistic braid visible after obstruction", async () => {
@@ -197,8 +205,8 @@ test("worldline drawer keeps conflicted optimistic braid visible after obstructi
const rendered = surfaceText(drawers.renderDrawer("history", harness.model, 96, 9));
assert.match(rendered, /phase:conflicted/);
- assert.match(rendered, /conflicted\s+L\s+local\s+canonical@t0\s+-\s+\+local\/-0\s+\/repo\/notes\.md:/);
- assert.match(rendered, /conflicted\s+B\s+visible braid\s+main\+local\s+-\s+\+local\/-0\s+canonical@t0\s+active/);
+ assert.match(rendered, /conflicted\s+L\s+local\s+canonical@t0\s+-\s+local\s+\/repo\/notes\.md:/);
+ assert.match(rendered, /conflicted\s+B\s+visible braid\s+main\+local\s+-\s+local\s+canonical@t0\s+active/);
assert.match(harness.renderText(), /X/);
});
diff --git a/src/app/workspace/workspace-footer-posture.ts b/src/app/workspace/workspace-footer-posture.ts
index 465bd540..25b24d28 100644
--- a/src/app/workspace/workspace-footer-posture.ts
+++ b/src/app/workspace/workspace-footer-posture.ts
@@ -1,5 +1,8 @@
-import { workspaceTextAuthorityPosture } from './workspace-text-authority.js';
-import { WorkspaceTextAuthorityKinds } from './workspace-text-authority.js';
+import {
+ WorkspaceTextAuthorityKinds,
+ type WorkspaceTextAuthority,
+ type WorkspaceTextAuthorityOpened,
+} from './workspace-text-authority.js';
import {
workspaceWorldlineContextLabel,
workspaceWorldlineMaterialization,
@@ -9,9 +12,26 @@ import {
import type { WorkspaceModel } from './model.js';
import { jeditCommandFooterSummary } from './command-provenance.js';
+const BASIS_NONE = 'basis:none';
+const BASIS_PENDING_OPEN = 'basis:pending-open';
+const BASIS_OBSTRUCTED = 'basis:obstructed';
+const BASIS_OPEN = 'basis:open';
+const BASIS_READING = 'basis:reading';
+const BASIS_CHECKPOINT = 'basis:checkpoint';
+const BASIS_EXPORT = 'basis:export';
+const HEAD_NONE = 'head:none';
+const HEAD_PENDING_OPEN = 'head:pending-open';
+const HEAD_OBSTRUCTED = 'head:obstructed';
+const HEAD_BASIS = 'head:basis';
+const HEAD_LOCAL = 'head:local';
+const HEAD_RECEIPT = 'head:receipt';
+const HEAD_CHECKPOINT = 'head:checkpoint';
+const HEAD_EXPORT = 'head:export';
+const HEAD_PREFIX = 'head:';
+
export function workspaceFooterTextPosture(model: WorkspaceModel): string {
return [
- workspaceTextAuthorityPosture(model.textAuthority),
+ workspaceFooterTextAuthorityPosture(model.textAuthority),
workspaceWorldlineContextLabel({
worldline: model.worldline,
materialization: workspaceFooterMaterialization(model),
@@ -36,3 +56,43 @@ function workspaceFooterMaterialization(
? model.textAuthority.materialization
: workspaceWorldlineMaterialization(model.editor?.dirty);
}
+
+function workspaceFooterTextAuthorityPosture(
+ authority: WorkspaceTextAuthority,
+): string {
+ return [
+ workspaceFooterTextBasis(authority),
+ workspaceFooterTextHead(authority),
+ ].join(' | ');
+}
+
+function workspaceFooterTextBasis(authority: WorkspaceTextAuthority): string {
+ if (authority.kind === WorkspaceTextAuthorityKinds.None) return BASIS_NONE;
+ if (authority.kind === WorkspaceTextAuthorityKinds.PendingOpen) return BASIS_PENDING_OPEN;
+ if (authority.kind === WorkspaceTextAuthorityKinds.Obstructed) return BASIS_OBSTRUCTED;
+ return openedTextBasis(authority);
+}
+
+function workspaceFooterTextHead(authority: WorkspaceTextAuthority): string {
+ if (authority.kind === WorkspaceTextAuthorityKinds.None) return HEAD_NONE;
+ if (authority.kind === WorkspaceTextAuthorityKinds.PendingOpen) return HEAD_PENDING_OPEN;
+ if (authority.kind === WorkspaceTextAuthorityKinds.Obstructed) return HEAD_OBSTRUCTED;
+ return openedTextHead(authority);
+}
+
+function openedTextBasis(authority: WorkspaceTextAuthorityOpened): string {
+ if (authority.lastCheckpointId != null) return BASIS_CHECKPOINT;
+ if (authority.lastExportReadingId != null) return BASIS_EXPORT;
+ return authority.cache == null ? BASIS_OPEN : BASIS_READING;
+}
+
+function openedTextHead(authority: WorkspaceTextAuthorityOpened): string {
+ if (authority.pendingIntentStatus != null) {
+ return `${HEAD_PREFIX}${authority.pendingIntentStatus}`;
+ }
+ if (authority.lastCheckpointId != null) return HEAD_CHECKPOINT;
+ if (authority.lastExportReadingId != null) return HEAD_EXPORT;
+ if (authority.lastReceiptId != null) return HEAD_RECEIPT;
+ if (authority.dirty) return HEAD_LOCAL;
+ return HEAD_BASIS;
+}
diff --git a/src/app/workspace/worldline-graph.ts b/src/app/workspace/worldline-graph.ts
index 868bb6ea..e8030394 100644
--- a/src/app/workspace/worldline-graph.ts
+++ b/src/app/workspace/worldline-graph.ts
@@ -4,14 +4,14 @@ import { fitLine } from '../../ui/fit-line.js';
import { clampIndex } from './viewport.js';
import { isWorkspaceDownKey, isWorkspaceUpKey, WorkspaceKeys } from './workspace-key.js';
import {
- MAIN_WORLDLINE_NAME,
WorkspaceWorldlineNodeKinds,
- WorkspaceWorldlinePostureKinds,
+ WorkspaceWorldlineMaterializationKinds,
workspaceWorldlinePostureLabel,
type WorkspaceWorldlineGraphNode,
type WorkspaceWorldlineMaterializationKind,
type WorkspaceWorldlineState,
} from './worldline-types.js';
+import { workspaceWorldlineTickSpanLabel } from './worldline-tick-span.js';
const GRAPH_PAGE_STEP = 10;
const TITLE_KEY = 'worldline.title';
@@ -29,6 +29,11 @@ const HEAD_CELL_WIDTH = 5;
const DELTA_CELL_WIDTH = 9;
const CONFLICT_CELL_WIDTH = 10;
const FIRST_ROW = 0;
+const WORLDLINE_PREFIX = 'worldline:';
+const EXPORT_HOST = 'export:host';
+const EXPORT_PENDING = 'export:pending';
+const EXPORT_NONE = 'export:none';
+const ADMIT_PREFIX = 'admit:';
export interface WorkspaceWorldlineContextState {
readonly worldline: WorkspaceWorldlineState;
@@ -38,12 +43,11 @@ export interface WorkspaceWorldlineContextState {
export function workspaceWorldlineContextLabel(
state: WorkspaceWorldlineContextState,
): string {
- const node = currentWorldlineGraphNode(state.worldline);
return [
- workspaceWorldlinePostureLabel(state.worldline.posture),
- `fs:${state.materialization}`,
- `target:${state.worldline.posture.admissionTarget}`,
- node == null ? undefined : worldlineDeltaLabel(node),
+ `${WORLDLINE_PREFIX}${workspaceWorldlinePostureLabel(state.worldline.posture)}`,
+ workspaceWorldlineExportLabel(state.materialization),
+ `${ADMIT_PREFIX}${state.worldline.posture.admissionTarget}`,
+ workspaceWorldlineTickSpanLabel(state.worldline.posture),
].filter((part): part is string => part != null && part.length > EMPTY_LENGTH).join(' | ');
}
@@ -133,20 +137,21 @@ function graphScrollStart(selectedIndex: number, total: number, visible: number)
return Math.min(Math.max(FIRST_ROW, selectedIndex - half), total - visible);
}
-function currentWorldlineGraphNode(
- worldline: WorkspaceWorldlineState,
-): WorkspaceWorldlineGraphNode | undefined {
- if (worldline.posture.kind === WorkspaceWorldlinePostureKinds.Canonical ||
- worldline.posture.kind === WorkspaceWorldlinePostureKinds.Historical) {
- return worldline.graph.find((node) => node.name === MAIN_WORLDLINE_NAME);
- }
- return worldline.graph.find((node) => node.name === worldline.posture.name);
-}
-
function worldlineDeltaLabel(node: WorkspaceWorldlineGraphNode): string {
return `+${node.ahead}/-${node.behind}`;
}
+function workspaceWorldlineExportLabel(
+ materialization: WorkspaceWorldlineMaterializationKind,
+): string {
+ if (materialization === WorkspaceWorldlineMaterializationKinds.Materialized) {
+ return EXPORT_HOST;
+ }
+ return materialization === WorkspaceWorldlineMaterializationKinds.Unmaterialized
+ ? EXPORT_PENDING
+ : EXPORT_NONE;
+}
+
function braidStatusLabel(node: WorkspaceWorldlineGraphNode): string {
if (node.kind !== WorkspaceWorldlineNodeKinds.Braid) {
return '';
diff --git a/src/app/workspace/worldline-phase-view.ts b/src/app/workspace/worldline-phase-view.ts
index 4cf6d62c..32313685 100644
--- a/src/app/workspace/worldline-phase-view.ts
+++ b/src/app/workspace/worldline-phase-view.ts
@@ -11,6 +11,7 @@ import {
type WorkspaceWorldlineGraphNode,
type WorkspaceWorldlinePhaseKind,
} from './worldline-types.js';
+import { workspaceWorldlineTickSpanLabel } from './worldline-tick-span.js';
const TITLE_KEY = 'worldline.title';
const EMPTY_LENGTH = 0;
@@ -21,7 +22,7 @@ const RAIL_CELL_WIDTH = 1;
const NAME_CELL_WIDTH = 14;
const BASIS_CELL_WIDTH = 14;
const HEAD_CELL_WIDTH = 5;
-const DELTA_CELL_WIDTH = 9;
+const SPAN_CELL_WIDTH = 9;
const EVIDENCE_CELL_WIDTH = 18;
const SELECTED_CELL_WIDTH = 1;
const NO_EVIDENCE = '-';
@@ -35,12 +36,12 @@ const PROJECTION_PREFIX = 'projection:';
const CANONICAL_PREFIX = 'canonical@t';
const LOCAL_NAME = 'local';
const VISIBLE_BRAID_NAME = 'visible braid';
-const LOCAL_DELTA = '+local/-0';
+const LOCAL_SPAN = 'local';
const OPTIMISTIC_NOTE = 'optimistic';
const ACTIVE_NOTE = 'active';
const CONFLICT_NOTE = 'conflict';
const BLOCKED_NOTE = 'blocked';
-const PHASE_HEADER = 's phase r name basis head delta evidence note';
+const PHASE_HEADER = 's phase r name basis head span evidence note';
const SELECTED_MARKER = '>';
const UNSELECTED_MARKER = ' ';
@@ -51,7 +52,7 @@ interface WorldlinePhaseRow {
readonly name: string;
readonly basis: string;
readonly head: string;
- readonly delta: string;
+ readonly span: string;
readonly evidence: string;
readonly note: string;
}
@@ -109,7 +110,7 @@ function graphNodePhaseRow(
name: node.name,
basis: node.basis,
head: node.headTick == null ? NO_HEAD : String(node.headTick),
- delta: worldlineDeltaLabel(node),
+ span: workspaceWorldlineTickSpanLabel(node) ?? NO_EVIDENCE,
evidence: node.headTick == null ? NO_EVIDENCE : `${CANONICAL_PREFIX}${node.headTick}`,
note: graphNodeNote(node),
};
@@ -129,7 +130,7 @@ function localOptimisticRow(model: WorkspaceModel): WorldlinePhaseRow | undefine
name: LOCAL_NAME,
basis: canonicalRef(model),
head: NO_HEAD,
- delta: LOCAL_DELTA,
+ span: LOCAL_SPAN,
evidence: localOptimisticEvidence(model, latest),
note: localOptimisticNote(phase),
};
@@ -146,7 +147,7 @@ function visibleBraidRow(
name: VISIBLE_BRAID_NAME,
basis: `${MAIN_WORLDLINE_NAME}+${LOCAL_NAME}`,
head: NO_HEAD,
- delta: LOCAL_DELTA,
+ span: LOCAL_SPAN,
evidence: canonicalRef(model),
note: ACTIVE_NOTE,
};
@@ -235,10 +236,6 @@ function canonicalRef(model: WorkspaceModel): string {
return `${CANONICAL_PREFIX}${model.worldline.canonicalHeadTick}`;
}
-function worldlineDeltaLabel(node: WorkspaceWorldlineGraphNode): string {
- return `+${node.ahead}/-${node.behind}`;
-}
-
function renderPhaseRow(row: WorldlinePhaseRow): string {
return [
cell(row.selected ? SELECTED_MARKER : UNSELECTED_MARKER, SELECTED_CELL_WIDTH),
@@ -247,7 +244,7 @@ function renderPhaseRow(row: WorldlinePhaseRow): string {
cell(row.name, NAME_CELL_WIDTH),
cell(row.basis, BASIS_CELL_WIDTH),
cell(row.head, HEAD_CELL_WIDTH),
- cell(row.delta, DELTA_CELL_WIDTH),
+ cell(row.span, SPAN_CELL_WIDTH),
cell(row.evidence, EVIDENCE_CELL_WIDTH),
row.note,
].filter((part) => part.length > EMPTY_LENGTH).join(' ');
diff --git a/src/app/workspace/worldline-tick-span.ts b/src/app/workspace/worldline-tick-span.ts
new file mode 100644
index 00000000..a0c362af
--- /dev/null
+++ b/src/app/workspace/worldline-tick-span.ts
@@ -0,0 +1,26 @@
+const TICK_PREFIX = 'tick:t';
+const TICKS_PREFIX = 'ticks:t';
+const TICK_SPAN_SEPARATOR = '->t';
+
+export interface WorkspaceWorldlineTickSpan {
+ readonly basisTick?: number;
+ readonly observedTick?: number;
+ readonly headTick?: number;
+}
+
+export function workspaceWorldlineTickSpanLabel(
+ span: WorkspaceWorldlineTickSpan,
+): string | undefined {
+ const basisTick = span.observedTick ?? span.basisTick;
+ const headTick = span.observedTick ?? span.headTick;
+ if (basisTick == null && headTick == null) {
+ return undefined;
+ }
+ if (basisTick == null) {
+ return `${TICK_PREFIX}${headTick}`;
+ }
+ if (headTick == null || basisTick === headTick) {
+ return `${TICK_PREFIX}${basisTick}`;
+ }
+ return `${TICKS_PREFIX}${basisTick}${TICK_SPAN_SEPARATOR}${headTick}`;
+}
diff --git a/src/ui/jedit-theme-palettes.ts b/src/ui/jedit-theme-palettes.ts
index ac37c85f..113fab5d 100644
--- a/src/ui/jedit-theme-palettes.ts
+++ b/src/ui/jedit-theme-palettes.ts
@@ -103,12 +103,12 @@ const SOLARIZED_DARK_PALETTE: ThemePalette = {
};
const SOLARIZED_LIGHT_PALETTE: ThemePalette = {
- ink: [101, 123, 131],
- muted: [147, 161, 161],
+ ink: [79, 98, 104],
+ muted: [88, 110, 117],
accent: [211, 54, 130],
info: [38, 139, 210],
- warning: [181, 137, 0],
- success: [133, 153, 0],
+ warning: [165, 112, 0],
+ success: [112, 137, 0],
surface: [253, 246, 227],
surfaceRaised: [238, 232, 213],
surfaceMuted: [238, 232, 213],
diff --git a/src/ui/jedit-themes.ts b/src/ui/jedit-themes.ts
index 583dd080..91445ef1 100644
--- a/src/ui/jedit-themes.ts
+++ b/src/ui/jedit-themes.ts
@@ -35,6 +35,20 @@ const ACTIVE_EDGE_CHAR = "░";
const THEME_MODE_LABEL_DARK = "Dark";
const THEME_MODE_LABEL_LIGHT = "Light";
const COLOR_CHANNEL_MAX = 255;
+const COLOR_CHANNEL_MIN = 0;
+const CONTRAST_BLEND_STEPS = 20;
+const CONTRAST_LUMINANCE_OFFSET = 0.05;
+const CONTRAST_TARGET_LIGHTNESS = 0.5;
+const LUMINANCE_BLUE_WEIGHT = 0.0722;
+const LUMINANCE_GREEN_WEIGHT = 0.7152;
+const LUMINANCE_RED_WEIGHT = 0.2126;
+const MIN_ACCENT_CONTRAST_RATIO = 3;
+const MIN_SURFACE_TEXT_CONTRAST_RATIO = 4.5;
+const SRGB_LINEAR_BREAKPOINT = 0.03928;
+const SRGB_LINEAR_DIVISOR = 12.92;
+const SRGB_LINEAR_EXPONENT = 2.4;
+const SRGB_LINEAR_OFFSET = 0.055;
+const SRGB_LINEAR_SCALE = 1.055;
interface ThemeVariables {
readonly ink: ThemeColorVariable;
@@ -223,7 +237,7 @@ function oppositeThemeMode(mode: JeditThemeMode): JeditThemeMode {
}
function oppositePalette(palette: ThemePalette): ThemePalette {
- return {
+ return contrastAdjustedPalette({
ink: invertColor(palette.ink),
muted: invertColor(palette.muted),
accent: invertColor(palette.accent),
@@ -233,9 +247,98 @@ function oppositePalette(palette: ThemePalette): ThemePalette {
surface: invertColor(palette.surface),
surfaceRaised: invertColor(palette.surfaceRaised),
surfaceMuted: invertColor(palette.surfaceMuted),
+ });
+}
+
+function contrastAdjustedPalette(palette: ThemePalette): ThemePalette {
+ return {
+ ink: contrastAdjustedColor(palette.ink, surfaceBackgrounds(palette), MIN_SURFACE_TEXT_CONTRAST_RATIO),
+ muted: contrastAdjustedColor(palette.muted, [palette.surface], MIN_ACCENT_CONTRAST_RATIO),
+ accent: contrastAdjustedColor(palette.accent, [palette.surface], MIN_ACCENT_CONTRAST_RATIO),
+ info: contrastAdjustedColor(palette.info, [palette.surface, palette.surfaceRaised], MIN_ACCENT_CONTRAST_RATIO),
+ warning: contrastAdjustedColor(palette.warning, [palette.surface, palette.surfaceRaised], MIN_ACCENT_CONTRAST_RATIO),
+ success: contrastAdjustedColor(palette.success, [palette.surface], MIN_ACCENT_CONTRAST_RATIO),
+ surface: palette.surface,
+ surfaceRaised: palette.surfaceRaised,
+ surfaceMuted: palette.surfaceMuted,
};
}
+function surfaceBackgrounds(palette: ThemePalette): readonly RgbTuple[] {
+ return [palette.surface, palette.surfaceRaised, palette.surfaceMuted];
+}
+
+function contrastAdjustedColor(
+ color: RgbTuple,
+ backgrounds: readonly RgbTuple[],
+ minContrastRatio: number,
+): RgbTuple {
+ const target = averageLuminance(backgrounds) > CONTRAST_TARGET_LIGHTNESS
+ ? colorTarget(COLOR_CHANNEL_MIN)
+ : colorTarget(COLOR_CHANNEL_MAX);
+ for (let step = 0; step <= CONTRAST_BLEND_STEPS; step += 1) {
+ const candidate = blendColor(color, target, step / CONTRAST_BLEND_STEPS);
+ if (passesContrast(candidate, backgrounds, minContrastRatio)) {
+ return candidate;
+ }
+ }
+ return target;
+}
+
+function averageLuminance(colors: readonly RgbTuple[]): number {
+ const total = colors.reduce((sum, color) => sum + relativeColorLuminance(color), 0);
+ return total / colors.length;
+}
+
+function colorTarget(channel: number): RgbTuple {
+ return [channel, channel, channel];
+}
+
+function blendColor(from: RgbTuple, to: RgbTuple, amount: number): RgbTuple {
+ return [
+ blendChannel(from[0], to[0], amount),
+ blendChannel(from[1], to[1], amount),
+ blendChannel(from[2], to[2], amount),
+ ];
+}
+
+function blendChannel(from: number, to: number, amount: number): number {
+ return Math.round(from + ((to - from) * amount));
+}
+
+function passesContrast(
+ color: RgbTuple,
+ backgrounds: readonly RgbTuple[],
+ minContrastRatio: number,
+): boolean {
+ return backgrounds.every(
+ (background) => colorContrastRatio(color, background) >= minContrastRatio,
+ );
+}
+
+function colorContrastRatio(first: RgbTuple, second: RgbTuple): number {
+ const firstLuminance = relativeColorLuminance(first);
+ const secondLuminance = relativeColorLuminance(second);
+ const lighter = Math.max(firstLuminance, secondLuminance);
+ const darker = Math.min(firstLuminance, secondLuminance);
+ return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET);
+}
+
+function relativeColorLuminance(color: RgbTuple): number {
+ return (
+ linearChannel(color[0]) * LUMINANCE_RED_WEIGHT +
+ linearChannel(color[1]) * LUMINANCE_GREEN_WEIGHT +
+ linearChannel(color[2]) * LUMINANCE_BLUE_WEIGHT
+ );
+}
+
+function linearChannel(channel: number): number {
+ const scaled = channel / COLOR_CHANNEL_MAX;
+ return scaled <= SRGB_LINEAR_BREAKPOINT
+ ? scaled / SRGB_LINEAR_DIVISOR
+ : ((scaled + SRGB_LINEAR_OFFSET) / SRGB_LINEAR_SCALE) ** SRGB_LINEAR_EXPONENT;
+}
+
function paletteFromTheme(theme: JeditTheme): ThemePalette {
return {
ink: variableRgb(theme, VARIABLE_INK, [226, 231, 236]),
diff --git a/src/ui/source-viewer.ts b/src/ui/source-viewer.ts
index cef662b3..511779c8 100644
--- a/src/ui/source-viewer.ts
+++ b/src/ui/source-viewer.ts
@@ -20,6 +20,7 @@ const DEFAULT_LINE_NUMBER_MODE = SOURCE_LINE_NUMBER_MODE.Absolute;
const CURRENT_LINE_RELATIVE_NUMBER = 0;
export type SourceViewerMode = typeof NORMAL_MODE | typeof INSERT_MODE;
+type SourceViewerCellStyle = Pick;
export interface SourceViewerEditor {
readonly lines: readonly string[];
@@ -94,7 +95,7 @@ function paintSourceViewerCursor(
surface.set(cursorX, cursorY, {
...cell,
char: cell.char.length > 0 ? cell.char : cursorFallbackChar(editor.mode),
- ...cellStyle(token),
+ ...cellStyle(token, options.theme.surface.workspace),
empty: false,
});
}
@@ -157,6 +158,10 @@ function paintSourceViewerGutter(
options.theme.source.get(JEDIT_SOURCE_TOKEN.Comment) ??
options.theme.chrome.titleLogoShadow;
const ruleToken = options.theme.chrome.activeEdge;
+ const baseToken = options.theme.surface.workspace;
+ const numberStyle = cellStyle(numberToken, baseToken);
+ const ruleStyle = cellStyle(ruleToken, baseToken);
+ const gapStyle = cellStyle(baseToken, baseToken);
const ruleX = options.leftPad + gutter.numberWidth + GUTTER_CONTENT_GAP - 1;
for (let row = 0; row < options.viewport.height; row += 1) {
const sourceLine = reading.lines[row];
@@ -167,9 +172,9 @@ function paintSourceViewerGutter(
gutter.numberWidth,
' ',
);
- paintGutterText(surface, label, options.leftPad, y, numberToken);
- paintGutterCell(surface, ruleX, y, GUTTER_RULE, ruleToken);
- paintGutterRuleGap(surface, ruleX, y, options.theme.surface.workspace);
+ paintGutterText(surface, label, options.leftPad, y, numberStyle);
+ paintGutterCell(surface, ruleX, y, GUTTER_RULE, ruleStyle);
+ paintGutterRuleGap(surface, ruleX, y, gapStyle);
}
}
@@ -213,10 +218,10 @@ function paintGutterRuleGap(
surface: Surface,
ruleX: number,
y: number,
- token: JeditStyleToken,
+ style: SourceViewerCellStyle,
): void {
for (let gap = 0; gap < GUTTER_RULE_GAP; gap += 1) {
- paintGutterCell(surface, ruleX + gap + 1, y, ' ', token);
+ paintGutterCell(surface, ruleX + gap + 1, y, ' ', style);
}
}
@@ -225,10 +230,10 @@ function paintGutterText(
text: string,
x: number,
y: number,
- token: JeditStyleToken,
+ style: SourceViewerCellStyle,
): void {
for (let index = 0; index < text.length; index += 1) {
- paintGutterCell(surface, x + index, y, text[index] ?? ' ', token);
+ paintGutterCell(surface, x + index, y, text[index] ?? ' ', style);
}
}
@@ -237,7 +242,7 @@ function paintGutterCell(
x: number,
y: number,
char: string,
- token: JeditStyleToken,
+ style: SourceViewerCellStyle,
): void {
if (x < 0 || y < 0 || x >= surface.width || y >= surface.height) {
return;
@@ -246,7 +251,7 @@ function paintGutterCell(
surface.set(x, y, {
...cell,
char,
- ...cellStyle(token),
+ ...style,
empty: false,
});
}
@@ -270,12 +275,16 @@ function cursorFallbackChar(mode: SourceViewerMode): string {
return mode === NORMAL_MODE ? ' ' : '│';
}
-function cellStyle(token: JeditStyleToken): Pick| {
+function cellStyle(token: JeditStyleToken, baseToken: JeditStyleToken): SourceViewerCellStyle {
return {
- fg: token.fg,
- bg: token.bg,
- fgRGB: token.fgRGB,
- bgRGB: token.bgRGB,
- modifiers: token.modifiers == null ? undefined : [...token.modifiers],
+ fg: token.fg ?? baseToken.fg,
+ bg: token.bg ?? baseToken.bg,
+ fgRGB: token.fgRGB ?? baseToken.fgRGB,
+ bgRGB: token.bgRGB ?? baseToken.bgRGB,
+ modifiers: token.modifiers == null ? copiedModifiers(baseToken) : [...token.modifiers],
};
}
+
+function copiedModifiers(token: JeditStyleToken): string[] | undefined {
+ return token.modifiers == null ? undefined : [...token.modifiers];
+}
diff --git a/src/ui/workspace-footer-surface-utils.ts b/src/ui/workspace-footer-surface-utils.ts
index 7317e76b..3831cb59 100644
--- a/src/ui/workspace-footer-surface-utils.ts
+++ b/src/ui/workspace-footer-surface-utils.ts
@@ -8,6 +8,8 @@ export { fitLine } from './fit-line.js';
export function fillSurface(surface: Surface, token: JeditStyleToken): void {
surface.fill({
char: SURFACE_FILL_CHAR,
+ fg: token.fg,
+ fgRGB: token.fgRGB,
bg: token.bg,
bgRGB: token.bgRGB,
empty: false,
@@ -21,6 +23,8 @@ export function applyBackground(surface: Surface, token: JeditStyleToken): void
surface.set(x, y, {
...cell,
char: cell.char.length > 0 ? cell.char : SURFACE_FILL_CHAR,
+ fg: token.fg,
+ fgRGB: token.fgRGB,
bg: token.bg,
bgRGB: token.bgRGB,
empty: false,
| |