icebreaker 2026.2.0

A local AI chat app powered by 🦀Rust, 🧊iced, 🤗Hugging Face, and 🦙llama.cpp
use crate::browser;
use crate::icon;
use crate::model;
use crate::widget::sidebar;

use iced::border;
use iced::font;
use iced::padding;
use iced::widget::{
    Svg, button, center_x, center_y, column, container, float, grid, hover, right_center, row,
    scrollable, space, stack, svg, text, value,
};
use iced::{Center, Element, Fill, Font, Shrink, Task, Theme};
use iced_palace::widget::{ellipsized_text, typewriter};

use std::path::PathBuf;
use std::sync::LazyLock;

pub struct Settings {
    section: Section,
    themes: Vec<Theme>,
}

#[derive(Debug, Clone)]
pub enum Message {
    Open(Section),
    ChangeTheme(Theme),
    OpenTechne,
    PickLibraryFolder,
    PickedLibraryFolder(Option<rfd::FileHandle>),
}

pub enum Action {
    None,
    ChangeTheme(Theme),
    ChangeLibraryFolder(PathBuf),
    Run(Task<Message>),
}

impl Settings {
    pub fn new() -> (Self, Task<Message>) {
        use itertools::Itertools;

        (
            Self {
                section: Section::Storage,
                themes: Theme::ALL
                    .iter()
                    .sorted_by_key(|theme| {
                        (theme.palette().background.relative_luminance() * 1_000.0) as u32
                    })
                    .rev()
                    .cloned()
                    .collect(),
            },
            Task::none(),
        )
    }

    pub fn title(&self) -> &str {
        "Settings"
    }

    pub fn update(&mut self, message: Message) -> Action {
        match message {
            Message::Open(section) => {
                self.section = section;

                Action::None
            }
            Message::ChangeTheme(theme) => Action::ChangeTheme(theme),
            Message::OpenTechne => {
                browser::open("https://github.com/hecrj/techne");

                Action::None
            }
            Message::PickLibraryFolder => Action::Run(Task::perform(
                rfd::AsyncFileDialog::new()
                    .set_title("Choose a folder for your model library...")
                    .pick_folder(),
                Message::PickedLibraryFolder,
            )),
            Message::PickedLibraryFolder(directory) => {
                let Some(directory) = directory else {
                    return Action::None;
                };

                Action::ChangeLibraryFolder(directory.path().to_path_buf())
            }
        }
    }

    pub fn view<'a>(&'a self, library: &model::Library, theme: &'a Theme) -> Element<'a, Message> {
        let section = match self.section {
            Section::Storage => self.storage(library),
            Section::Theme => self.theme(theme),
            Section::Mcp => self.mcp(),
        };

        center_y(scrollable(
            center_x(container(section).max_width(600)).padding(20),
        ))
        .into()
    }

    pub fn storage(&self, library: &model::Library) -> Element<'_, Message> {
        row![
            column![
                text("Model Library")
                    .font(Font {
                        weight: font::Weight::Semibold,
                        ..Font::MONOSPACE
                    })
                    .size(20),
                text("Models will be downloaded and stored in this directory.").width(Fill)
            ]
            .spacing(10),
            row![
                container(value(library.directory().path().display()).font(Font::MONOSPACE))
                    .width(300)
                    .padding(10)
                    .style(container::bordered_box),
                button(icon::folder_open()).on_press(Message::PickLibraryFolder),
            ]
            .align_y(Center)
            .spacing(10)
        ]
        .align_y(Center)
        .spacing(20)
        .into()
    }

    pub fn theme<'a>(&'a self, current: &'a Theme) -> Element<'a, Message> {
        let swatch = |color| {
            container(space::horizontal())
                .width(10)
                .height(10)
                .style(move |_theme: &Theme| {
                    container::Style::default()
                        .background(color)
                        .border(border::rounded(5))
                })
                .into()
        };

        let themes = self.themes.iter().map(|theme| {
            let palette = theme.palette();

            let item = button(
                ellipsized_text(theme.to_string())
                    .font(Font::MONOSPACE)
                    .width(Fill)
                    .wrapping(text::Wrapping::None)
                    .center(),
            )
            .on_press_with(|| Message::ChangeTheme(theme.clone()))
            .padding([10, 0])
            .style(move |_theme, status| {
                let mut style = button::background(theme, status);

                style.border = style.border.rounded(5);

                if current == theme {
                    button::Style {
                        border: style.border.color(current.palette().primary).width(3),
                        ..style
                    }
                } else {
                    style
                }
            });

            let swatches = {
                let colors = [palette.primary, palette.success, palette.danger];

                right_center(row(colors.into_iter().map(swatch)).spacing(5))
                    .padding(padding::right(10))
            };

            if current == theme {
                float(stack![item, swatches]).scale(1.1).into()
            } else {
                hover(item, swatches)
            }
        });

        container(grid(themes).spacing(10).fluid(300).height(Shrink)).into()
    }

    pub fn mcp(&self) -> Element<'_, Message> {
        button(
            column![
                mcp().width(100).height(100),
                typewriter("Coming Soonâ„¢")
                    .font(Font::MONOSPACE)
                    .size(30)
                    .very_slow(),
            ]
            .spacing(20)
            .align_x(Center),
        )
        .on_press(Message::OpenTechne)
        .style(button::text)
        .into()
    }

    pub fn sidebar(&self) -> Element<'_, Message> {
        let header = sidebar::header("Settings", None);

        let sections = [Section::Storage, Section::Theme, Section::Mcp]
            .into_iter()
            .map(|section| {
                sidebar::item(
                    row![section.icon(), text(section.title())]
                        .align_y(Center)
                        .spacing(10),
                    self.section == section,
                    move || Message::Open(section),
                )
            });

        column![header, scrollable(column(sections)).spacing(10)]
            .spacing(10)
            .into()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Section {
    Storage,
    Theme,
    Mcp,
}

impl Section {
    pub fn title(self) -> &'static str {
        match self {
            Self::Storage => "Storage",
            Self::Theme => "Theme",
            Self::Mcp => "MCP",
        }
    }

    pub fn icon(self) -> Element<'static, Message> {
        match self {
            Self::Storage => icon::folder().line_height(1.0).into(),
            Self::Theme => icon::palette().line_height(1.0).into(),
            Self::Mcp => mcp()
                .width(16)
                .height(16)
                .style(|theme: &Theme, _status| svg::Style {
                    color: Some(theme.palette().text),
                })
                .into(),
        }
    }
}

fn mcp() -> Svg<'static> {
    static ICON: LazyLock<svg::Handle> =
        LazyLock::new(|| svg::Handle::from_memory(include_bytes!("../../assets/mcp.svg")));

    svg(ICON.clone()).style(|theme: &Theme, _status| svg::Style {
        color: Some(theme.palette().text),
    })
}