feat: custom background image with opacity control

This commit is contained in:
2026-02-25 15:49:42 -08:00
committed by Naomi Carrigan
parent e8cbe9f647
commit f57397a6c0
7 changed files with 157 additions and 5 deletions
+15
View File
@@ -128,6 +128,13 @@ pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub trusted_workspaces: Vec<String>, pub trusted_workspaces: Vec<String>,
// Background image settings
#[serde(default)]
pub background_image_path: Option<String>,
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -163,6 +170,8 @@ impl Default for HikariConfig {
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: Vec::new(), trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
} }
} }
} }
@@ -199,6 +208,10 @@ fn default_discord_rpc_enabled() -> bool {
true true
} }
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum BudgetAction { pub enum BudgetAction {
@@ -308,6 +321,8 @@ mod tests {
use_worktree: true, use_worktree: true,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+8 -2
View File
@@ -3,7 +3,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character"; import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config"; import { isStreamerMode, configStore } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules"; import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri"; import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states"; import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,6 +14,9 @@
let { onExpand }: Props = $props(); let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
@@ -150,7 +153,10 @@
} }
</script> </script>
<div class="compact-container {getStateGlow()}"> <div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) --> <!-- Character sprite (smaller) -->
<div class="compact-character"> <div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}"> <div class="sprite-wrapper {getAnimationClass()}">
+63
View File
@@ -13,6 +13,7 @@
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import CostSummary from "./CostSummary.svelte"; import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
@@ -56,6 +57,8 @@
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
@@ -242,6 +245,20 @@
await window.setAlwaysOnTop(enabled); await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled }); await configStore.updateConfig({ always_on_top: enabled });
} }
async function pickBackgroundImage() {
const selected = await open({
multiple: false,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
});
if (selected) {
config.background_image_path = selected;
}
}
function clearBackgroundImage() {
config.background_image_path = null;
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -915,6 +932,52 @@
expanded/collapsed to see reasoning details. expanded/collapsed to see reasoning details.
</p> </p>
</div> </div>
<!-- Background Image -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
{#if config.background_image_path}
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
{config.background_image_path.split("/").pop()}
</p>
{/if}
<div class="flex gap-2">
<button
onclick={pickBackgroundImage}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
{config.background_image_path ? "Change Image" : "Choose Image"}
</button>
{#if config.background_image_path}
<button
onclick={clearBackgroundImage}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
title="Remove background image"
>
Clear
</button>
{/if}
</div>
{#if config.background_image_path}
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
<span class="text-xs text-[var(--text-tertiary)]">
{Math.round(config.background_image_opacity * 100)}%
</span>
</div>
<input
id="bg-opacity"
type="range"
bind:value={config.background_image_opacity}
min="0.05"
max="1"
step="0.05"
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
</div>
{/if}
</div>
</section> </section>
<!-- Window Section --> <!-- Window Section -->
+2
View File
@@ -108,6 +108,8 @@
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
+6
View File
@@ -197,6 +197,8 @@ describe("config store", () => {
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -246,6 +248,8 @@ describe("config store", () => {
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -794,6 +798,8 @@ describe("config store", () => {
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
+5
View File
@@ -53,6 +53,9 @@ export interface HikariConfig {
disable_1m_context: boolean; disable_1m_context: boolean;
// Workspaces the user has explicitly trusted // Workspaces the user has explicitly trusted
trusted_workspaces: string[]; trusted_workspaces: string[];
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -96,6 +99,8 @@ const defaultConfig: HikariConfig = {
use_worktree: false, use_worktree: false,
disable_1m_context: false, disable_1m_context: false,
trusted_workspaces: [], trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
function createConfigStore() { function createConfigStore() {
+58 -3
View File
@@ -12,6 +12,7 @@
setSkipNextGreeting, setSkipNextGreeting,
} from "$lib/tauri"; } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config"; import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
@@ -37,6 +38,45 @@
import { debugConsoleStore } from "$lib/stores/debugConsole"; import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos"; import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let backgroundDataUrl = $state<string | null>(null);
let backgroundOpacity = $state(0.3);
const configValues = configStore.config;
$effect(() => {
const cfg = $configValues;
backgroundOpacity = cfg.background_image_opacity;
if (cfg.background_image_path) {
void loadBackgroundImage(cfg.background_image_path);
} else {
backgroundDataUrl = null;
}
});
async function loadBackgroundImage(path: string) {
try {
const data = await readFile(path);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
};
const mime = mimeMap[ext] ?? "image/png";
backgroundDataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
} catch (error) {
console.error("Failed to load background image:", error);
backgroundDataUrl = null;
}
}
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
@@ -473,16 +513,27 @@
}); });
</script> </script>
{#if backgroundDataUrl}
<div
class="fixed inset-0 bg-cover bg-center pointer-events-none"
style="background-image: url('{backgroundDataUrl}'); opacity: {backgroundOpacity}; z-index: 0;"
></div>
{/if}
{#if compactModeActive} {#if compactModeActive}
<!-- Compact mode: minimal widget interface --> <!-- Compact mode: minimal widget interface -->
<div <div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden" class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
> >
<CompactMode onExpand={exitCompactMode} /> <CompactMode onExpand={exitCompactMode} />
</div> </div>
{:else} {:else}
<!-- Full mode: standard interface --> <!-- Full mode: standard interface -->
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"> <div
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<StatusBar <StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode} onToggleCompact={enterCompactMode}
@@ -491,8 +542,12 @@
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50" class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;" ? ''
: 'bg-[var(--bg-secondary)]/50'}"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;{backgroundDataUrl
? ' background: transparent !important;'
: ''}"
> >
<AnimeGirl /> <AnimeGirl />
</div> </div>