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/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/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 ({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..672a53dc 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,9 +426,20 @@ 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(', ')}`)]; + ...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); @@ -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/src/appearance/shaderPreview.ts b/src/appearance/shaderPreview.ts index 00583866..7a1c83d7 100644 --- a/src/appearance/shaderPreview.ts +++ b/src/appearance/shaderPreview.ts @@ -12,8 +12,9 @@ import type {RgbColor} from '../ui/palette.js'; import {colorEscape} from '../chroma/escape.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import type {PresetCategory, ShaderValue} from './kittyShaderCatalog.js'; +import {nmshEffect, type NmshEffectId} from './nmshShaders.js'; -export type PreviewKind = 'trail' | 'comet' | 'blaze' | 'lightning' | 'motion-blur' | 'aurora' | 'dim' | 'vignette' | 'scanlines' | 'glow'; +export type PreviewKind = 'trail' | 'comet' | 'blaze' | 'lightning' | 'motion-blur' | 'aurora' | 'dim' | 'vignette' | 'scanlines' | 'glow' | NmshEffectId; /** * Each trail style has its own shape, modelled on what the shader draws (Kitty 0.49.2's shipped sources; NMSh's Ghostty @@ -27,6 +28,8 @@ const TRAIL_PREVIEWS: ReadonlySet = 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/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..1f5a890b 100644 --- a/tests/kittyShaders.test.ts +++ b/tests/kittyShaders.test.ts @@ -553,3 +553,76 @@ 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); + 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'); + 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})); 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`); + } +});