From 14a5de26e0826fbb6f8185cffc672cb874949b56 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:24:40 +0530 Subject: [PATCH 1/3] Shaders: NMSh's own cursor, typing and welcome effects for Ghostty and Kitty Eight original effects (Ink Bloom, Type Garden, Filament, Glitch Hop, Gravity Lens, Holo Foil, Shine Sweep, Halo Bloom) generated for both hosts from one GLSL-subset source. Each has at most three friendly choices. Ghostty gets generated passes; Kitty gets NMSh-written .slang files beside NMSh's pipeline, recorded in the ownership ledger and removed exactly. Typing effects are the one cursor effect and start Kitty's trail on one-cell moves. --- src/app/TerminalApp.ts | 2 +- src/appearance/ghosttyShaderCatalog.ts | 11 +- src/appearance/ghosttyShaders.ts | 2 +- src/appearance/kittyShaderCatalog.ts | 35 +- src/appearance/kittyShaders.ts | 79 +++- src/appearance/nmshShaders.ts | 494 +++++++++++++++++++++++++ tests/ghosttyShaders.test.ts | 48 ++- tests/ghosttyShadersLive.test.ts | 4 +- tests/kittyShaders.test.ts | 72 ++++ tests/kittyShadersLive.test.ts | 5 +- 10 files changed, 726 insertions(+), 26 deletions(-) create mode 100644 src/appearance/nmshShaders.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index fb6ee216..027d7901 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -4040,7 +4040,7 @@ export class TerminalApp { * would remove it again (Portable). Returns the configuration the review is planned against and the /cursor changes it implies. */ private kittyTrailFollowUp(draft: KittyShaderSettings, catalog: KittyCatalog, config: PromptConfiguration = this.promptConfiguration): {config: PromptConfiguration; changes: string[]} { - const trail = draft.effects.some(effect => effect.enabled && catalog.offered.some(item => item.definition.id === effect.id && item.definition.category === 'cursor')); + const trail = draft.effects.some(effect => effect.enabled && catalog.offered.some(item => item.definition.id === effect.id && (item.definition.category === 'cursor' || item.definition.category === 'typing'))); // Under Reduced Motion or Effects Off the pipeline has no trail, so /cursor is left alone. const gates = this.shaderGates(config); if (!trail || gates.reducedMotion || gates.effectsOff || (config.cursor.motion !== 'off' && config.cursor.renderer !== 'portable')) return {config, changes: []}; diff --git a/src/appearance/ghosttyShaderCatalog.ts b/src/appearance/ghosttyShaderCatalog.ts index 0edca715..b534c2f5 100644 --- a/src/appearance/ghosttyShaderCatalog.ts +++ b/src/appearance/ghosttyShaderCatalog.ts @@ -16,6 +16,7 @@ */ import {createHash} from 'node:crypto'; import type {OfferedParam, PresetCategory, PresetParam, ShaderValue} from './kittyShaderCatalog.js'; +import {NMSH_EFFECTS, nmshGhosttyBody, type NmshEffectId} from './nmshShaders.js'; export type GhosttyVersion = readonly [number, number, number]; @@ -120,6 +121,8 @@ export interface GenerationContext { cursorColors: {head: readonly number[]; tail: readonly number[]}; /** The Chroma palette's ends (0–1 RGB; the theme's colors while Chroma is off), for effects that follow Chroma. */ chroma: {a: readonly number[]; b: readonly number[]}; + /** Where Ghostty runs (its fragment y axis points down under Metal on macOS); defaults to this process's platform. */ + platform?: NodeJS.Platform; } /** Ambient colors: follow Chroma (rewritten when the Chroma palette or theme changes; Ghostty applies it on reload) or the studio's own. */ @@ -158,7 +161,11 @@ export const GHOSTTY_PRESETS: readonly GhosttyPresetDefinition[] = [ {id: 'glow', label: 'Text glow', category: 'display', animated: false, params: [n('INTENSITY', 'Intensity', 0, 1.5, 0.05), n('RADIUS', 'Radius (px)', 1, 6, 0.5)], defaults: {INTENSITY: 0.45, RADIUS: 2}, - note: 'A soft bloom around bright text. Samples 16 neighbours per pixel: the most expensive NMSh effect.'}, + note: 'A soft bloom around bright text. Samples 16 neighbours per pixel.'}, + // NMSh's own effects (shared with Kitty, see nmshShaders.ts). Cursor & typing effects are the one cursor effect. + ...NMSH_EFFECTS.map((effect): GhosttyPresetDefinition => ({id: effect.id, label: effect.label, category: effect.category, params: effect.params, defaults: effect.defaults, + animated: effect.animated, ...(effect.category === 'typing' ? {exclusive: 'cursor', trail: true as const} : {}), + ...(effect.id === 'halo-bloom' ? {note: 'Samples 48 neighbours per pixel: the most expensive NMSh effect.'} : {})})), ]; export const GHOSTTY_MAX_EFFECTS = 6; @@ -213,7 +220,9 @@ function trailColors(values: Record, context: GenerationCon return `vec3 headColor() { return ${vec3(head)}; }\nvec3 tailColor() { return ${vec3(tail)}; }`; } +const nmshBody = (id: NmshEffectId) => (_values: Record, context: GenerationContext) => nmshGhosttyBody(id, context.platform ?? process.platform); const BODIES: Record, context: GenerationContext) => string> = { + ...Object.fromEntries(NMSH_EFFECTS.map(effect => [effect.id, nmshBody(effect.id)])), smear: (values, context) => `${TRAIL_HELPERS} ${trailColors(values, context)} void mainImage(out vec4 fragColor, in vec2 fragCoord) { diff --git a/src/appearance/ghosttyShaders.ts b/src/appearance/ghosttyShaders.ts index 242fb32f..fd747c96 100644 --- a/src/appearance/ghosttyShaders.ts +++ b/src/appearance/ghosttyShaders.ts @@ -306,7 +306,7 @@ function compose(settings: GhosttyShaderSettings, context: GhosttyShaderContext, if (unknown) throw new Error(`NMSh cannot read your shader ${unknown.path} (${unknown.reason}), so it cannot rule out a second cursor trail.`); } if (inspection.entries.some(entry => entry.path.startsWith(`${paths.shaderDirectory}/`))) throw new Error('Your configuration already names a file in NMSh’s Ghostty shader folder; remove that line first.'); - const shaders = active.map(({preset, effect}) => ({id: preset.definition.id, path: passPath(paths, preset.definition.id), source: generateGhosttyShader(preset.definition, effect.values, context.colors).source})); + const shaders = active.map(({preset, effect}) => ({id: preset.definition.id, path: passPath(paths, preset.definition.id), source: generateGhosttyShader(preset.definition, effect.values, {...context.colors, platform: context.platform}).source})); for (const shader of shaders) if (!state.passes.has(shader.id)) { const file = snapshot(shader.path, paths.home, true); if (file.content !== undefined) throw new Error(`${shader.path} exists but is not recorded as NMSh’s; it was preserved.`); diff --git a/src/appearance/kittyShaderCatalog.ts b/src/appearance/kittyShaderCatalog.ts index 69658327..aec3fc79 100644 --- a/src/appearance/kittyShaderCatalog.ts +++ b/src/appearance/kittyShaderCatalog.ts @@ -13,6 +13,7 @@ import {spawn} from 'node:child_process'; import {createHash} from 'node:crypto'; import {existsSync} from 'node:fs'; import {delimiter, join} from 'node:path'; +import {kittyShaderName, NMSH_EFFECTS, nmshKittyGroups, nmshKittySource, type NmshEffectId} from './nmshShaders.js'; export const KITTY_SHADER_MIN_VERSION = [0, 49] as const; /** Kitty's MAX_CUSTOM_SHADER_GROUPS: the limit for one pipeline and for all chained pipelines together. */ @@ -233,8 +234,11 @@ export function formatShaderValue(type: string, value: ShaderValue): string { // ---- Curated presets --------------------------------------------------------------------------------------------- -export type PresetCategory = 'cursor' | 'ambient' | 'interaction' | 'focus' | 'display'; -export const CATEGORY_LABELS: Record = {cursor: 'Cursor trail', ambient: 'Ambient', interaction: 'Pointer', focus: 'Focus & tabs', display: 'Display'}; +export type PresetCategory = 'cursor' | 'typing' | 'welcome' | 'ambient' | 'interaction' | 'focus' | 'display'; +export const CATEGORY_LABELS: Record = {cursor: 'Cursor trail', typing: 'Cursor & typing', welcome: 'Welcome & images', ambient: 'Ambient', + interaction: 'Pointer', focus: 'Focus & tabs', display: 'Display'}; +/** Categories that draw at the cursor: one at a time, and only while /cursor wants a native trail (never two trails). */ +export const drawsCursor = (category: PresetCategory) => category === 'cursor' || category === 'typing'; export interface PresetParam { name: string; label: string; kind: 'number' | 'bool' | 'color' | 'choice'; /** Kitty's declared type this control expects; a different declaration means the parameter is not offered. */ @@ -244,8 +248,8 @@ export interface PresetParam { } export interface PresetDefinition { id: string; label: string; category: PresetCategory; - /** A shipped pipeline (its group structure is reused) or a shipped shader with Kitty's default pipeline. */ - source: {pipeline: string} | {shader: string}; + /** A shipped pipeline (its group structure is reused), a shipped shader with Kitty's default pipeline, or NMSh's own shader (written by NMSh). */ + source: {pipeline: string} | {shader: string} | {nmsh: NmshEffectId}; params: readonly PresetParam[]; /** Presets sharing a key cannot be enabled together. */ exclusive?: string; @@ -308,8 +312,17 @@ export const KITTY_PRESETS: readonly PresetDefinition[] = [ note: 'Light rays from above. Kitty’s settings for it are internal, so NMSh offers it as is. Runs every frame.'}, {id: 'negative', label: 'Negative', category: 'display', source: {shader: 'negative'}, params: [], note: 'Inverts every color in the window.'}, + // NMSh's own effects: NMSh writes their .slang next to its pipeline (Kitty looks there first). + ...NMSH_EFFECTS.map((effect): PresetDefinition => ({id: effect.id, label: effect.label, category: effect.category, source: {nmsh: effect.id}, params: effect.params, + ...(effect.category === 'typing' ? {exclusive: 'cursor', requires: 'cursor_trail' as const, + note: 'Runs while Kitty’s cursor trail moves; NMSh starts it on one-cell moves too, so typing shows it.'} : {}), + ...(effect.id === 'shine-sweep' ? {note: 'On focus only starts when the window gains focus; the timed cadences redraw continuously.'} : {})})), ]; +/** The constants each NMSh Kitty shader declares (what its pipeline vars may replace). */ +const NMSH_CONSTANTS = new Map(NMSH_EFFECTS.map(effect => [kittyShaderName(effect.id), parseShaderConstants(nmshKittySource(effect.id))])); +export const nmshShaderConstants = (name: string) => NMSH_CONSTANTS.get(name); + // ---- Installed catalog ------------------------------------------------------------------------------------------- export interface CatalogShader {name: string; sha256: string; constants: Map} @@ -376,6 +389,20 @@ export function buildKittyCatalog(version: string, resources: Readonly effect.id === id)!.defaults; + const params: OfferedParam[] = []; + const missing: string[] = []; + for (const param of definition.params) { + if (constants.get(param.name)?.type === param.type && defaults[param.name] !== undefined) params.push({...param, default: defaults[param.name]!}); + else missing.push(param.name); + } + return {definition, groups: nmshKittyGroups(id, defaults), textures: [], params, missing, + origin: {kittyVersion: catalog.version, resources: [{name: `${name}.slang`, sha256: sha(nmshKittySource(id))}]}}; + } const source = 'pipeline' in definition.source ? catalog.pipelines.get(definition.source.pipeline) : undefined; if ('pipeline' in definition.source && !source) return 'not installed in this Kitty'; const pipeline = source?.pipeline ?? defaultPipeline((definition.source as {shader: string}).shader); diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index 53e192e8..df71a0d3 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -22,9 +22,10 @@ import {readHostConfiguration} from './hostConfiguration.js'; import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './hostFiles.js'; import {withoutHook} from './hostAppearance.js'; import {readProcessTable, type ProcessRow} from './ghosttyShaders.js'; -import {CATEGORY_LABELS, defaultPipeline, emitKittyPipeline, formatShaderValue, KITTY_MAX_GROUPS, mergeVars, parseKittyPipeline, pipelineTextures, +import {CATEGORY_LABELS, defaultPipeline, drawsCursor, emitKittyPipeline, formatShaderValue, KITTY_MAX_GROUPS, mergeVars, nmshShaderConstants, parseKittyPipeline, pipelineTextures, subscribesToCursorTrail, type KittyCatalog, type KittyGroup, type KittyPipeline, type KittyVar, type OfferedParam, type OfferedPreset, type ShaderValue} from './kittyShaderCatalog.js'; import type {CursorMotion, CursorRenderer} from '../prompt/configuration.js'; +import {kittyShaderName, NMSH_KITTY_CURSOR_SHADERS, NMSH_KITTY_SHADERS, nmshKittyGroups, nmshKittySource} from './nmshShaders.js'; // ---- Settings ---------------------------------------------------------------------------------------------------- @@ -39,13 +40,15 @@ export interface ShaderGates {reducedMotion: boolean; effectsOff: boolean; curso * or /cursor's own file with Motion off), NMSh's fragment sets this value, the one /cursor's Kitty file uses for a trail. */ export const KITTY_TRAIL_ON = 3; +/** Kitty trail settings NMSh's fragment adds while a cursor & typing effect is written (see compose). */ +export const TYPING_TRAIL: ReadonlyArray = [['cursor_trail_start_threshold', '0'], ['cursor_trail_decay', '6 12']]; /** What NMSh's pipeline may contain right now: nothing under Reduced Motion or Effects Off, no Kitty trail when the cursor renderer is Portable or Motion is Off. */ export function gatedEffects(settings: KittyShaderSettings, catalog: KittyCatalog, gates: ShaderGates): Array<{preset: OfferedPreset; effect: KittyShaderEffect}> { if (gates.reducedMotion || gates.effectsOff) return []; return settings.effects.filter(effect => effect.enabled).flatMap(effect => { const preset = catalog.offered.find(item => item.definition.id === effect.id); - if (!preset || (preset.definition.category === 'cursor' && (gates.cursorRenderer === 'portable' || gates.cursorMotion === 'off'))) return []; + if (!preset || (drawsCursor(preset.definition.category) && (gates.cursorRenderer === 'portable' || gates.cursorMotion === 'off'))) return []; return [{preset, effect}]; }); } @@ -130,8 +133,13 @@ interface ShaderLedger { placement: 'after' | 'before'; kittyVersion: string; presets: Array<{id: string; resources: Array<{name: string; sha256: string}>}>; + /** NMSh's own .slang files written next to its pipeline (absent in records from before NMSh wrote shaders). */ + shaders?: Array<{name: string; path: string; sha256: string}>; } +/** Where NMSh writes one of its own Kitty shaders: beside its pipeline, where Kitty looks first. */ +const nmshShaderPath = (paths: ReturnType, name: string) => join(paths.directory, `${name}.slang`); + /** Strict by default; `lenient` (removal only) reports damaged owned files instead of refusing, so a broken integration can still be removed. */ function owned(context: AppearanceContext, lenient = false) { const paths = kittyShaderPaths(context); @@ -145,13 +153,22 @@ function owned(context: AppearanceContext, lenient = false) { const hash = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value); if (parsed?.version !== 1 || parsed.feature !== 'kitty-shaders' || typeof parsed.created !== 'boolean' || !paths.candidates.includes(parsed.hostPath ?? '') || typeof parsed.hook !== 'string' || !hash(parsed.fragmentHash) || !hash(parsed.pipelineHash) || typeof parsed.preserved !== 'string' - || (parsed.placement !== 'after' && parsed.placement !== 'before') || !Array.isArray(parsed.presets)) throw new Error('The Kitty shader ownership record is invalid.'); + || (parsed.placement !== 'after' && parsed.placement !== 'before') || !Array.isArray(parsed.presets) + || (parsed.shaders !== undefined && (!Array.isArray(parsed.shaders) || !parsed.shaders.every(item => item && NMSH_KITTY_SHADERS.has(item.name) + && item.path === nmshShaderPath(paths, item.name) && hash(item.sha256))))) throw new Error('The Kitty shader ownership record is invalid.'); record = parsed as ShaderLedger; const newline = record.hook.includes('\r\n') ? '\r\n' : '\n'; if (record.hook.replace(/\r\n/gu, '\n').trim() !== `${HOOK_COMMENT}\ninclude ${paths.fragment}` || !record.hook.endsWith(`include ${paths.fragment}${newline}`)) throw new Error('The recorded include does not match NMSh’s own Kitty shader hook.'); } - const damaged = new Map<'fragment' | 'pipeline', 'edited' | 'missing'>(); - for (const [file, expected, label] of [[fragment, record?.fragmentHash, 'fragment'], [pipeline, record?.pipelineHash, 'pipeline']] as const) { + const damaged = new Map(); + const shaderFiles = new Map(); + const files: Array = [[fragment, record?.fragmentHash, 'fragment'], [pipeline, record?.pipelineHash, 'pipeline']]; + for (const item of record?.shaders ?? []) { + const file = snapshot(item.path, paths.home, true); + shaderFiles.set(item.name, file); + files.push([file, item.sha256, `${item.name}.slang`]); + } + for (const [file, expected, label] of files) { if (file.content !== undefined && (!record || digest(file.content) !== expected)) { if (lenient && record) { damaged.set(label, 'edited'); continue; } throw new Error(`The Kitty shader ${label} is unowned or was edited outside NMSh; it was preserved.`); @@ -161,7 +178,7 @@ function owned(context: AppearanceContext, lenient = false) { throw new Error(`The recorded Kitty shader ${label} is missing; R in /appearance › Kitty shaders removes the integration.`); } } - return {paths, fragment, pipeline, ledger, record, damaged}; + return {paths, fragment, pipeline, ledger, record, damaged, shaderFiles}; } // ---- Reading the person's shaders ---------------------------------------------------------------------------------- @@ -339,8 +356,11 @@ export function buildNmshPipeline(active: ReadonlyArray<{preset: OfferedPreset; if (textures.includes(texture)) throw new Error(`${preset.definition.label} needs Kitty's shared texture “${texture}”, which another NMSh effect already uses.`); textures.push(texture); } - for (const group of preset.groups) { - const constants = group.shaders.map(name => catalog.shaders.get(name)?.constants); + const own = 'nmsh' in preset.definition.source ? preset.definition.source.nmsh : undefined; + // NMSh's own effects choose their groups from the values (Shine Sweep's cadence decides when it runs). + const presetGroups = own ? nmshKittyGroups(own, Object.fromEntries(preset.params.map(param => [param.name, effect.values[param.name] ?? param.default]))) : preset.groups; + for (const group of presetGroups) { + const constants = group.shaders.map(name => catalog.shaders.get(name)?.constants ?? nmshShaderConstants(name)); const declared = (name: string, type: string) => constants.find(map => map?.get(name)?.type === type)?.get(name); const pinned: KittyVar[] = leaked.flatMap(variable => { const constant = declared(variable.name, variable.type); @@ -392,7 +412,9 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod for (const {preset} of active) { const texture = preset.textures.find(name => userTextures.has(name)); if (texture) throw new Error(`${preset.definition.label} needs Kitty's shared texture “${texture}”, which one of your shaders already uses.`); - if (preset.definition.category === 'cursor' && userGroups.some(subscribesToCursorTrail)) throw new Error(`Your custom_shaders already draws a cursor trail; NMSh will not add a second one (${preset.definition.label}).`); + if (drawsCursor(preset.definition.category) && userGroups.some(subscribesToCursorTrail)) throw new Error(`Your custom_shaders already draws a cursor trail; NMSh will not add a second one (${preset.definition.label}).`); + // NMSh's own shaders are written beside its pipeline (checked as owned files below), so nothing of Kitty's is replaced. + if ('nmsh' in preset.definition.source) continue; for (const shader of new Set(preset.groups.flatMap(group => group.shaders))) { // Kitty looks in NMSh's pipeline folder, then the kitty config folder, before its shipped shaders. for (const candidate of [join(paths.directory, `${shader}.slang`), join(inspection.configDirectory, `${shader}.slang`)]) { @@ -404,6 +426,17 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod if (preset.definition.requires === 'opaque' && inspection.backgroundOpacity < 1) notes.push(`${preset.definition.label} does not work with background_opacity ${inspection.backgroundOpacity} (Kitty's own note).`); } const pipeline = buildNmshPipeline(active, catalog, leaked); + // NMSh's own shader files: written when used, removed when no longer used; a same-named file NMSh did not record is never touched. + const ownShaders = [...new Set(active.flatMap(({preset}) => 'nmsh' in preset.definition.source ? [preset.definition.source.nmsh] : []))] + .map(id => ({name: kittyShaderName(id), path: nmshShaderPath(paths, kittyShaderName(id)), source: nmshKittySource(id)})); + const shaderChanges: FileChange[] = []; + for (const shader of ownShaders) { + const before = state.shaderFiles.get(shader.name) ?? snapshot(shader.path, paths.home, true); + if (!state.shaderFiles.has(shader.name) && before.content !== undefined) throw new Error(`${shader.path} exists but is not recorded as NMSh’s; it was preserved.`); + if (before.content !== shader.source) shaderChanges.push({before, after: shader.source}); + else dependencies.push(before); + } + for (const [name, before] of state.shaderFiles) if (!ownShaders.some(shader => shader.name === name) && before.content !== undefined) shaderChanges.push({before}); if (userGroups.length + pipeline.groups.length > KITTY_MAX_GROUPS) throw new Error(`Kitty allows ${KITTY_MAX_GROUPS} shader groups in total; your shaders use ${userGroups.length} and these effects need ${pipeline.groups.length}.`); const header = ['Managed by NMSh (/appearance › Kitty shaders). Edits here are replaced; remove with R there.', ...active.map(({preset}) => `${preset.definition.label}: Kitty ${preset.origin.kittyVersion} shipped ${preset.origin.resources.map(item => `${item.name} sha256:${item.sha256.slice(0, 12)}`).join(', ')}`)]; @@ -428,7 +461,7 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); // A trail shader with cursor_trail at 0 is written but never drawn. Judged without NMSh's own trail line (so a later sync // keeps it): when the value would be 0, NMSh's fragment turns the trail on and the review says so. - const trailActive = active.some(({preset}) => preset.definition.category === 'cursor'); + const trailActive = active.some(({preset}) => drawsCursor(preset.definition.category)); const trailWithout = Number(simulated.values.get('cursor_trail')?.value ?? 0); if (trailActive && !(trailWithout > 0)) { fragmentText += `# Kitty draws trail shaders only while cursor_trail is above 0.\ncursor_trail ${KITTY_TRAIL_ON}\n`; @@ -436,6 +469,17 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod simulated = simulate(); if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); } + // Cursor & typing effects run while Kitty's trail moves: start it on one-cell moves (typing) and let it run long enough + // for the effect to finish (Kitty reports the cursor only while its trail animates; observed with Kitty 0.49.2). + if (active.some(({preset}) => preset.definition.category === 'typing')) { + fragmentText += `# NMSh's cursor & typing effects: start Kitty's trail on one-cell moves and keep it running while the effect plays.\n${TYPING_TRAIL.map(([key, value]) => `${key} ${value}`).join('\n')}\n`; + simulated = simulate(); + if (simulated.problems.length) throw new Error(simulated.problems.join(' ')); + for (const [key, value] of TYPING_TRAIL) { + const now = simulated.values.get(key); + if (now?.path !== paths.fragment) notes.push(`${key} is set again later${now ? ` (${now.path})` : ''}, so Kitty uses ${now?.value ?? 'its default'} instead of ${value}: typing may not start the effect, or it may end early.`); + } + } const effective = simulated.values.get('custom_shaders'); const effectiveWords = shellWords(effective?.raw ?? '') ?? []; if (active.length && (effective?.path !== paths.fragment || JSON.stringify(effectiveWords) !== JSON.stringify(expected))) { @@ -448,16 +492,19 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod } const record: ShaderLedger = {version: 1, feature: 'kitty-shaders', hostPath: hostFile.path, hook, fragmentHash: digest(fragmentText), pipelineHash: digest(pipelineText), created: state.record?.created ?? hostFile.content === undefined, preserved: inspection.userRaw, placement: settings.placement, kittyVersion: catalog.version, - presets: active.map(({preset}) => ({id: preset.definition.id, resources: preset.origin.resources}))}; + presets: active.map(({preset}) => ({id: preset.definition.id, resources: preset.origin.resources})), + ...(ownShaders.length ? {shaders: ownShaders.map(shader => ({name: shader.name, path: shader.path, sha256: digest(shader.source)}))} : {})}; const ledgerText = `${JSON.stringify(record, null, 2)}\n`; - const changes: FileChange[] = [{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, + const changes: FileChange[] = [...shaderChanges, {before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, ...(state.record ? [] : [{before: hostFile, after: hostAfter}]), {before: state.ledger, after: ledgerText}]; if (mode === 'save') notes.push('After writing, NMSh asks the Kitty it runs in to reload its configuration: Kitty does not watch included files, so it would keep drawing the previous effect.'); const kept = inspection.userEntries.length ? `Kept, in order: ${inspection.userEntries.join(' · ')}` : 'You have no custom_shaders of your own.'; const shadowedNote = inspection.shadowed.length ? [`Kitty already ignores earlier custom_shaders lines at ${inspection.shadowed.join(', ')} (a later line replaces them); they are unchanged.`] : []; const preview = [kept, ...shadowedNote, `Effective after this change: ${effectiveWords.length ? effectiveWords.join(' · ') : '(none)'}`, '', ...previewFile(hostFile.path, hostFile.content, state.record ? hostFile.content : hostAfter), ...previewFile(paths.fragment, state.fragment.content, fragmentText), - ...previewFile(paths.pipeline, state.pipeline.content, pipelineText), ...(notes.length ? ['', ...notes] : [])]; + ...previewFile(paths.pipeline, state.pipeline.content, pipelineText), + ...shaderChanges.map(change => change.after === undefined ? `- ${change.before.path} (NMSh-owned shader, no longer used)` : `File: ${change.before.path} (NMSh-owned shader, ${change.after.split('\n').length} lines of NMSh’s own Slang)`), + ...(notes.length ? ['', ...notes] : [])]; return {ok: true, plan: {host: 'kitty', operation: mode, hostPath: hostFile.path, fragmentPath: paths.fragment, pipelinePath: paths.pipeline, preview, notes, guidance: kittyShaderGuidance(context.platform), home: paths.home, changes, dependencies: [...dependencies, ...(state.record ? [hostFile] : [])], pipelineText, effectiveShaders: effectiveWords}}; @@ -503,7 +550,8 @@ export function planKittyShaderRemoval(context: AppearanceContext): KittyShaderP const hooks = content.split(state.record.hook).length - 1; if (hooks > 1) throw new Error('The owned Kitty shader include is duplicated; it was preserved.'); // Intact owned files are deleted; a missing one needs nothing; one edited outside NMSh is kept (unreferenced once the hook is gone). - const own: FileChange[] = [...(state.damaged.has('fragment') ? [] : [{before: state.fragment}]), ...(state.damaged.has('pipeline') ? [] : [{before: state.pipeline}]), {before: state.ledger}]; + const own: FileChange[] = [...(state.damaged.has('fragment') ? [] : [{before: state.fragment}]), ...(state.damaged.has('pipeline') ? [] : [{before: state.pipeline}]), + ...[...state.shaderFiles].filter(([name]) => !state.damaged.has(`${name}.slang`)).map(([, before]) => ({before})), {before: state.ledger}]; const notes = [...state.damaged].map(([label, kind]) => kind === 'missing' ? `NMSh's Kitty shader ${label} is already missing.` : `NMSh's Kitty shader ${label} was edited outside NMSh; it is kept as it is and no longer included.`); const guidance = kittyShaderGuidance(context.platform); @@ -590,7 +638,8 @@ export function writtenKittyTrail(context: AppearanceContext): string | undefine const state = owned(context); if (!state.record || !(snapshot(state.record.hostPath, state.paths.home).content ?? '').includes(state.record.hook)) return undefined; if (!/^custom_shaders /mu.test(state.fragment.content ?? '')) return undefined; - const trail = /^\s*shaders (cursor-trail-[a-z-]+)\s*$/mu.exec(state.pipeline.content ?? '')?.[1]; + const trail = /^\s*shaders (cursor-trail-[a-z-]+|nmsh-[a-z-]+)\s*$/mu.exec((state.pipeline.content ?? '').split('\n').filter(line => !/^\s*shaders nmsh-/u.test(line) + || NMSH_KITTY_CURSOR_SHADERS.has(line.trim().slice('shaders '.length))).join('\n'))?.[1]; if (!trail) return undefined; // Kitty's trail shaders draw only while cursor_trail is above 0; otherwise Kitty draws no trail and Portable must. const read = readHostConfiguration('kitty', context); diff --git a/src/appearance/nmshShaders.ts b/src/appearance/nmshShaders.ts new file mode 100644 index 00000000..e0d3e683 --- /dev/null +++ b/src/appearance/nmshShaders.ts @@ -0,0 +1,494 @@ +/** + * NMSh's own shader effects, written for both hosts from ONE source: + * Ghostty (Shadertoy-style GLSL `mainImage`) and Kitty 0.49+ (Slang + * `fragment_main`, written by NMSh next to its own pipeline file). + * + * Each effect's look lives in a host-neutral core function written in a small + * GLSL subset; `toSlang` translates that subset mechanically (vecN → floatN, + * mix → lerp, fract → frac, mod → fmod, atan → atan2). Only the thin wrappers + * differ: where the cursor, time and theme color come from, and Kitty's linear + * colors, which the wrapper converts to and from a gamma-2 space so thresholds + * and palettes mean the same on both hosts. + * + * Every effect has at most three settings, all friendly choices compiled in + * as `const int` (Ghostty) or `static const int` replaced by Kitty pipeline + * vars: Color, Strength (Subtle / Balanced / Vivid), Speed (Calm / Normal / + * Lively) or, for Shine Sweep, its cadence. Original NMSh code, GPL-3.0-only. + */ +import type {KittyGroup, PresetParam, ShaderValue} from './kittyShaderCatalog.js'; + +export type NmshEffectId = 'ink-bloom' | 'type-garden' | 'filament' | 'glitch-hop' | 'gravity-lens' | 'holo-foil' | 'shine-sweep' | 'halo-bloom'; + +export interface NmshEffect { + id: NmshEffectId; + label: string; + /** typing: cursor & typing effects (one at a time, shared with the cursor trails); welcome: colorful content only. */ + category: 'typing' | 'welcome'; + /** One plain line for the studio. */ + description: string; + params: readonly PresetParam[]; + defaults: Readonly>; + /** sRGB palettes per COLOR choice (index = choice value); 'theme' reads the host's live cursor color. */ + palette?: ReadonlyArray>; + /** Reads time continuously (redraws every frame while the host animates). */ + animated: boolean; +} + +const choice = (name: string, label: string, names: readonly string[]): PresetParam => + ({name, label, kind: 'choice', type: 'int', choices: names.map((item, index) => [index, item] as const)}); +const STRENGTH = choice('STRENGTH', 'Strength', ['Subtle', 'Balanced', 'Vivid']); +const SPEED = choice('SPEED', 'Speed', ['Calm', 'Normal', 'Lively']); +export const SHINE_CADENCES = ['Every minute', 'On focus only', 'Every 15 s'] as const; + +type Rgb = readonly [number, number, number]; +const THEME = 'theme' as const; + +export const NMSH_EFFECTS: readonly NmshEffect[] = [ + {id: 'ink-bloom', label: 'Ink Bloom', category: 'typing', animated: false, + description: 'Each letter you type bleeds a soft watercolor bloom that spreads a little and dries.', + params: [choice('COLOR', 'Ink', ['From theme', 'Indigo', 'Vermilion', 'Teal']), STRENGTH, SPEED], defaults: {COLOR: 1, STRENGTH: 1, SPEED: 1}, + palette: [[THEME, THEME], [[0.36, 0.42, 1], [0.62, 0.36, 1]], [[1, 0.33, 0.24], [1, 0.62, 0.25]], [[0.1, 0.85, 0.78], [0.25, 0.55, 1]]]}, + {id: 'type-garden', label: 'Type Garden', category: 'typing', animated: false, + description: 'Typing grows tiny glowing sprouts from the letter just typed; they sway, rise and fade.', + params: [choice('COLOR', 'Petals', ['Spring', 'From theme', 'Sakura', 'Firefly']), STRENGTH, SPEED], defaults: {COLOR: 0, STRENGTH: 1, SPEED: 1}, + // [stem, petal A, petal B] + palette: [[[0.42, 0.95, 0.55], [1, 0.5, 0.78], [1, 0.84, 0.35]], [[0.42, 0.9, 0.6], THEME, THEME], [[0.86, 0.58, 0.68], [1, 0.72, 0.84], [1, 0.9, 0.94]], + [[0.55, 0.85, 0.28], [1, 0.92, 0.42], [0.72, 1, 0.5]]]}, + {id: 'filament', label: 'Filament', category: 'typing', animated: false, + description: 'The cursor’s path leaves a coiled hot filament that cools from white to amber to deep red.', + params: [choice('COLOR', 'Glow', ['Tungsten', 'From theme', 'Plasma']), STRENGTH, SPEED], defaults: {COLOR: 0, STRENGTH: 1, SPEED: 1}, + // [cold, warm, hot] (white-hot is added on top) + palette: [[[0.5, 0.03, 0.01], [1, 0.32, 0.05], [1, 0.7, 0.25]], [THEME, THEME, THEME], [[0.3, 0.06, 0.62], [0.42, 0.36, 1], [0.45, 0.92, 1]]]}, + {id: 'glitch-hop', label: 'Glitch Hop', category: 'typing', animated: false, + description: 'Big cursor jumps tear a brief RGB-split slice glitch along the path, near the cursor only.', + params: [choice('COLOR', 'Split', ['Classic RGB', 'Cyan / magenta', 'From theme']), STRENGTH, SPEED], defaults: {COLOR: 0, STRENGTH: 1, SPEED: 1}, + // [left ghost weight, right ghost weight] per channel; the rest stays in place + palette: [[[1, 0, 0], [0, 0, 1]], [[0.7, 0, 0.45], [0, 0.7, 0.45]], [THEME, THEME]]}, + {id: 'gravity-lens', label: 'Gravity Lens', category: 'typing', animated: false, + description: 'Text near a moving cursor bends toward it for a moment, then springs back.', + params: [STRENGTH, SPEED], defaults: {STRENGTH: 1, SPEED: 1}}, + {id: 'holo-foil', label: 'Holo Foil', category: 'welcome', animated: true, + description: 'An iridescent, slowly shifting holographic sheen on colorful art, like a foil trading card.', + params: [choice('COLOR', 'Foil', ['Rainbow', 'Pearl', 'From theme']), STRENGTH, SPEED], defaults: {COLOR: 0, STRENGTH: 1, SPEED: 1}}, + {id: 'shine-sweep', label: 'Shine Sweep', category: 'welcome', animated: true, + description: 'A specular light band sweeps across colorful art when the window opens, then now and then.', + params: [choice('COLOR', 'Light', ['White', 'Gold', 'From theme']), STRENGTH, choice('CADENCE', 'Shine', SHINE_CADENCES)], defaults: {COLOR: 0, STRENGTH: 1, CADENCE: 0}, + palette: [[[1, 1, 1]], [[1, 0.84, 0.5]], [THEME]]}, + {id: 'halo-bloom', label: 'Halo Bloom', category: 'welcome', animated: true, + description: 'A soft, slowly breathing glow around bright, colorful art.', + params: [choice('COLOR', 'Glow', ['Own colors', 'From theme']), STRENGTH, SPEED], defaults: {COLOR: 0, STRENGTH: 1, SPEED: 1}}, +]; + +export const nmshEffect = (id: string) => NMSH_EFFECTS.find(effect => effect.id === id); +/** Kitty shader file names (written next to NMSh's pipeline). */ +export const kittyShaderName = (id: NmshEffectId) => `nmsh-${id}`; +export const NMSH_KITTY_SHADERS: ReadonlySet = new Set(NMSH_EFFECTS.map(effect => kittyShaderName(effect.id))); +/** Kitty shader names that draw the cursor (Portable must not draw a second trail). */ +export const NMSH_KITTY_CURSOR_SHADERS: ReadonlySet = new Set(NMSH_EFFECTS.filter(effect => effect.category === 'typing').map(effect => kittyShaderName(effect.id))); + +// ---- Shared GLSL-subset source ----------------------------------------------------------------------------------- + +const COMMON = `float nmshHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float nmshStrength() { return STRENGTH == 0 ? 0.55 : (STRENGTH == 1 ? 1.0 : 1.7); } +float nmshLuma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } +vec3 nmshScreen(vec3 a, vec3 b) { return vec3(1.0) - (vec3(1.0) - a) * (vec3(1.0) - clamp(b, vec3(0.0), vec3(1.0))); } +`; +const PACE = `float nmshPace() { return SPEED == 0 ? 0.7 : (SPEED == 1 ? 1.0 : 1.45); } +`; +/** Typing moves the caret right on its row by a cell or a few (a paste or completion lands further, Enter changes row). */ +const TYPED = `bool nmshTyped(vec2 from, vec2 to, float cellH) { + vec2 step1 = to - from; + return step1.x > 1.0 && abs(step1.y) < cellH * 0.5 && step1.x < cellH * 3.0; +} +`; +/** How colorful a gamma-space color is (0 for gray text and backgrounds). */ +const COLORFUL = `float nmshColorful(vec3 c) { + float hi = max(c.r, max(c.g, c.b)); + float lo = min(c.r, min(c.g, c.b)); + return smoothstep(0.2, 0.42, hi - lo) * smoothstep(0.18, 0.4, hi); +} +`; +/** Colorful AND solid: blocks, images and filled art pass; thin glyph strokes (colored words) mostly do not. */ +const SOLID = `float nmshSolid(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res) { + float own = nmshColorful(base); + if (own <= 0.0) return 0.0; + float near = 0.0; + for (int i = 0; i < 8; i++) { + float a = float(i) * 0.785398 + 0.3927; + near += nmshColorful(NMSH_SAMPLE(frag + vec2(cos(a), sin(a)) * 5.0)); + } + return own * smoothstep(5.0, 7.5, near); +} +`; + +/** One host-neutral core per effect: vec3 nmshEffect(...) returns the new gamma-space color. */ +const CORES: Record = { + 'ink-bloom': `${PACE}${TYPED}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, vec2 from, vec2 to, float cellH, float age, vec3 theme) { + if (!nmshTyped(from, to, cellH)) return base; + float p = age * nmshPace() / 1.15; + if (p >= 1.0 || p < 0.0) return base; + vec2 d = frag - from; + float seed = nmshHash(floor(from / 3.0)); + float angle = atan(d.y, d.x); + float edge = 1.0 + 0.13 * sin(angle * 2.0 + seed * 6.283) + 0.1 * sin(angle * 3.0 - seed * 11.0) + 0.04 * sin(angle * 5.0 + seed * 23.0); + float spread = 1.0 - (1.0 - min(p * 1.7, 1.0)) * (1.0 - min(p * 1.7, 1.0)) * (1.0 - min(p * 1.7, 1.0)); + float radius = cellH * mix(0.28, 1.0, spread) * edge; + float r = length(d * vec2(1.0, 1.2)); + if (r > radius * 1.35) return base; + float k = nmshStrength(); + float wet = 1.0 - p; + float dry = 1.0 - smoothstep(0.5, 1.0, p); + float body = 1.0 - smoothstep(radius * 0.2, radius, r); + float rimWidth = cellH * mix(0.05, 0.09, spread); + float rim = exp(-((r - radius * 0.88) * (r - radius * 0.88)) / (rimWidth * rimWidth)); + float grain = 0.7 + 0.6 * nmshHash(floor(frag / 2.0) + seed * 17.0); + float bloom = 0.2 + 0.25 * nmshHash(floor(frag / 7.0) + seed * 5.0); + vec3 ink = mix(nmshColorA(theme), nmshColorB(theme), smoothstep(0.3, 1.0, r / radius)); + float feather = r > radius * 0.9 ? exp(-(r - radius * 0.9) / (cellH * 0.12)) * 0.35 : 0.0; + float wash = k * ((body * (0.36 * wet + 0.07) + feather * dry) * grain + rim * 0.4 * dry * (0.55 + bloom)); + vec3 color = nmshScreen(base, ink * wash); + // Glyph strokes inside the bloom drink the ink while it is wet. + float text = smoothstep(0.25, 0.6, nmshLuma(base)); + color = mix(color, ink * (0.55 + 0.6 * nmshLuma(base)), text * body * wet * 0.55 * min(k, 1.0)); + return mix(color, ink, wash * 0.12); +} +`, + 'type-garden': `${PACE}${TYPED}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, vec2 from, vec2 to, float cellH, float age, vec3 theme) { + if (!nmshTyped(from, to, cellH)) return base; + float p = age * nmshPace() / 1.5; + if (p >= 1.0 || p < 0.0) return base; + // Work with y pointing up on every host. + vec2 flip = vec2(1.0, NMSH_UP); + vec2 rel0 = (frag - from) * flip; + if (abs(rel0.x) > cellH * 1.3 || rel0.y < -cellH * 0.7 || rel0.y > cellH * 2.4) return base; + float cellW = cellH * 0.5; + vec3 glow = vec3(0.0); + for (int i = 0; i < 3; i++) { + float fi = float(i); + float h1 = nmshHash(floor(from / 3.0) + vec2(fi * 7.3, 1.7)); + float h2 = nmshHash(floor(from / 3.0) + vec2(fi * 3.1, 11.0)); + float delay = fi * 0.09; + float q = clamp((p - delay) / (1.0 - delay), 0.0, 1.0); + float grow = 1.0 - (1.0 - min(q * 2.4, 1.0)) * (1.0 - min(q * 2.4, 1.0)); + float height = cellH * (0.65 + 0.55 * h2) * grow; + float rise = cellH * 0.35 * smoothstep(0.4, 1.0, q); + float fade = (1.0 - smoothstep(0.55, 1.0, q)) * smoothstep(0.0, 0.05, q); + vec2 rel = rel0 - vec2((fi - 1.0) * cellW * 0.5 + (h1 - 0.5) * cellW * 0.25, cellH * 0.3 + rise); + float s = clamp(rel.y / max(height, 1.0), 0.0, 1.0); + float sway = sin(time * 4.5 * nmshPace() + fi * 2.1 + h1 * 6.283) * cellW * 0.2; + float lean = (h1 - 0.5) * cellW * 0.8; + float bend = (sway + lean) * s * s; + float dx = rel.x - bend; + float width = mix(2.2, 1.0, s); + float stem = (rel.y >= -1.0 && rel.y <= height) ? exp(-(dx * dx) / (width * width)) : 0.0; + // One leaf on the side the sprout leans away from. + vec2 leafAt = vec2(bend * 0.3 - sign(lean + 0.001) * cellW * 0.16, height * 0.42); + vec2 lv = (rel - leafAt) * vec2(1.0, 2.0); + float leaf = (1.0 - smoothstep(cellW * 0.1, cellW * 0.2, length(lv))) * smoothstep(0.2, 0.5, q); + vec2 fd = rel - vec2(sway + lean, height); + float fr = length(fd); + float fa = atan(fd.y, fd.x) + time * 0.7 + h2 * 6.283; + float open1 = smoothstep(0.2, 0.55, q); + float petalR = cellW * (0.12 + 0.3 * open1) * (0.45 + 0.55 * abs(cos(2.5 * fa))); + float petal = 1.0 - smoothstep(petalR * 0.5, petalR, fr); + float core = exp(-(fr * fr) / (cellW * cellW * 0.012)); + float halo = exp(-fr / (cellW * 0.4)) * 0.5; + vec3 bloomColor = mix(nmshColorB(theme), nmshColorC(theme), h2); + glow += (nmshColorA(theme) * (stem * 0.9 + leaf * 0.7) + bloomColor * (petal * 0.95 + halo) + vec3(1.0, 0.96, 0.85) * core * open1) * fade; + } + return nmshScreen(base, glow * nmshStrength() * 0.9); +} +`, + filament: `${PACE}vec3 nmshHeat(float h, vec3 theme) { + vec3 c = mix(vec3(0.0), nmshColorA(theme), smoothstep(0.0, 0.25, h)); + c = mix(c, nmshColorB(theme), smoothstep(0.2, 0.5, h)); + c = mix(c, nmshColorC(theme), smoothstep(0.45, 0.75, h)); + return mix(c, vec3(1.0, 0.97, 0.9), smoothstep(0.75, 1.0, h)); +} +vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, vec2 from, vec2 to, float cellH, float age, vec3 theme) { + vec2 travel = to - from; + float len = length(travel); + if (len < 2.0) return base; + float p = age * nmshPace() / 0.95; + if (p >= 1.0 || p < 0.0) return base; + vec2 dir = travel / len; + vec2 nrm = vec2(-dir.y, dir.x); + vec2 rel = frag - from; + float along = dot(rel, dir); + float across = dot(rel, nrm); + if (along < -cellH || along > len + cellH || abs(across) > cellH * 1.6) return base; + float s = clamp(along / len, 0.0, 1.0); + float pitch = cellH * 0.3; + float taper = sqrt(clamp(s * (1.0 - s) * 4.0, 0.0, 1.0)); + float wobble = sin(along * 0.045 - time * 9.0) * cellH * 0.06 * (1.0 - p); + float coil = sin(along / pitch * 6.283) * cellH * 0.11 * taper + wobble * taper; + float off = max(max(-along, along - len), 0.0); + float dist = length(vec2(across - coil, off)); + // The head (where the cursor is) burns hottest; the far end cools first. + float heat = clamp(1.12 - p * 1.25 - (1.0 - s) * (0.25 + 0.6 * p), 0.0, 1.0); + if (heat <= 0.0) return base; + float core = exp(-(dist * dist) / ((0.8 + 1.6 * heat) * (0.8 + 1.6 * heat))); + float glow = exp(-dist / (cellH * (0.1 + 0.22 * heat))) * 0.5; + vec3 light = nmshHeat(heat, theme) * (core * 1.25 + glow) * nmshStrength(); + return base + light * (1.0 - nmshLuma(base) * 0.4); +} +`, + 'glitch-hop': `${PACE}float nmshSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 0.0001), 0.0, 1.0); + return length(pa - ba * h); +} +vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, vec2 from, vec2 to, float cellH, float age, vec3 theme) { + float jump = length(to - from); + if (jump < cellH * 1.4) return base; + float p = age * nmshPace() / 0.5; + if (p >= 1.0 || p < 0.0) return base; + float band = 1.0 - smoothstep(cellH * 0.55, cellH * 1.0, nmshSegment(frag, from, to)); + float dest = 1.0 - smoothstep(cellH * 1.1, cellH * 2.3, length(frag - to)); + float region = max(band * 0.75, dest); + if (region <= 0.0) return base; + float k = nmshStrength(); + float frame = floor(age * 30.0); + float slice = floor(frag.y / (cellH * 0.23)); + float r1 = nmshHash(vec2(slice, frame + 3.0)); + float r2 = nmshHash(vec2(frame * 1.3 + 7.0, slice * 1.7)); + float env = (1.0 - p) * (1.0 - p) * (0.55 + 0.45 * step(0.35, nmshHash(vec2(frame, 9.0)))); + float tear = step(0.5, r1) * (r2 - 0.5) * cellH * 1.6 * k * env * region; + float split = cellH * 0.13 * k * env * region * (0.5 + r1); + vec2 at = frag + vec2(tear, 0.0); + vec3 mid = NMSH_SAMPLE(at); + vec3 left = NMSH_SAMPLE(at + vec2(split, 0.0)); + vec3 right = NMSH_SAMPLE(at - vec2(split, 0.0)); + vec3 a = nmshColorA(theme); + vec3 b = nmshColorB(theme); + vec3 keep = clamp(vec3(1.0) - a - b, vec3(0.0), vec3(1.0)); + vec3 color = mid * keep + left * a + right * b; + color *= 1.0 - 0.22 * env * region * step(0.5, fract(frag.y * 0.5)); + color += (step(0.92, r1) * 0.3 + step(0.7, r2) * 0.12) * env * region * k * mix(a, b, r2); + return mix(base, color, step(0.001, region)); +} +`, + 'gravity-lens': `${PACE}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, vec2 from, vec2 to, float cellH, float age, vec3 theme) { + float travel = length(to - from); + if (travel < 2.0) return base; + float p = age * nmshPace() / 1.0; + if (p >= 1.0 || p < 0.0) return base; + float radius = cellH * 2.6; + vec2 d = frag - to; + float r = length(d); + if (r > radius * 1.7) return base; + float k = nmshStrength(); + float reach = clamp(travel / (cellH * 3.0), 0.4, 1.0); + float spring = exp(-3.6 * p) * cos(p * 7.5) * (1.0 - exp(-40.0 * p)); + float x = r / radius; + float fall = x * exp(-x * x * 1.7); + float pull = k * cellH * 0.85 * reach * spring * fall; + vec3 color = NMSH_SAMPLE(frag + d / max(r, 0.001) * pull); + float ringR = radius * (0.3 + 0.55 * p); + float ringW = cellH * 0.07; + float ring = exp(-((r - ringR) * (r - ringR)) / (ringW * ringW)) * exp(-5.0 * p) * reach * k * 0.3; + return nmshScreen(color, nmshColorA(theme) * ring); +} +`, + 'holo-foil': `${PACE}${COLORFUL}${SOLID}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, float since, vec3 theme) { + float m = nmshSolid(NMSH_TEX_ARG frag, base, res); + if (m <= 0.0) return base; + float k = nmshStrength(); + vec2 uv = frag / res.y; + float t = time * 0.1 * nmshPace(); + float lum = nmshLuma(base); + // Thin-film color: shifts with position (a stand-in for viewing angle), brightness and slow drift. + float phase = dot(uv, vec2(0.6, 0.8)) * 1.7 - t + lum * 0.45 + 0.07 * sin(uv.y * 19.0 + t * 5.0); + vec3 film = nmshFilm(phase, theme); + vec3 foil = film * (0.42 + 0.8 * lum); + // A broad specular highlight crosses the art diagonally every few seconds. + float sweep = fract(t * 1.2) * 1.8 - 0.4; + float sd = dot(uv, vec2(0.7071)) / (res.x / res.y * 0.7071 + 0.7071) - sweep; + float sheen = exp(-(sd * sd) / 0.0035); + // Glitter: sparse foil flecks that twinkle. + float fleck = nmshHash(floor(frag / 2.0)); + float glitter = step(0.965, fleck) * (0.5 + 0.5 * sin(time * 2.6 * nmshPace() + fleck * 60.0)); + vec3 color = mix(base, foil, m * min(0.52 * k, 0.9)); + return color + (film * 0.3 + vec3(0.3)) * sheen * m * k + vec3(glitter * m * k * 0.55); +} +`, + 'shine-sweep': `${COLORFUL}${SOLID}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, float since, vec3 theme) { + float age = CADENCE == 1 ? since : mod(max(since, 0.0), CADENCE == 0 ? 60.0 : 15.0); + float duration = 2.2; + if (age < 0.0 || age > duration) return base; + float m = nmshSolid(NMSH_TEX_ARG frag, base, res); + if (m <= 0.0) return base; + float k = nmshStrength(); + float down = NMSH_UP > 0.0 ? res.y - frag.y : frag.y; + float u = (frag.x + down * 0.5) / (res.x + res.y * 0.5); + float ease = age / duration; + ease = ease * ease * (3.0 - 2.0 * ease); + float d = u - mix(-0.1, 1.1, ease); + float w = 0.035; + float band = exp(-(d * d) / (w * w)); + float hot = exp(-(d * d) / (w * w * 0.025)); + float glint = exp(-((d + w * 1.1) * (d + w * 1.1)) / (w * w * 0.02)); + vec3 fringe = 0.5 + 0.5 * cos(6.283 * (d / w * 0.35 + vec3(0.0, 0.33, 0.67))); + vec3 light = nmshColorA(theme); + vec3 color = base + (light * (band * 0.32 + hot * 0.7 + glint * 0.45) + fringe * band * 0.1) * m * k; + return mix(color, light, hot * m * 0.25 * k); +} +`, + 'halo-bloom': `${PACE}${COLORFUL}vec3 nmshEffect(NMSH_TEX_PARAM vec2 frag, vec3 base, vec2 res, float time, float since, vec3 theme) { + vec3 halo = vec3(0.0); + float weight = 0.0; + for (int ring = 1; ring <= 2; ring++) { + float fr = float(ring); + for (int i = 0; i < 12; i++) { + float a = float(i) * 0.5236 + fr * 0.2618; + vec2 dir = vec2(cos(a), sin(a)); + vec2 at = frag + dir * 7.0 * fr; + vec3 s = NMSH_SAMPLE(at); + // Both the sample and its neighbor across the ray must be colorful: solid art glows, thin colored words do not. + float c = nmshColorful(s) * nmshColorful(NMSH_SAMPLE(at + vec2(-dir.y, dir.x) * 3.0)); + float w = fr == 1.0 ? 1.0 : 0.6; + halo += s * s * c * w * 1.6; + weight += w; + } + } + halo /= weight; + if (COLOR == 1) halo = nmshColorA(theme) * nmshLuma(halo) * 1.6; + float period = SPEED == 0 ? 6.0 : (SPEED == 1 ? 4.0 : 2.6); + float breathe = 0.68 + 0.32 * sin(time * 6.283 / period); + float k = nmshStrength(); + return nmshScreen(base, halo * k * 1.9 * breathe * (1.0 - nmshColorful(base) * 0.5)); +} +`, +}; + +// ---- Colors ------------------------------------------------------------------------------------------------------ + +/** GLSL palette functions for an effect: COLOR is a compile-time constant on both hosts, so the ternary folds away. */ +function paletteFunctions(effect: NmshEffect, literal: (rgb: Rgb) => string): string { + const names = ['nmshColorA', 'nmshColorB', 'nmshColorC']; + if (effect.id === 'holo-foil') { + return `vec3 nmshFilm(float phase, vec3 theme) { + vec3 rainbow = vec3(0.5) + 0.5 * cos(6.283 * (phase + vec3(0.0, 0.33, 0.67))); + if (COLOR == 1) return mix(rainbow, vec3(1.0, 0.96, 0.98), 0.55); + if (COLOR == 2) return mix(theme, vec3(1.0) - theme * 0.6, 0.5 + 0.5 * sin(phase * 6.283)); + return rainbow; +} +`; + } + if (effect.id === 'halo-bloom' || effect.id === 'gravity-lens') return 'vec3 nmshColorA(vec3 theme) { return theme; }\n'; + const palette = effect.palette ?? []; + const slots = Math.max(...palette.map(entry => entry.length)); + return names.slice(0, slots).map((name, slot) => { + const options = palette.map(entry => { + const value = entry[Math.min(slot, entry.length - 1)]!; + if (value !== THEME) return literal(value); + // Theme slots derive related tones from the one live color. + if (effect.id === 'filament') return slot === 0 ? `theme * 0.35` : slot === 1 ? `theme` : `mix(theme, vec3(1.0), 0.45)`; + if (effect.id === 'glitch-hop') return slot === 0 ? `theme * 0.6` : `(vec3(1.0) - theme) * 0.6`; + if (effect.id === 'ink-bloom' && slot === 1) return `mix(theme, theme.gbr, 0.35)`; + if (effect.id === 'type-garden' && slot === 2) return `mix(theme, vec3(1.0), 0.4)`; + return 'theme'; + }); + const body = options.reduceRight((rest, option, index) => index === options.length - 1 ? option : `COLOR == ${index} ? ${option} : (${rest})`, ''); + return `vec3 ${name}(vec3 theme) { return ${body}; }\n`; + }).join(''); +} + +// ---- Hosts ------------------------------------------------------------------------------------------------------- + +const glslNumber = (value: number) => { const text = Number(value.toFixed(4)).toString(); return /[.eE]/u.test(text) ? text : `${text}.0`; }; +const glslRgb = (rgb: Rgb) => `vec3(${rgb.map(glslNumber).join(', ')})`; +const coreSource = (effect: NmshEffect) => `${COMMON}${paletteFunctions(effect, glslRgb)}${CORES[effect.id]}`; +const isTyping = (effect: NmshEffect) => effect.category === 'typing'; + +/** + * The Ghostty GLSL body (after NMSh's `const` lines for each parameter). Cursor geometry comes from Ghostty's + * cursor uniforms; the cell height from the cursor rectangle (an underline cursor is short, so its width stands in). + */ +export function nmshGhosttyBody(id: NmshEffectId, platform: NodeJS.Platform): string { + const effect = nmshEffect(id)!; + // Ghostty renders through Metal on macOS, where fragCoord's y grows downward (observed with Ghostty 1.3.1); OpenGL's grows upward. + const glue = `#define NMSH_UP ${platform === 'darwin' ? '-1.0' : '1.0'} +#define NMSH_TEX_PARAM +#define NMSH_TEX_ARG +#define NMSH_SAMPLE(px) texture(iChannel0, (px) / res).rgb +`; + const main = isTyping(effect) ? `vec2 nmshCenter(vec4 rect) { return rect.xy + vec2(rect.z * 0.5, -rect.w * 0.5); } +float nmshCellHeight(vec4 rect) { return rect.w >= rect.z ? max(rect.w, 8.0) : max(rect.z * 2.0, 8.0); } +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + vec3 color = nmshEffect(fragCoord, base.rgb, iResolution.xy, iTime, nmshCenter(iPreviousCursor), nmshCenter(iCurrentCursor), + nmshCellHeight(iCurrentCursor), iTime - iTimeCursorChange, iCurrentCursorColor.rgb); + fragColor = vec4(color, base.a); +} +` : `void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec4 base = texture(iChannel0, fragCoord / iResolution.xy); + float since = ${effect.id === 'shine-sweep' ? '(CADENCE == 1 && iFocus == 0) ? -1.0 : iTime - iTimeFocus' : '0.0'}; + vec3 color = nmshEffect(fragCoord, base.rgb, iResolution.xy, iTime, since, iCurrentCursorColor.rgb); + fragColor = vec4(color, base.a); +} +`; + return `${glue}${coreSource(effect)}${main}`; +} + +/** The mechanical GLSL-subset → Slang translation used for the shared cores (see the module comment). */ +export const toSlang = (glsl: string) => glsl.replace(/\bvec([234])\b/gu, 'float$1').replace(/\bmix\(/gu, 'lerp(').replace(/\bfract\(/gu, 'frac(') + .replace(/\bmod\(/gu, 'fmod(').replace(/\batan\(/gu, 'atan2('); + +/** The complete Kitty `.slang` file for one effect. Values arrive as pipeline vars replacing the `static const` defaults. */ +export function nmshKittySource(id: NmshEffectId): string { + const effect = nmshEffect(id)!; + const constants = effect.params.map(param => `static const int ${param.name} = ${effect.defaults[param.name]};`).join('\n'); + // Kitty's colors are linear: samples are taken to gamma 2 (sqrt) and results brought back (squared), so the + // shared thresholds and palettes mean the same as on Ghostty. + const glue = `#define NMSH_UP 1.0 +#define NMSH_TEX_PARAM KittyTextures t, +#define NMSH_TEX_ARG t, +#define NMSH_SAMPLE(px) sqrt(max(t.backbuffer.Sample((px) / res).rgb, float3(0.0))) +`; + const theme = 'sqrt(max(d.cursor_color.a > 0.0 ? d.cursor_color.rgb : d.foreground.rgb, float3(0.0)))'; + const main = isTyping(effect) ? `public float4 +fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) { + // Kitty reports the cursor only while its cursor trail animates; NMSh's pipeline runs this group exactly then. + float4 now = d.cursor_trail_edge; + float4 before = d.cursor_trail_prev_edge; + if (now.y <= 0.0 || before.y <= 0.0 || d.cursor_trail_change_time <= 0.0) return color; + float2 res = float2(d.viewport_size_pixels); + float2 to = float2((now.x + now.y) * 0.5, (now.z + now.w) * 0.5) * res; + float2 from = float2((before.x + before.y) * 0.5, (before.z + before.w) * 0.5) * res; + float cellH = max((now.z - now.w) * res.y, 8.0); + float3 base = sqrt(max(color.rgb, float3(0.0))); + float3 result = nmshEffect(t, t.pos * res, base, res, d.timestamp, from, to, cellH, d.timestamp - d.cursor_trail_change_time, ${theme}); + return float4(result * result, color.a); +} +` : `public float4 +fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) { + float2 res = float2(d.viewport_size_pixels); + float3 base = sqrt(max(color.rgb, float3(0.0))); + float since = ${effect.id === 'shine-sweep' ? 'CADENCE == 1 ? t.animation_progress * 2.2 : d.timestamp' : '0.0'}; + float3 result = nmshEffect(t, t.pos * res, base, res, d.timestamp, since, ${theme}); + return float4(result * result, color.a); +} +`; + return `#language slang 2026 +// NMSh ${effect.label} for Kitty. Generated by NMSh (/appearance › Kitty shaders); original NMSh code, GPL-3.0-only. +// Edits are replaced; remove with R in that studio. Settings arrive as pipeline vars. +import kitty_custom_shader_types; + +${constants} +${glue}${toSlang(coreSource(effect))}${main}`; +} + +/** + * Kitty pipeline groups for an effect. Cursor & typing effects run while Kitty's cursor trail animates (subscribing also + * hides Kitty's built-in trail, so there is one cursor effect); welcome effects run continuously, except Shine Sweep's + * "On focus only", which starts when the OS window gains focus (Kitty also fires that when the pipeline loads). + */ +export function nmshKittyGroups(id: NmshEffectId, values: Readonly>): KittyGroup[] { + const effect = nmshEffect(id)!; + const shaders = [kittyShaderName(id)]; + if (isTyping(effect)) return [{shaders, vars: [], attach: false, animationStart: 'cursor-trail-move', animationStop: 'cursor-trail-stop', animationStep: 16}]; + if (id === 'shine-sweep' && values.CADENCE === 1) return [{shaders, vars: [], attach: false, animationStart: 'os-window-focus-in', animationStop: '2200', animationStep: 16}]; + return [{shaders, vars: [], attach: false, animationStep: 33}]; +} diff --git a/tests/ghosttyShaders.test.ts b/tests/ghosttyShaders.test.ts index 1b3835fe..74274aeb 100644 --- a/tests/ghosttyShaders.test.ts +++ b/tests/ghosttyShaders.test.ts @@ -47,8 +47,9 @@ test('version gates: unknown, too old and any unverified newer version fail clos assert.match(ghosttyCapability(newer).reason!, /newer than NMSh has verified \(through 1\.4\.x\)/u); } const old = buildGhosttyCatalog('1.2.0'); - assert.deepEqual(old.offered.map(item => item.definition.id).sort(), ['comet', 'glow', 'scanlines', 'smear', 'vignette']); - assert.deepEqual(old.notOffered, ['Aurora (Ghostty 1.3+)', 'Dim when unfocused (Ghostty 1.3+)']); + assert.deepEqual(old.offered.map(item => item.definition.id).sort(), ['comet', 'filament', 'glitch-hop', 'glow', 'gravity-lens', 'halo-bloom', 'holo-foil', 'ink-bloom', + 'scanlines', 'smear', 'type-garden', 'vignette']); + assert.deepEqual(old.notOffered, ['Aurora (Ghostty 1.3+)', 'Dim when unfocused (Ghostty 1.3+)', 'Shine Sweep (Ghostty 1.3+)'], 'Shine Sweep reads the 1.3 focus time'); assert.deepEqual(buildGhosttyCatalog('1.3.1').offered.map(item => item.definition.id), buildGhosttyCatalog('1.4.0').offered.map(item => item.definition.id)); assert.equal(buildGhosttyCatalog('1.3.1').offered.length, GHOSTTY_PRESETS.length); assert.equal(buildGhosttyCatalog(undefined).offered.length, 0); @@ -400,3 +401,46 @@ test('animation: the person’s custom-shader-animation is read, never written; assert.ok(vignette.ok && vignette.plan.notes.some(note => /runs its animation loop on the focused surface \(custom-shader-animation true, the default\)/u.test(note))); assert.ok(!presetById('smear')!.note!.includes('Redraws only')); }); + +// ---- NMSh's own effects ------------------------------------------------------------------------------------------ + +test('NMSh’s own effects: two new categories, ≤3 friendly choices each, typing effects are the one cursor effect, every value generates', () => { + const ours = GHOSTTY_PRESETS.filter(preset => preset.category === 'typing' || preset.category === 'welcome'); + assert.deepEqual(ours.map(preset => preset.id), ['ink-bloom', 'type-garden', 'filament', 'glitch-hop', 'gravity-lens', 'holo-foil', 'shine-sweep', 'halo-bloom']); + for (const preset of ours) { + assert.ok(preset.params.length <= 3 && preset.params.every(param => param.kind === 'choice' && param.type === 'int'), preset.id); + assert.equal(preset.trail === true && preset.exclusive === 'cursor', preset.category === 'typing', `${preset.id}: typing effects replace the trail`); + for (const param of preset.params) for (const [value] of param.choices!) { + const source = generateGhosttyShader(preset, {[param.name]: value}, colors).source; + assert.match(source, new RegExp(`const int ${param.name} = ${value};`, 'u')); + assert.match(source, /Original NMSh code, GPL-3\.0-only/u); + } + } + assert.deepEqual(validateGhosttyEffects(settingsOf(['smear'], ['ink-bloom']), buildGhosttyCatalog('1.3.1')), ['Ink Bloom and Smear trail cannot run together (one cursor trail).']); + assert.deepEqual(validateGhosttyEffects(settingsOf(['ink-bloom'], ['holo-foil'], ['halo-bloom']), buildGhosttyCatalog('1.3.1')), [], 'one cursor effect plus welcome effects'); + // Ghostty's fragment y axis points down under Metal (macOS) and up under OpenGL; Type Garden grows up on both. + assert.match(generateGhosttyShader(presetById('type-garden')!, {}, {...colors, platform: 'darwin'}).source, /#define NMSH_UP -1\.0/u); + assert.match(generateGhosttyShader(presetById('type-garden')!, {}, {...colors, platform: 'linux'}).source, /#define NMSH_UP 1\.0/u); + // Theme colors are read live, not baked in. + assert.match(generateGhosttyShader(presetById('ink-bloom')!, {COLOR: 0}, colors).source, /iCurrentCursorColor\.rgb/u); + assert.match(generateGhosttyShader(presetById('shine-sweep')!, {CADENCE: 1}, colors).source, /iTimeFocus/u); +}); + +test('NMSh’s own effects are written as owned passes, gated like trails, and removed exactly', t => { + const {context, put, legacy, paths} = fixture(t); + put(legacy, 'font-size = 13\n'); + const plan = apply(planGhosttyShaders(settingsOf(['filament', {COLOR: 2}], ['holo-foil']), context)); + assert.deepEqual(plan.shaders.map(shader => shader.id), ['filament', 'holo-foil']); + assert.equal(plan.trailAfter, 'filament', 'a typing effect is the trail Portable must not double'); + assert.equal(writtenGhosttyTrail(context), 'filament'); + for (const id of ['filament', 'holo-foil']) assert.ok(readFileSync(join(paths.shaderDirectory, `${id}.glsl`), 'utf8').includes('nmshEffect(')); + // Portable cursor: the typing effect leaves, the welcome effect stays. + const synced = syncGhosttyShaders(settingsOf(['filament'], ['holo-foil']), {...context, gates: {...openGates, cursorRenderer: 'portable'}}); + assert.equal(synced.kind, 'written'); + assert.deepEqual(effective(context).map(path => path.split('/').at(-1)), ['holo-foil.glsl']); + const removal = planGhosttyShaderRemoval(context); + assert.ok(removal.ok, removal.ok ? '' : removal.error); + assert.ok(applyGhosttyShaderPlan(removal.plan).ok); + for (const id of ['filament', 'holo-foil']) assert.equal(existsSync(join(paths.shaderDirectory, `${id}.glsl`)), false, `${id} removed`); + assert.equal(readFileSync(legacy, 'utf8'), 'font-size = 13\n'); +}); diff --git a/tests/ghosttyShadersLive.test.ts b/tests/ghosttyShadersLive.test.ts index 6d157d47..72d9565d 100644 --- a/tests/ghosttyShadersLive.test.ts +++ b/tests/ghosttyShadersLive.test.ts @@ -61,7 +61,9 @@ for (const binary of binaries) { mkdirSync(dirname(config), {recursive: true, mode: 0o700}); writeFileSync(config, 'font-size = 13\ncustom-shader = mine.glsl\n', {mode: 0o600}); writeFileSync(join(dirname(config), 'mine.glsl'), 'void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p / iResolution.xy); }\n'); - const effects = buildGhosttyCatalog(version).offered.filter(item => item.definition.id !== 'comet').map(item => ({id: item.definition.id, enabled: true, values: {}})); + // One cursor effect (Smear) and at most six passes: the original presets plus nothing exclusive with Smear. + const effects = buildGhosttyCatalog(version).offered.filter(item => item.definition.id !== 'comet' && item.definition.category !== 'typing' && item.definition.category !== 'welcome') + .map(item => ({id: item.definition.id, enabled: true, values: {}})); const planned = planGhosttyShaders(normalizeHostShaders({ghostty: {effects}}).ghostty, context); assert.ok(planned.ok, planned.ok ? '' : planned.error); assert.ok(applyGhosttyShaderPlan(planned.plan).ok); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts index 0e139d20..15723fac 100644 --- a/tests/kittyShaders.test.ts +++ b/tests/kittyShaders.test.ts @@ -553,3 +553,75 @@ test('reload: Kitty ignores changed included files, so NMSh asks only the verifi assert.ok(plan.ok && plan.plan.preview.some(line => /NMSh asks the Kitty it runs in to reload its configuration/u.test(line))); assert.ok(plan.ok && /Kitty watches only kitty\.conf, not the files it includes/u.test(plan.plan.guidance)); }); + +// ---- NMSh's own effects ------------------------------------------------------------------------------------------ + +test('NMSh’s own effects: offered from NMSh’s Slang (any Kitty 0.49+), typing effects are the one cursor effect, ≤3 choices each', () => { + const c = catalog(); + const ours = c.offered.filter(preset => 'nmsh' in preset.definition.source); + assert.deepEqual(ours.map(preset => preset.definition.id), ['ink-bloom', 'type-garden', 'filament', 'glitch-hop', 'gravity-lens', 'holo-foil', 'shine-sweep', 'halo-bloom']); + for (const preset of ours) { + assert.deepEqual(preset.missing, [], `${preset.definition.id}: every setting is declared by NMSh's own shader`); + assert.ok(preset.params.length <= 3 && preset.params.every(param => param.kind === 'choice')); + assert.equal(preset.definition.exclusive === 'cursor', preset.definition.category === 'typing'); + assert.equal(c.shaders.has(`nmsh-${preset.definition.id}`), false, 'NMSh’s shaders are not mistaken for shipped ones'); + } + assert.deepEqual(validateEffects(settingsOf(['cursor-trail-blaze'], ['type-garden']), c), ['Type Garden and Blaze trail cannot run together.']); + assert.equal(catalog('0.48.1').offered.length, 0, 'older Kitty has no custom shaders at all'); +}); + +test('NMSh’s own Kitty shaders are written beside the pipeline, owned, swapped and removed exactly; typing starts the trail on one-cell moves', t => { + const f = fixture(t); + f.put(f.kitty, 'font_size 13\n'); + const shader = (id: string) => join(f.paths.directory, `nmsh-${id}.slang`); + const plan = apply(planKittyShaders(settingsOf(['ink-bloom', {COLOR: 2, STRENGTH: 2}], ['shine-sweep', {CADENCE: 1}]), f.context)); + // The pipeline names NMSh's shaders by name; Kitty finds them in the pipeline's own folder first. + assert.match(plan.pipelineText, /animation_start cursor-trail-move\n {4}animation_stop cursor-trail-stop\n {4}animation_step 16\n {4}var int COLOR = 2\n {4}var int STRENGTH = 2\n {4}shaders nmsh-ink-bloom/u); + assert.match(plan.pipelineText, /animation_start os-window-focus-in\n {4}animation_stop 2200[\s\S]*var int CADENCE = 1\n {4}shaders nmsh-shine-sweep/u, 'On focus only starts on focus'); + assert.ok(parseKittyPipeline(plan.pipelineText, 'nmsh').ok); + for (const id of ['ink-bloom', 'shine-sweep']) assert.match(readFileSync(shader(id), 'utf8'), /^#language slang 2026\n[\s\S]*import kitty_custom_shader_types;[\s\S]*fragment_main/u); + const fragment = readFileSync(f.paths.fragment, 'utf8'); + assert.match(fragment, /cursor_trail 3\n/u, 'trail turned on for the cursor effect'); + assert.match(fragment, /cursor_trail_start_threshold 0\ncursor_trail_decay 6 12\n/u); + const ledger = JSON.parse(readFileSync(f.paths.ledger, 'utf8')); + assert.deepEqual(ledger.shaders.map((item: {name: string}) => item.name), ['nmsh-ink-bloom', 'nmsh-shine-sweep']); + return import('../src/appearance/kittyShaders.js').then(({writtenKittyTrail}) => { + assert.equal(writtenKittyTrail(f.context), 'nmsh-ink-bloom', 'Portable must not draw a second cursor effect'); + // Swapping effects writes the new shader and deletes the one no longer used; welcome effects are not trails. + apply(planKittyShaders(settingsOf(['ink-bloom', {}, false], ['halo-bloom']), f.context)); + assert.equal(existsSync(shader('ink-bloom')), false); + assert.equal(existsSync(shader('shine-sweep')), false); + assert.ok(existsSync(shader('halo-bloom'))); + assert.equal(writtenKittyTrail(f.context), undefined); + assert.doesNotMatch(readFileSync(f.paths.fragment, 'utf8'), /cursor_trail_start_threshold/u, 'no trail tuning without a typing effect'); + // A same-named file NMSh did not record is never overwritten; an edited owned shader blocks a rewrite but not removal. + f.put(shader('filament'), '// mine\n'); + const refused = planKittyShaders(settingsOf(['filament'], ['halo-bloom']), f.context); + assert.equal(refused.ok, false); + assert.match((refused as {error: string}).error, /nmsh-filament\.slang exists but is not recorded as NMSh’s/u); + writeFileSync(shader('halo-bloom'), '// edited\n'); + assert.match((planKittyShaders(settingsOf(['halo-bloom']), f.context) as {error: string}).error, /nmsh-halo-bloom\.slang is unowned or was edited/u); + const removal = planKittyShaderRemoval(f.context); + assert.ok(removal.ok, removal.ok ? '' : removal.error); + assert.ok(applyKittyShaderPlan(removal.plan).ok); + assert.equal(readFileSync(shader('halo-bloom'), 'utf8'), '// edited\n', 'an edited shader is kept as it is'); + assert.equal(readFileSync(shader('filament'), 'utf8'), '// mine\n'); + assert.equal(readFileSync(f.kitty, 'utf8'), 'font_size 13\n'); + assert.equal(existsSync(f.paths.pipeline), false); + }); +}); + +test('removal deletes every intact NMSh shader it recorded; a later trail-threshold line is disclosed, not fought', t => { + const f = fixture(t); + f.put(f.kitty, 'cursor_trail 3\ninclude late.conf\n'); + f.put(join(dirname(f.kitty), 'late.conf'), ''); + apply(planKittyShaders(settingsOf(['gravity-lens'], ['holo-foil']), f.context)); + writeFileSync(f.kitty, `${readFileSync(f.kitty, 'utf8')}cursor_trail_start_threshold 2\n`); + const replan = planKittyShaders(settingsOf(['gravity-lens', {SPEED: 2}], ['holo-foil']), f.context); + assert.ok(replan.ok, replan.ok ? '' : replan.error); + assert.ok(replan.plan.notes.some(note => /cursor_trail_start_threshold is set again later .*uses 2 instead of 0/u.test(note)), replan.plan.notes.join('\n')); + const removal = planKittyShaderRemoval(f.context); + assert.ok(removal.ok); + assert.ok(applyKittyShaderPlan(removal.plan).ok); + for (const id of ['gravity-lens', 'holo-foil']) assert.equal(existsSync(join(f.paths.directory, `nmsh-${id}.slang`)), false); +}); diff --git a/tests/kittyShadersLive.test.ts b/tests/kittyShadersLive.test.ts index 0847432d..9e1676fa 100644 --- a/tests/kittyShadersLive.test.ts +++ b/tests/kittyShadersLive.test.ts @@ -80,7 +80,10 @@ test('installed Kitty loads NMSh’s composed custom_shaders exactly and compile test('installed Kitty compiles each newer curated preset with its tuned values (headless, private HOME)', {skip: !supported && 'Kitty 0.49+ is not installed', timeout: 300_000}, async t => { const presets: Array<[string, Record]> = [['crt-blue', {WARP: 0.15}], ['fireworks-rockets', {CYCLE_TIME: 4.5}], ['bloom', {BLOOM_STRENGTH: 0.35}], - ['tft', {STRENGTH: 0.65}], ['water', {CAUSTIC_INTEN: 0.007, WATER_COLOR: [0.2, 0.4, 0.6]}], ['underwater', {}], ['negative', {}]]; + ['tft', {STRENGTH: 0.65}], ['water', {CAUSTIC_INTEN: 0.007, WATER_COLOR: [0.2, 0.4, 0.6]}], ['underwater', {}], ['negative', {}], + // NMSh's own Slang, written beside its pipeline: Kitty must find and compile it (warnings are errors in Kitty). + ['ink-bloom', {COLOR: 2}], ['type-garden', {STRENGTH: 2}], ['filament', {COLOR: 2}], ['glitch-hop', {COLOR: 1}], ['gravity-lens', {STRENGTH: 0}], + ['holo-foil', {COLOR: 1}], ['shine-sweep', {CADENCE: 1}], ['halo-bloom', {COLOR: 1}]]; const compile = async (id: string, values: Record) => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-klive2-'))); t.after(() => rmSync(home, {recursive: true, force: true})); From 707d407be3fdfb26cbf850aca985e73ee7e4ec48 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:24:47 +0530 Subject: [PATCH 2/3] Shader Studio: Cursor & typing and Welcome & images categories with previews Both studios list NMSh's eight effects in two new categories, each with a one-line plain description, a note that one cursor effect runs at a time, and that welcome effects leave plain text alone. Each effect has its own small, distinct terminal-text preview that animates only while it runs and is still under Reduced Motion, Effects Off and NO_COLOR. --- src/appearance/KittyShaderPanel.ts | 2 +- src/appearance/ShaderStudioPanel.ts | 9 +- src/appearance/shaderPreview.ts | 185 ++++++++++++++++++++++++++- tests/appearanceStudioLayout.test.ts | 33 ++++- tests/shaderPreview.test.ts | 30 +++++ 5 files changed, 253 insertions(+), 6 deletions(-) diff --git a/src/appearance/KittyShaderPanel.ts b/src/appearance/KittyShaderPanel.ts index 29c8ccc5..7a61b077 100644 --- a/src/appearance/KittyShaderPanel.ts +++ b/src/appearance/KittyShaderPanel.ts @@ -17,7 +17,7 @@ export type KittyShaderAction = Exclude; export const KITTY_STUDIO: StudioHost = { name: 'Kitty', placement: true, - intro: catalog => `Kitty ${catalog.version} · shipped shaders read from Kitty itself · effects cover the whole Kitty window, not only NMSh`, + intro: catalog => `Kitty ${catalog.version} · shipped shaders read from Kitty itself, plus NMSh’s own (written beside NMSh’s pipeline) · effects cover the whole Kitty window, not only NMSh`, unsupportedHint: 'Cursor effects stay with /cursor (Portable, or Kitty’s built-in trail).', status: inspection => inspection.drifted ? 'Your own custom_shaders changed since NMSh composed with it; S reviews an updated plan.' : inspection.configured ? `Set up · ${inspection.activePresets.length ? `${inspection.activePresets.length} NMSh effect${inspection.activePresets.length === 1 ? '' : 's'} in Kitty’s config` : 'no NMSh effect active'}` diff --git a/src/appearance/ShaderStudioPanel.ts b/src/appearance/ShaderStudioPanel.ts index f3504644..bec4f2b8 100644 --- a/src/appearance/ShaderStudioPanel.ts +++ b/src/appearance/ShaderStudioPanel.ts @@ -18,6 +18,7 @@ import {stripTerminalControls} from '../util/terminalControls.js'; import {CATEGORY_LABELS, type OfferedParam, type PresetCategory, type ShaderValue} from './kittyShaderCatalog.js'; import type {KittyShaderEffect} from './kittyShaderSettings.js'; import {PREVIEW_MS, previewKind, previewValues, renderShaderPreview} from './shaderPreview.js'; +import {NMSH_EFFECTS} from './nmshShaders.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import type {RgbColor} from '../ui/palette.js'; @@ -75,7 +76,7 @@ export type StudioAction = {kind: 'back'} | {kind: 'review'} | {kind: 'remove'} type Row = {kind: 'placement'} | {kind: 'preset'; preset: StudioPreset} | {kind: 'param'; preset: StudioPreset; param: OfferedParam}; type AnyState = ShaderStudioState; -const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'ambient', 'interaction', 'focus', 'display']; +const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'typing', 'welcome', 'ambient', 'interaction', 'focus', 'display']; const PREVIEW_DESCRIPTIONS: Record = { trail: 'A fading smear follows the cursor between positions.', comet: 'A bright head pulls a soft tail behind the cursor.', @@ -87,6 +88,8 @@ const PREVIEW_DESCRIPTIONS: Record = { vignette: 'The edges darken to draw attention toward the center.', scanlines: 'Fine horizontal lines give the display a CRT texture.', glow: 'Bright text gains a soft halo.', + // NMSh's own effects: one plain line each (the same words on both hosts). + ...Object.fromEntries(NMSH_EFFECTS.map(effect => [effect.id, effect.description])), }; export function createShaderStudio(saved: D, gates: G): ShaderStudioState { @@ -283,7 +286,9 @@ export function renderShaderStudio(state: ShaderStudioState = new Set(['trail', 'comet', 'bla /** The preview kind for a preset (Ghostty and Kitty ids), or undefined when NMSh has no faithful approximation. */ export function previewKind(id: string, category: PresetCategory): PreviewKind | undefined { + // NMSh's own effects each have their own preview (same id on both hosts). + if (nmshEffect(id)) return id as NmshEffectId; if (category === 'cursor') return TRAIL_KINDS[id] ?? 'trail'; if (id === 'aurora' || id === 'northern-lights') return 'aurora'; if (id === 'focus-dim' || id === 'dim-inactive-windows') return 'dim'; @@ -53,7 +56,8 @@ export function previewValues(id: string, values: Readonly = {trail: 1800, comet: 1800, blaze: 2000, lightning: 1100, 'motion-blur': 1600, - aurora: 3600, dim: 1500, vignette: 0, scanlines: 0, glow: 0}; + aurora: 3600, dim: 1500, vignette: 0, scanlines: 0, glow: 0, + 'ink-bloom': 1700, 'type-garden': 2000, filament: 1500, 'glitch-hop': 900, 'gravity-lens': 1400, 'holo-foil': 3600, 'shine-sweep': 2400, 'halo-bloom': 3200}; export interface PreviewInput { kind: PreviewKind; @@ -217,6 +221,8 @@ export function renderShaderPreview(input: PreviewInput): PreviewFrame { const cell = at(x, y)!; cell.bg = mix(cell.bg, {red: 0, green: 0, blue: 0}, Math.min(1, intensity * 1.5)); cell.fg = mix(cell.fg, {red: 0, green: 0, blue: 0}, intensity); } + } else if (nmshEffect(kind)) { + drawNmshPreview(kind as NmshEffectId, {grid, width, rows, t, elapsed: input.still ? duration * 0.6 : input.elapsed, values, colors}); } else if (kind === 'glow') { for (let y = 0; y < rows; y++) for (let x = 0; x < width; x++) { const cell = at(x, y)!; @@ -240,3 +246,178 @@ export function renderShaderPreview(input: PreviewInput): PreviewFrame { }); return {rows: out, busy: !input.still && duration > 0 && input.elapsed < duration}; } + +// ---- NMSh's own effects -------------------------------------------------------------------------------------------- + +interface NmshPreviewInput { + grid: Cell[][]; width: number; rows: number; t: number; elapsed: number; + values: Readonly>; colors: PreviewInput['colors']; +} + +const STRENGTHS = [0.55, 1, 1.7]; +const fromUnit = (rgb: readonly number[]): RgbColor => ({red: channel(rgb[0]), green: channel(rgb[1]), blue: channel(rgb[2])}); +const hash = (x: number, y: number) => { const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return v - Math.floor(v); }; +const rainbow = (phase: number): RgbColor => fromUnit([0, 0.33, 0.67].map(shift => 0.5 + 0.5 * Math.cos(6.283 * (phase + shift)))); + +/** The effect's palette color for a slot, as the shader picks it (theme slots use the sample's cursor color). */ +function paletteColor(id: NmshEffectId, values: Readonly>, slot: number, theme: RgbColor): RgbColor { + const effect = nmshEffect(id)!; + const choice = effect.palette?.[num(values.COLOR, effect.defaults.COLOR ?? 0)] ?? effect.palette?.[0]; + const value = choice?.[Math.min(slot, choice.length - 1)]; + return !value || value === 'theme' ? theme : fromUnit(value); +} + +/** A small colorful block "logo" on the right of the sample, where welcome effects act (plain text stays plain). */ +function drawLogo(grid: Cell[][], width: number): Array<{x: number; y: number; cell: Cell}> { + const x0 = Math.max(0, width - 11); + const shape = ['██ ██ ███', '█████ █ █', '██ ██ ███', '██ ██ █ █']; + const cells: Array<{x: number; y: number; cell: Cell}> = []; + shape.forEach((line, y) => [...line].forEach((char, dx) => { + const cell = grid[y]?.[x0 + dx]; + if (!cell) return; + if (char === ' ') { cell.char = ' '; return; } + cell.char = '█'; cell.fg = rainbow(dx / 11 + y * 0.04); + cells.push({x: x0 + dx, y, cell}); + })); + return cells; +} + +function drawNmshPreview(id: NmshEffectId, input: NmshPreviewInput): void { + const {grid, width, rows, t, values, colors} = input; + const at = (x: number, y: number) => grid[y]?.[x]; + const k = STRENGTHS[num(values.STRENGTH, 1)] ?? 1; + const theme = colors.cursor; + const color = (slot: number) => paletteColor(id, values, slot, theme); + const typed = {x: SAMPLE[2]!.length - 1, y: 2}; + const caret = (x: number, y: number) => { const cell = at(x, y); if (cell) { cell.bg = colors.cursor; cell.fg = colors.background; } }; + if (id === 'ink-bloom') { + // An irregular wash spreads from the glyph just typed, its rim darker, then it dries. + const spread = 1 - (1 - Math.min(1, t * 1.7)) ** 3; + const wet = 1 - t; + const dry = 1 - Math.max(0, (t - 0.5) / 0.5); + for (let y = 0; y < rows; y++) for (let x = 0; x < width; x++) { + const dx = (x - typed.x) / 2; + const dy = y - typed.y; + const angle = Math.atan2(dy, dx); + const radius = (0.6 + 1.5 * spread) * (1 + 0.18 * Math.sin(angle * 2 + 1.3) + 0.12 * Math.sin(angle * 3 - 0.7)); + const r = Math.hypot(dx, dy); + if (r > radius + 0.3) continue; + const rim = Math.abs(r - radius * 0.85) < 0.45 ? 1 : 0; + const ink = mix(color(0), color(1), r / Math.max(radius, 0.1)); + const cell = at(x, y)!; + cell.bg = mix(cell.bg, ink, k * Math.min(0.9, (0.38 * wet + 0.08) * (r < radius ? 1 : 0.4) + rim * 0.45 * dry)); + if (cell.char !== ' ' && r < radius * 0.7) cell.fg = mix(cell.fg, ink, wet * 0.6); + } + caret(typed.x + 1, typed.y); + } else if (id === 'type-garden') { + // Sprouts grow up from the glyph just typed, open small flowers, then rise a row and fade. + for (const [index, dx] of [[0, -1], [1, 0], [2, 1]] as const) { + const q = Math.max(0, Math.min(1, (t - index * 0.09) / (1 - index * 0.09))); + if (q <= 0) continue; + const fade = 1 - Math.max(0, (q - 0.55) / 0.45); + const rise = q > 0.7 ? 1 : 0; + const height = Math.min(2, Math.ceil(q * 2.4 * (index === 1 ? 2 : 1.5))); + for (let h = 1; h <= height; h++) { + const cell = at(typed.x + dx, typed.y - h - rise); + if (!cell) continue; + const top = h === height; + const tint = mix(cell.bg, top && q > 0.25 ? color(1 + (index % 2)) : color(0), Math.min(1, fade * (0.5 + 0.5 * k))); + // Sprouts grow in the gaps; over existing text they only light the cell, as the shader's glow does. + if (cell.char === ' ') { cell.char = top && q > 0.25 ? (index === 1 ? '✿' : '❀') : '│'; cell.fg = tint; } + else cell.bg = mix(cell.bg, tint, 0.45); + } + } + caret(typed.x + 1, typed.y); + } else if (id === 'filament') { + // A coiled wire along the cursor's jump, white-hot at the cursor, cooling to amber and deep red from the far end. + const from = {x: SAMPLE[0]!.length, y: 0}; + const to = {x: 2, y: 2}; + const ramp = (heat: number) => heat > 0.75 ? mix(color(2), {red: 255, green: 247, blue: 230}, (heat - 0.75) / 0.25) + : heat > 0.45 ? mix(color(1), color(2), (heat - 0.45) / 0.3) : mix(colors.background, mix(color(0), color(1), heat / 0.45), Math.min(1, heat / 0.2)); + for (let step = 0; step <= 40; step++) { + const s = step / 40; + const heat = Math.max(0, Math.min(1, 1.12 - t * 1.25 - (1 - s) * (0.25 + 0.6 * t))); + if (heat <= 0) continue; + const cell = at(Math.round(from.x + (to.x - from.x) * s), Math.round(from.y + (to.y - from.y) * s)); + if (!cell) continue; + if (cell.char === ' ') { cell.char = '~'; cell.fg = ramp(heat); } + cell.bg = mix(cell.bg, ramp(heat), Math.min(0.6, heat * 0.45 * k)); + } + caret(to.x, to.y); + } else if (id === 'glitch-hop') { + // Rows along a big jump tear sideways in slices, with colored ghosts, for a moment only. + const frame = Math.floor(input.elapsed / 60); + const env = (1 - t) ** 2; + const tintA = color(0); + const tintB = color(1); + const ghostA = mix(tintA.red + tintA.green + tintA.blue ? tintA : colors.accent, {red: 255, green: 255, blue: 255}, 0.2); + const ghostB = mix(tintB, {red: 255, green: 255, blue: 255}, 0.2); + for (let y = 0; y < rows - 1; y++) { + const r = hash(y, frame); + if (r < 0.35 || env <= 0.05) continue; + const shove = hash(frame, y * 1.7) - 0.5; + const offset = Math.sign(shove || 1) * Math.max(1, Math.round(Math.abs(shove) * 6 * env * k)); + const source = grid[y]!.map(cell => ({...cell})); + for (let x = 0; x < width; x++) { + const from = source[x + offset]; + const cell = grid[y]![x]!; + if (!from) continue; + cell.char = from.char; + cell.fg = from.char === ' ' ? cell.fg : mix(from.fg, offset > 0 ? ghostA : ghostB, Math.min(1, 0.45 + 0.35 * k)); + } + } + caret(2, 2); + } else if (id === 'gravity-lens') { + // Text near the cursor's landing spot is drawn in toward it, overshoots and springs back; a faint ring spreads. + const center = {x: 14, y: 1}; + const spring = Math.exp(-3.6 * t) * Math.cos(t * 7.5) * (1 - Math.exp(-40 * t)); + const source = grid.map(line => line.map(cell => ({...cell}))); + for (let y = 0; y < rows; y++) for (let x = 0; x < width; x++) { + const dx = (x - center.x) / 2; + const dy = y - center.y; + const r = Math.hypot(dx, dy); + if (r > 3.5 || r < 0.5) continue; + const pull = Math.round(spring * k * 1.6 * (r / 3.5) * Math.exp(-((r / 3.5) ** 2)) * 3.2); + const from = source[y]?.[x + Math.sign(x - center.x) * pull]; + const cell = grid[y]![x]!; + if (from) { cell.char = from.char; cell.fg = from.fg; } + const ring = 0.6 + 2.4 * t; + if (Math.abs(r - ring) < 0.4) cell.bg = mix(cell.bg, theme, 0.5 * Math.exp(-4 * t) * k); + } + caret(center.x, center.y); + } else { + const logo = drawLogo(grid, width); + if (id === 'holo-foil') { + // Iridescent bands drift diagonally over the logo, with a few twinkling flecks. + for (const {x, y, cell} of logo) { + const film = values.COLOR === 1 ? mix(rainbow(x * 0.08 + y * 0.12 - t * 0.9), {red: 255, green: 245, blue: 250}, 0.55) + : values.COLOR === 2 ? mix(theme, {red: 255 - theme.red, green: 255 - theme.green, blue: 255 - theme.blue}, 0.5 + 0.5 * Math.sin((x + y) * 0.7 - t * 6)) + : rainbow(x * 0.08 + y * 0.12 - t * 0.9); + cell.fg = mix(cell.fg, film, Math.min(0.9, 0.55 * k)); + if (hash(x, y + Math.floor(t * 6)) > 0.9) { cell.char = '✦'; cell.fg = {red: 255, green: 255, blue: 255}; cell.bg = mix(cell.bg, film, 0.5); } + } + } else if (id === 'shine-sweep') { + // One bright diagonal band crosses the logo; the text beside it is untouched. + const light = paletteColor(id, values, 0, theme); + const x0 = Math.max(0, width - 11); + const center = -0.25 + t * 1.5; + for (const {x, y, cell} of logo) { + const d = (x - x0 + (3 - y) * 0.8) / 12 - center; + const band = Math.max(0, 1 - Math.abs(d) / 0.14); + cell.fg = mix(cell.fg, light, Math.min(1, band * 0.95 * k)); + } + } else { + // A soft glow breathes around the logo's colorful cells. + const breathe = 0.7 + 0.3 * Math.sin(t * Math.PI * 4); + const glowColor = (from: Cell) => values.COLOR === 1 ? theme : from.fg; + for (let y = 0; y < rows; y++) for (let x = 0; x < width; x++) { + const cell = at(x, y)!; + if (logo.some(item => item.cell === cell)) continue; + let best: {d: number; cell: Cell} | undefined; + for (const item of logo) { const d = Math.hypot((item.x - x) / 2, item.y - y); if (!best || d < best.d) best = {d, cell: item.cell}; } + if (!best || best.d > 1.6) continue; + cell.bg = mix(cell.bg, glowColor(best.cell), Math.min(0.75, (0.55 - best.d * 0.28) * breathe * k)); + } + } + } +} diff --git a/tests/appearanceStudioLayout.test.ts b/tests/appearanceStudioLayout.test.ts index deee451f..7023f9b2 100644 --- a/tests/appearanceStudioLayout.test.ts +++ b/tests/appearanceStudioLayout.test.ts @@ -19,11 +19,42 @@ test('shader browser offers category jumps and visible focus with a persistent f for (const [w, h] of sizes) { const rows = renderGhosttyShaderPanel(s, w, h); const plain = rows.map(stripAnsi); - assert.ok(plain.some(row => row.startsWith('›') && row.includes('Aurora')), 'Tab jumps to the next category'); + assert.ok(plain.some(row => row.startsWith('›') && row.includes('Ink Bloom')), 'Tab jumps to the next category (Cursor & typing follows Cursor trail)'); assert.ok(plain.at(-1)?.includes('Esc')); assert.ok(rows.length <= h && rows.every(row => displayWidth(row) <= w)); } }); + +test('NMSh’s own effects appear in their two categories with a one-line description, at 120x40 and 60x20, on both hosts', () => { + const kitty = createKittyShaderPanel(DEFAULT.hostShaders.kitty, {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto'}); + kitty.catalog = buildKittyCatalog('0.49.2', {'types.slang': '// types'}); + const ghostty = createGhosttyShaderPanel(DEFAULT.hostShaders.ghostty, gates); + ghostty.catalog = buildGhosttyCatalog('1.3.1'); + const hosts = [[kitty, renderKittyShaderPanel, kittyShaderKey], [ghostty, renderGhosttyShaderPanel, ghosttyShaderKey]] as const; + for (const [state, render, keyOf] of hosts) { + for (const [w, h] of [[120, 40], [60, 20]] as const) { + state.selected = 0; + const seen = new Map(); + for (let step = 0; step < 60; step++) { + const plain = (render as (s: typeof state, w: number, h: number) => string[])(state, w, h).map(stripAnsi); + assert.ok(plain.length <= h && plain.every(row => displayWidth(row) <= w)); + const focused = plain.find(row => row.startsWith('›')) ?? ''; + for (const label of ['Ink Bloom', 'Type Garden', 'Filament', 'Glitch Hop', 'Gravity Lens', 'Holo Foil', 'Shine Sweep', 'Halo Bloom']) { + if (focused.includes(label) && !seen.has(label)) seen.set(label, plain.join('\n')); + } + (keyOf as (s: typeof state, k: never) => unknown)(state, {kind: 'down'} as never); + } + assert.equal(seen.size, 8, `all eight effects reachable at ${w}x${h}`); + if (w === 120) { + assert.match(seen.get('Ink Bloom')!, /Cursor & typing/u); + assert.match(seen.get('Holo Foil')!, /Welcome & images/u); + assert.match(seen.get('Ink Bloom')!.replace(/\s+/gu, ' '), /watercolor bloom/u); + assert.match(seen.get('Shine Sweep')!.replace(/\s+/gu, ' '), /plain text stays as it is/u); + assert.match(seen.get('Filament')!.replace(/\s+/gu, ' '), /One cursor effect at a time/u); + } + } + } +}); test('hub preserves focused host editing and wrapped actions at every size', () => { const s = createAppearanceHub('Host', {opacity: 0.9, blurModeIndex: 0, blurStrength: 0, selectedIndex: 0}); s.selected = 9; diff --git a/tests/shaderPreview.test.ts b/tests/shaderPreview.test.ts index 1b2b8da1..61a4a39e 100644 --- a/tests/shaderPreview.test.ts +++ b/tests/shaderPreview.test.ts @@ -128,3 +128,33 @@ test('trail styles differ in shape, not only in color: Lightning is never drawn assert.ok(PREVIEW_MS.lightning < PREVIEW_MS.blaze); for (const kind of kinds) assert.equal(renderShaderPreview(input({kind, values, elapsed: PREVIEW_MS[kind]})).busy, false, `${kind} settles`); }); + +test('NMSh’s own effects: each has its own distinct, deterministic preview; still under Reduced Motion; welcome effects leave plain text alone', () => { + const ids = ['ink-bloom', 'type-garden', 'filament', 'glitch-hop', 'gravity-lens', 'holo-foil', 'shine-sweep', 'halo-bloom'] as const; + const frames = new Map(); + for (const id of ids) { + assert.equal(previewKind(id, id.endsWith('foil') || id.endsWith('sweep') || id === 'halo-bloom' ? 'welcome' : 'typing'), id); + const at = (elapsed: number, still = false) => renderShaderPreview(input({kind: id, values: {STRENGTH: 1}, elapsed, still})); + const mid = at(PREVIEW_MS[id] * 0.3); + assert.equal(mid.rows.length, PREVIEW_ROWS); + assert.ok(mid.rows.every(row => stripAnsi(row).length === 40), id); + assert.deepEqual(mid, at(PREVIEW_MS[id] * 0.3), `${id} is deterministic`); + assert.equal(mid.busy, true, `${id} animates while the preview runs`); + assert.equal(at(PREVIEW_MS[id] + 1).busy, false, `${id} settles`); + assert.deepEqual(at(10, true).rows, at(9000, true).rows, `${id} is one still frame under Reduced Motion / Effects Off`); + assert.equal(at(10, true).busy, false); + assert.notDeepEqual(mid.rows, renderShaderPreview(input({kind: 'glow', elapsed: 0})).rows, `${id} draws something`); + frames.set(id, mid.rows.join('\n')); + // Strength changes the preview. + assert.notDeepEqual(renderShaderPreview(input({kind: id, values: {STRENGTH: 0}, elapsed: PREVIEW_MS[id] * 0.3})).rows, mid.rows, `${id} strength`); + } + assert.equal(new Set(frames.values()).size, ids.length, 'every effect looks different'); + // Welcome effects change only the colorful logo (and, for Halo Bloom, the glow around it); the sample text keeps its colors. + const plainText = (rows: string[]) => rows.slice(0, 4).map(row => row.split('\u001b[0m')[0]!.slice(0, 200)); + const base = renderShaderPreview(input({kind: 'holo-foil', values: {STRENGTH: 0}, elapsed: 0, still: true})); + for (const id of ['holo-foil', 'shine-sweep'] as const) { + const frame = renderShaderPreview(input({kind: id, values: {STRENGTH: 2}, elapsed: PREVIEW_MS[id] * 0.4})); + assert.deepEqual(frame.rows.map(row => stripAnsi(row).slice(0, 22)), base.rows.map(row => stripAnsi(row).slice(0, 22)), `${id} keeps the text`); + assert.deepEqual(plainText(frame.rows).map(row => row.slice(0, 60)), plainText(base.rows).map(row => row.slice(0, 60)), `${id} keeps the text colors`); + } +}); From 94e965d789136df836671bb5f64681dd8d2d5df9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:32:01 +0530 Subject: [PATCH 3/3] Kitty shaders: label NMSh's own shader files as NMSh's in the pipeline header; document the custom shader design --- docs/development/native-appearance-welcome/RESEARCH.md | 9 +++++++++ src/appearance/kittyShaders.ts | 2 +- tests/kittyShaders.test.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/development/native-appearance-welcome/RESEARCH.md b/docs/development/native-appearance-welcome/RESEARCH.md index cb175c2c..221be073 100644 --- a/docs/development/native-appearance-welcome/RESEARCH.md +++ b/docs/development/native-appearance-welcome/RESEARCH.md @@ -73,3 +73,12 @@ Kitty0.49.2 installed catalog probe (headless, private HOME): read_kitty_resourc - Kitty's config watcher (`kitten __watch_conf__`) watches only kitty.conf (and /etc/xdg/kitty/kitty.conf), not included files. NMSh's preset switch changes only its included pipeline, so Kitty kept drawing the previous effect. Kitty documents SIGUSR1 as "reload config". One SIGUSR1 reloaded an unchanged `custom_shaders` path (kq/shots/exp-*.png). - Ghostty on macOS reads its macOS config even with a private HOME, and NMSh refuses `--config-file` launches by design, so an NMSh-driven Ghostty write cannot run against a private config. The generated passes were checked by loading them in an isolated instance instead. - Official references: https://sw.kovidgoyal.net/kitty/custom-shaders/, https://sw.kovidgoyal.net/kitty/conf/ (cursor_trail, auto_reload_config, SIGUSR1 reload), https://ghostty.org/docs/config/reference (command, custom-shader). + +## NMSh's own shaders (verified 2026-10-29, Kitty 0.49.2, Ghostty 1.3.1; evidence /Volumes/T7Tmp/ai-dev/nmsh-native-host-fixes-20261029/shaders) +- Eight original effects in two new studio categories. **Cursor & typing** (one at a time, and exclusive with the trails): Ink Bloom, Type Garden, Filament, Glitch Hop, Gravity Lens. **Welcome & images** (combinable with one cursor effect): Holo Foil, Shine Sweep, Halo Bloom. Each has at most three choices: Color (a curated palette or "From theme" = the host's live cursor color), Strength (Subtle/Balanced/Vivid) and Speed (Calm/Normal/Lively), or Shine Sweep's cadence. +- One source, two hosts (`src/appearance/nmshShaders.ts`). The effect cores are a small GLSL subset that is mechanically translated to Slang. Only the wrappers differ. Kitty's colors are linear, so its wrapper converts samples to gamma 2 and back, which keeps thresholds and palettes identical on both hosts. +- Welcome effects act only on *solid colorful* pixels: a pixel and most of a 5 px ring around it must be saturated. Blocks, images and filled logo art pass; plain and colored words stay untouched (screenshots gpu/shots/*-holo-foil-*, *-halo-bloom-*, *-shine-burst-*). +- **Kitty** reports the cursor (`cursor_trail_edge`, `cursor_trail_prev_edge`, `cursor_trail_change_time`) only while its trail animates. All fields read zero otherwise (probe shots k/shots/p*, q*, t-*). Its default `cursor_trail_start_threshold 2` skips one-cell moves, so typing never started a trail; 0 does. With the default decay a one-cell trail ends in under 0.5 s; with `cursor_trail_decay 3 6` it lasted about 1.3 s. While a typing effect is written, NMSh's fragment therefore adds `cursor_trail_start_threshold 0` and `cursor_trail_decay 6 12`, and the review notes any later line that overrides them. The group subscribes to `cursor-trail-move`/`cursor-trail-stop`, which also hides Kitty's built-in trail. A new move restarts the group's animation. +- Kitty looks up shader names in the pipeline's own folder first. NMSh writes `nmsh-.slang` there, records each in the ledger with its hash, never overwrites an unrecorded or edited file, deletes the ones it no longer uses, and R removes exactly them. Values travel as group `var int` lines, so the `.slang` files never change with settings. Kitty compiles with warnings as errors; every effect compiles with Kitty's own `build_custom_shader_pipeline_glsl` (tests/kittyShadersLive). +- **Ghostty** on macOS renders through Metal and fragCoord y grows *downward* (observed: sprouts first grew down). The cursor math (`xy` = the "+Y" corner) is self-consistent either way. Only the direction of "up" differs, so the generator emits `NMSH_UP` from the platform: -1 on darwin, +1 elsewhere. Linux was not verified. The existing Aurora preset assumes y-up and may draw from the bottom on macOS (not changed here). +- Shine Sweep needs Ghostty 1.3 (`iTimeFocus`). On Kitty, "On focus only" uses `animation_start os-window-focus-in`; the timed cadences run every frame (`animation_step 33`). Halo Bloom samples 48 neighbours per pixel and is the most expensive effect. diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index df71a0d3..672a53dc 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -439,7 +439,7 @@ function compose(settings: KittyShaderSettings, context: KittyShaderContext, mod for (const [name, before] of state.shaderFiles) if (!ownShaders.some(shader => shader.name === name) && before.content !== undefined) shaderChanges.push({before}); if (userGroups.length + pipeline.groups.length > KITTY_MAX_GROUPS) throw new Error(`Kitty allows ${KITTY_MAX_GROUPS} shader groups in total; your shaders use ${userGroups.length} and these effects need ${pipeline.groups.length}.`); const header = ['Managed by NMSh (/appearance › Kitty shaders). Edits here are replaced; remove with R there.', - ...active.map(({preset}) => `${preset.definition.label}: Kitty ${preset.origin.kittyVersion} shipped ${preset.origin.resources.map(item => `${item.name} sha256:${item.sha256.slice(0, 12)}`).join(', ')}`)]; + ...active.map(({preset}) => `${preset.definition.label}: ${'nmsh' in preset.definition.source ? 'NMSh’s own' : `Kitty ${preset.origin.kittyVersion} shipped`} ${preset.origin.resources.map(item => `${item.name} sha256:${item.sha256.slice(0, 12)}`).join(', ')}`)]; const pipelineText = active.length ? emitKittyPipeline(pipeline, header) : `${header.map(line => `# ${line}`).join('\n')}\n# No NMSh effect is active (Reduced Motion, Effects Off or a Portable cursor).\n`; if (active.length && !parseKittyPipeline(pipelineText, 'nmsh').ok) throw new Error('NMSh generated a pipeline Kitty would reject; nothing was changed.'); const ours = quoteWord(paths.pipeline); diff --git a/tests/kittyShaders.test.ts b/tests/kittyShaders.test.ts index 15723fac..1f5a890b 100644 --- a/tests/kittyShaders.test.ts +++ b/tests/kittyShaders.test.ts @@ -579,6 +579,7 @@ test('NMSh’s own Kitty shaders are written beside the pipeline, owned, swapped assert.match(plan.pipelineText, /animation_start cursor-trail-move\n {4}animation_stop cursor-trail-stop\n {4}animation_step 16\n {4}var int COLOR = 2\n {4}var int STRENGTH = 2\n {4}shaders nmsh-ink-bloom/u); assert.match(plan.pipelineText, /animation_start os-window-focus-in\n {4}animation_stop 2200[\s\S]*var int CADENCE = 1\n {4}shaders nmsh-shine-sweep/u, 'On focus only starts on focus'); assert.ok(parseKittyPipeline(plan.pipelineText, 'nmsh').ok); + assert.match(plan.pipelineText, /# Ink Bloom: NMSh’s own nmsh-ink-bloom\.slang sha256:/u); for (const id of ['ink-bloom', 'shine-sweep']) assert.match(readFileSync(shader(id), 'utf8'), /^#language slang 2026\n[\s\S]*import kitty_custom_shader_types;[\s\S]*fragment_main/u); const fragment = readFileSync(f.paths.fragment, 'utf8'); assert.match(fragment, /cursor_trail 3\n/u, 'trail turned on for the cursor effect');