generated from nhcarrigan/template
feat: custom background image with opacity control
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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()}">
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user