feat: add font size and zoom settings

- Add font_size config field (10-24px, default 14px)
- Add keyboard shortcuts: Ctrl++/- to adjust, Ctrl+0 to reset
- Add font size slider in Settings > Appearance
- Apply font size to Terminal and InputBar via CSS variable
- Persist font size preference between sessions

Closes #19
This commit is contained in:
2026-01-23 18:16:45 -08:00
committed by Naomi Carrigan
parent 13c96a973a
commit 2db858080d
17 changed files with 596 additions and 255 deletions
+78 -53
View File
@@ -1,6 +1,6 @@
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use chrono::{DateTime, Utc, Timelike, Datelike};
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
@@ -12,9 +12,9 @@ pub enum AchievementId {
TokenMaster, // 1,000,000 tokens
// Code Generation
HelloWorld, // First code block
CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks
HelloWorld, // First code block
CodeWizard, // 100 code blocks
ThousandBlocks, // 1,000 code blocks
// File Operations
FileManipulator, // 10 files edited
@@ -22,23 +22,23 @@ pub enum AchievementId {
// Conversation milestones
ConversationStarter, // 10 messages
ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages
ChattyKathy, // 100 messages
Conversationalist, // 1,000 messages
// Tool usage
Toolsmith, // 5 different tools
ToolMaster, // 10 different tools
Toolsmith, // 5 different tools
ToolMaster, // 10 different tools
// Time-based achievements
EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend
EarlyBird, // Started session 5-7 AM
NightOwl, // Coding after midnight
AllNighter, // Worked 2-5 AM
WeekendWarrior, // Coding on weekend
DedicatedDeveloper, // 30 days in a row
// Search and exploration
Explorer, // 50 searches
MasterSearcher, // 500 searches
Explorer, // 50 searches
MasterSearcher, // 500 searches
// Session achievements
QuickSession, // Productive session < 5 min
@@ -47,36 +47,36 @@ pub enum AchievementId {
MarathonSession, // 5+ hour session
// Special achievements
FirstMessage, // First message sent
FirstTool, // First tool used
FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes
FirstMessage, // First message sent
FirstTool, // First tool used
FirstCodeBlock, // First code generated
FirstFileEdit, // First file edit
Polyglot, // 5+ languages in one session
SpeedCoder, // 10 code blocks in 10 minutes
ClaudeConnoisseur, // Used all Claude models
MarathonCoder, // 10k tokens in one session
MarathonCoder, // 10k tokens in one session
// Relationship & Greetings
GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily"
GoodMorning, // Say "good morning"
GoodNight, // Say "good night" or "goodnight"
ThankYou, // Say "thank you" or "thanks"
LoveYou, // Say "love you" or "ily"
// Personality & Fun
EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS
EmojiUser, // Use an emoji in a message
QuestionMaster, // Use "?" in 20 messages
CapsLock, // Send a message in ALL CAPS
PleaseAndThankYou, // Use "please" in messages
// Git & Development
GitGuru, // Use git commands 10 times
TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error")
GitGuru, // Use git commands 10 times
TestWriter, // Create test files
Debugger, // Fix bugs (messages with "fix", "bug", "error")
// Tool Mastery
BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times
BashMaster, // Use Bash tool 50 times
FileExplorer, // Use Read tool 100 times
SearchExpert, // Use Grep tool 50 times
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -509,15 +509,20 @@ pub fn check_message_achievements(
newly_unlocked.push(AchievementId::GoodMorning);
}
if (message_lower.contains("good night") || message_lower.contains("goodnight"))
&& progress.unlock(AchievementId::GoodNight) {
&& progress.unlock(AchievementId::GoodNight)
{
newly_unlocked.push(AchievementId::GoodNight);
}
if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou) {
if (message_lower.contains("thank you")
|| message_lower.contains("thanks")
|| message_lower.contains("thx"))
&& progress.unlock(AchievementId::ThankYou)
{
newly_unlocked.push(AchievementId::ThankYou);
}
if (message_lower.contains("love you") || message_lower.contains("ily"))
&& progress.unlock(AchievementId::LoveYou) {
&& progress.unlock(AchievementId::LoveYou)
{
newly_unlocked.push(AchievementId::LoveYou);
}
@@ -525,9 +530,11 @@ pub fn check_message_achievements(
if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) {
newly_unlocked.push(AchievementId::EmojiUser);
}
if message == message.to_uppercase() && message.len() > 5
if message == message.to_uppercase()
&& message.len() > 5
&& message.chars().any(|c| c.is_alphabetic())
&& progress.unlock(AchievementId::CapsLock) {
&& progress.unlock(AchievementId::CapsLock)
{
newly_unlocked.push(AchievementId::CapsLock);
}
if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) {
@@ -535,8 +542,11 @@ pub fn check_message_achievements(
}
// Git & Development patterns in messages
if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger) {
if (message_lower.contains("fix")
|| message_lower.contains("bug")
|| message_lower.contains("error"))
&& progress.unlock(AchievementId::Debugger)
{
newly_unlocked.push(AchievementId::Debugger);
}
@@ -550,10 +560,12 @@ pub fn check_achievements(
) -> Vec<AchievementId> {
let mut newly_unlocked = Vec::new();
println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
println!(
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
stats.messages_exchanged,
stats.total_input_tokens + stats.total_output_tokens,
stats.code_blocks_generated);
stats.code_blocks_generated
);
println!("Currently unlocked: {:?}", progress.unlocked);
// Token milestones
@@ -617,7 +629,8 @@ pub fn check_achievements(
// Search and exploration
let search_tools = ["Glob", "Grep", "search", "Task"];
let search_count: u64 = search_tools.iter()
let search_count: u64 = search_tools
.iter()
.filter_map(|tool| stats.tools_usage.get(*tool))
.sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
@@ -629,7 +642,10 @@ pub fn check_achievements(
// Session duration achievements
let session_secs = stats.session_duration_seconds;
if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) {
if session_secs < 300
&& stats.session_messages_exchanged >= 5
&& progress.unlock(AchievementId::QuickSession)
{
newly_unlocked.push(AchievementId::QuickSession);
}
if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) {
@@ -716,7 +732,9 @@ pub fn check_achievements(
// Weekend warrior
use chrono::Weekday;
if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) {
if (weekday == Weekday::Sat || weekday == Weekday::Sun)
&& progress.unlock(AchievementId::WeekendWarrior)
{
newly_unlocked.push(AchievementId::WeekendWarrior);
}
}
@@ -733,16 +751,21 @@ pub struct AchievementUnlockedEvent {
}
// Save achievements to persistent store
pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> {
let store = app.store("achievements.json")
.map_err(|e| e.to_string())?;
pub async fn save_achievements(
app: &tauri::AppHandle,
progress: &AchievementProgress,
) -> Result<(), String> {
let store = app.store("achievements.json").map_err(|e| e.to_string())?;
// Create a serializable version with just the unlocked achievement IDs
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
println!("Saving achievements: {:?}", unlocked_list);
store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?);
store.set(
"unlocked",
serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Achievements saved successfully");
@@ -766,7 +789,9 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
// Get unlocked achievements
if let Some(unlocked_value) = store.get("unlocked") {
println!("Found unlocked value in store: {:?}", unlocked_value);
if let Ok(unlocked_list) = serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone()) {
if let Ok(unlocked_list) =
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
{
println!("Loaded {} achievements", unlocked_list.len());
for achievement_id in unlocked_list {
progress.unlocked.insert(achievement_id);
@@ -805,4 +830,4 @@ mod tests {
let newly = progress.take_newly_unlocked();
assert!(newly.is_empty());
}
}
}