From 65e536432082bdd0cfd64d83911229963a0fabab Mon Sep 17 00:00:00 2001 From: jrosh Date: Sun, 14 Sep 2025 21:03:49 +0200 Subject: [PATCH] v0.9 cleanup, remove thinking checkbox, add pulldown-cmark and config --- Cargo.toml | 21 +- src/api.rs | 34 ++- src/app.rs | 168 +++++++++++ src/config.rs | 131 +++++++++ src/main.rs | 10 +- src/markdown_processor.rs | 351 ----------------------- src/markdown_renderer.rs | 402 +++++++++++++++++++++++++++ src/state.rs | 104 ++++++- src/types.rs | 2 - src/ui.rs | 572 -------------------------------------- src/ui/chat.rs | 164 +++++++++++ src/ui/controls.rs | 65 +++++ src/ui/handlers.rs | 352 +++++++++++++++++++++++ src/ui/input.rs | 64 +++++ src/ui/mod.rs | 4 + 15 files changed, 1476 insertions(+), 968 deletions(-) create mode 100644 src/app.rs create mode 100644 src/config.rs delete mode 100644 src/markdown_processor.rs create mode 100644 src/markdown_renderer.rs delete mode 100644 src/ui.rs create mode 100644 src/ui/chat.rs create mode 100644 src/ui/controls.rs create mode 100644 src/ui/handlers.rs create mode 100644 src/ui/input.rs create mode 100644 src/ui/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 81b93ab..142f078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,17 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] -# GTK4 and GLib gtk4 = { version = "0.9", features = ["v4_12"] } - -# HTTP client with JSON support -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] } - -# Async runtime and utilities -tokio = { version = "1.0", features = ["rt-multi-thread", "time"] } -futures-util = "0.3" -async-channel = "2.1" - -# Serialization +glib = "0.20" +tokio = { version = "1.0", features = ["full"] } +reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" \ No newline at end of file +serde_json = "1.0" +toml = "0.8" +dirs = "5.0" +futures-util = "0.3" +async-channel = "2.3" +pulldown-cmark = { version = "0.13.0", default-features = false, features = ["html"] } \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 69d5fe9..630c2b4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -17,8 +17,7 @@ pub async fn send_chat_request_streaming( model: &str, conversation: &Arc>>, token_sender: async_channel::Sender, - thinking_enabled: bool, -) -> Result> { +) -> Result<(String, Option), Box> { let messages = { let conversation = conversation.lock().unwrap(); conversation.iter().cloned().collect::>() @@ -28,7 +27,6 @@ pub async fn send_chat_request_streaming( model: model.to_string(), messages, stream: true, - think: if thinking_enabled { Some(true) } else { Some(false) }, }; let client = reqwest::Client::builder() @@ -51,7 +49,7 @@ pub async fn send_chat_request_streaming( let mut full_response = String::new(); let mut current_batch = String::new(); let mut tokens_since_last_send = 0; - const BATCH_SIZE: usize = 20; // Reasonable batch size + const BATCH_SIZE: usize = 20; const BATCH_TIMEOUT: Duration = Duration::from_millis(100); let mut last_send = tokio::time::Instant::now(); @@ -80,19 +78,19 @@ pub async fn send_chat_request_streaming( || last_send.elapsed() >= BATCH_TIMEOUT || stream_response.done; - if should_send && !current_batch.is_empty() { - // Use non-blocking send with async-channel - match token_sender.send(current_batch.clone()).await { - Ok(_) => { - current_batch.clear(); - tokens_since_last_send = 0; - last_send = tokio::time::Instant::now(); - } - Err(_) => { - // Channel closed, stop sending but continue processing - break; + if should_send { + // Send content batch + if !current_batch.is_empty() { + match token_sender.send(current_batch.clone()).await { + Ok(_) => { + current_batch.clear(); + tokens_since_last_send = 0; + } + Err(_) => break, } } + + last_send = tokio::time::Instant::now(); } if stream_response.done { @@ -108,17 +106,17 @@ pub async fn send_chat_request_streaming( } } - // Send any remaining tokens in the batch + // Send any remaining tokens in the batches if !current_batch.is_empty() { let _ = token_sender.send(current_batch).await; } - // async_channel automatically closes when sender is dropped + // Close channels drop(token_sender); if full_response.is_empty() { return Err("No response received from the model".into()); } - Ok(full_response) + Ok((full_response, None)) } \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..c0f3f8c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,168 @@ +use gtk4::prelude::*; +use gtk4::{Application, ApplicationWindow}; +use gtk4::Orientation; +use gtk4::Box as GtkBox; +use std::rc::Rc; +use std::cell::RefCell; + +use crate::state::{AppState, SharedState}; +use crate::ui::{chat, input, controls, handlers}; +use crate::config::Config; + +pub fn build_ui(app: &Application) { + let window = ApplicationWindow::builder() + .application(app) + .title("Ollama Chat") + .default_width(900) + .default_height(700) + .build(); + + // Initialize shared state (this loads config) + let shared_state: SharedState = Rc::new(RefCell::new(AppState::default())); + + // Setup CSS with config + setup_css(&window, &shared_state.borrow().config); + + // Create main container with proper spacing + let main_container = GtkBox::new(Orientation::Vertical, 12); + main_container.set_margin_top(16); + main_container.set_margin_bottom(16); + main_container.set_margin_start(16); + main_container.set_margin_end(16); + + // Create UI components + let chat_view = chat::create_chat_view(); + let input_area = input::create_input_area(); + let controls_area = controls::create_controls(); + + // Set proper expansion properties + // Chat view should expand to fill available space + chat_view.widget().set_vexpand(true); + chat_view.widget().set_hexpand(true); + + // Input area should not expand vertically but should expand horizontally + input_area.container.set_vexpand(false); + input_area.container.set_hexpand(true); + + // Controls should not expand + controls_area.container.set_vexpand(false); + controls_area.container.set_hexpand(true); + + // Assemble main UI + main_container.append(chat_view.widget()); + main_container.append(&input_area.container); + main_container.append(&controls_area.container); + + window.set_child(Some(&main_container)); + + // Setup event handlers + handlers::setup_handlers( + shared_state, + chat_view, + input_area, + controls_area, + ); + + window.present(); +} + +fn setup_css(window: &ApplicationWindow, config: &Config) { + let css_provider = gtk4::CssProvider::new(); + + // Generate CSS from config + let css_content = generate_css_from_config(config); + css_provider.load_from_string(&css_content); + + gtk4::style_context_add_provider_for_display( + >k4::prelude::WidgetExt::display(window), + &css_provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn generate_css_from_config(config: &Config) -> String { + format!( + r#" + window {{ + font-size: {}px; + background-color: {}; + }} + + .input-container, .input-text, .input-text > * {{ + background-color: {}; + border-radius: 12px; + }} + + .input-text {{ + font-size: {}px; + margin-left: 12px; + padding: 12px; + min-height: 60px; + color: {}; + }} + + .chat-container, .chat-text, .chat-text > * {{ + background-color: {}; + border-radius: 12px; + }} + + .chat-text {{ + font-size: {}px; + padding: 24px; + color: {}; + }} + + .input-text:focus {{ + border-color: {}; + outline: none; + }} + + button {{ + font-size: {}px; + margin-left: 8px; + margin-right: 12px; + border-radius: 8px; + height: 100%; + }} + + .stop-button {{ + background-color: {}; + color: white; + }} + + .send-button {{ + background-color: {}; + color: white; + }} + + dropdown {{ + font-size: {}px; + border-radius: 8px; + min-height: 40px; + }} + + checkbutton {{ + font-size: {}px; + }} + + .status-label {{ + font-size: 14px; + color: #555; + }} + "#, + config.ui.window_font_size, // window font-size + config.colors.window_background, // window background + config.colors.chat_background, // chat background + config.ui.chat_font_size, // chat font-size + config.colors.primary_text, // chat color + config.colors.chat_background, // input background (reuse chat) + config.ui.input_font_size, // input font-size + config.colors.primary_text, // input color + config.colors.link_text, // input focus border + config.ui.window_font_size, // button font-size + config.colors.stop_button, // stop button background + config.colors.send_button, // send button background + config.ui.window_font_size, // dropdown font-size + config.ui.window_font_size, // checkbutton font-size + ) +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7b7be36 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,131 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub ui: UiConfig, + pub colors: ColorConfig, + pub ollama: OllamaConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + pub window_font_size: u32, + pub chat_font_size: u32, + pub input_font_size: u32, + pub code_font_family: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColorConfig { + // Background colors + pub chat_background: String, + pub code_background: String, + pub window_background: String, + + // Text colors + pub primary_text: String, + pub code_text: String, + pub link_text: String, + pub think_text: String, + + // Button colors + pub send_button: String, + pub stop_button: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaConfig { + pub url: String, + pub timeout_seconds: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + ui: UiConfig::default(), + colors: ColorConfig::default(), + ollama: OllamaConfig::default(), + } + } +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + window_font_size: 16, + chat_font_size: 18, + input_font_size: 16, + code_font_family: "monospace".to_string(), + } + } +} + +impl Default for ColorConfig { + fn default() -> Self { + Self { + // Background colors + chat_background: "#ffffff".to_string(), + code_background: "#f5f5f5".to_string(), + window_background: "#fafafa".to_string(), + + // Text colors + primary_text: "#333333".to_string(), + code_text: "#d63384".to_string(), + link_text: "#0066cc".to_string(), + think_text: "#6666cc".to_string(), + + // Button colors + send_button: "#007bff".to_string(), + stop_button: "#dc3545".to_string(), + } + } +} + +impl Default for OllamaConfig { + fn default() -> Self { + Self { + url: "http://localhost:11434".to_string(), + timeout_seconds: 120, + } + } +} + +impl Config { + pub fn load() -> Result> { + let config_path = Self::get_config_path()?; + + if config_path.exists() { + let content = fs::read_to_string(&config_path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } else { + // Create default config file + let default_config = Config::default(); + default_config.save()?; + Ok(default_config) + } + } + + pub fn save(&self) -> Result<(), Box> { + let config_path = Self::get_config_path()?; + + // Create directory if it doesn't exist + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + fs::write(&config_path, content)?; + Ok(()) + } + + fn get_config_path() -> Result> { + let config_dir = dirs::config_dir() + .ok_or("Could not determine config directory")? + .join("ollama-chat"); + + Ok(config_dir.join("config.toml")) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index aa81e45..b1eba97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,18 @@ use gtk4::prelude::*; use gtk4::{glib, Application}; -mod ui; +mod app; +mod state; mod api; mod types; -mod state; -mod markdown_processor; +mod markdown_renderer; +mod ui; +mod config; const APP_ID: &str = "com.example.ollama-chat"; fn main() -> glib::ExitCode { let app = Application::builder().application_id(APP_ID).build(); - app.connect_activate(ui::build_ui); + app.connect_activate(app::build_ui); app.run() } \ No newline at end of file diff --git a/src/markdown_processor.rs b/src/markdown_processor.rs deleted file mode 100644 index c677a3d..0000000 --- a/src/markdown_processor.rs +++ /dev/null @@ -1,351 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{TextBuffer, TextTag, TextTagTable}; -use std::collections::HashMap; - -pub struct MarkdownProcessor { - h1_tag: TextTag, - h2_tag: TextTag, - h3_tag: TextTag, - h4_tag: TextTag, - h5_tag: TextTag, - h6_tag: TextTag, - bold_tag: TextTag, - italic_tag: TextTag, - code_tag: TextTag, -} - -impl MarkdownProcessor { - pub fn new() -> Self { - // Heading tags - let h1_tag = TextTag::new(Some("h1")); - h1_tag.set_weight(700); - h1_tag.set_line_height(1.4); - h1_tag.set_scale(1.5); - - let h2_tag = TextTag::new(Some("h2")); - h2_tag.set_weight(700); - h2_tag.set_line_height(1.4); - h2_tag.set_scale(1.4); - - let h3_tag = TextTag::new(Some("h3")); - h3_tag.set_weight(700); - h3_tag.set_line_height(1.2); - h3_tag.set_scale(1.3); - - let h4_tag = TextTag::new(Some("h4")); - h4_tag.set_weight(700); - h4_tag.set_line_height(1.2); - h4_tag.set_scale(1.2); - - let h5_tag = TextTag::new(Some("h5")); - h5_tag.set_weight(700); - h5_tag.set_line_height(1.2); - h5_tag.set_scale(1.1); - - let h6_tag = TextTag::new(Some("h6")); - h6_tag.set_weight(700); - h6_tag.set_line_height(1.2); - h6_tag.set_scale(1.0); - - // Inline formatting tags - let bold_tag = TextTag::new(Some("bold")); - bold_tag.set_weight(700); - - let italic_tag = TextTag::new(Some("italic")); - italic_tag.set_style(gtk4::pango::Style::Italic); - - let code_tag = TextTag::new(Some("code")); - code_tag.set_property("font", &"monospace"); - code_tag.set_scale(0.9); - - Self { - h1_tag, - h2_tag, - h3_tag, - h4_tag, - h5_tag, - h6_tag, - bold_tag, - italic_tag, - code_tag, - } - } - - pub fn setup_tags(&self, buffer: &TextBuffer) { - let tag_table = buffer.tag_table(); - tag_table.add(&self.h1_tag); - tag_table.add(&self.h2_tag); - tag_table.add(&self.h3_tag); - tag_table.add(&self.h4_tag); - tag_table.add(&self.h5_tag); - tag_table.add(&self.h6_tag); - tag_table.add(&self.bold_tag); - tag_table.add(&self.italic_tag); - tag_table.add(&self.code_tag); - } - - pub fn insert_formatted_text(&self, buffer: &TextBuffer, text: &str, iter: &mut gtk4::TextIter) { - // Process the text line by line to handle headings and inline formatting - let lines: Vec<&str> = text.lines().collect(); - - for (i, line) in lines.iter().enumerate() { - // Check if this line is a heading - if let Some(heading) = self.parse_heading(line) { - let tag = match heading.level { - 1 => &self.h1_tag, - 2 => &self.h2_tag, - 3 => &self.h3_tag, - 4 => &self.h4_tag, - 5 => &self.h5_tag, - _ => &self.h6_tag, - }; - - buffer.insert(iter, "\n"); - buffer.insert_with_tags(iter, &heading.content, &[tag]); - buffer.insert(iter, "\n"); - } else { - // Process inline formatting for non-heading lines - self.insert_formatted_line(buffer, line, iter); - - // Add newline if not the last line - if i < lines.len() - 1 { - buffer.insert(iter, "\n"); - } - } - } - } - - fn insert_formatted_line(&self, buffer: &TextBuffer, line: &str, iter: &mut gtk4::TextIter) { - let segments = self.parse_inline_formatting(line); - - for segment in segments { - match segment.format_type { - FormatType::Plain => { - buffer.insert(iter, &segment.content); - } - FormatType::Bold => { - buffer.insert_with_tags(iter, &segment.content, &[&self.bold_tag]); - } - FormatType::Italic => { - buffer.insert_with_tags(iter, &segment.content, &[&self.italic_tag]); - } - FormatType::Code => { - buffer.insert_with_tags(iter, &segment.content, &[&self.code_tag]); - } - FormatType::BoldItalic => { - buffer.insert_with_tags(iter, &segment.content, &[&self.bold_tag, &self.italic_tag]); - } - FormatType::Heading(_) => { - // This shouldn't happen in inline processing - buffer.insert(iter, &segment.content); - } - } - } - } - - fn parse_heading(&self, line: &str) -> Option { - if line.starts_with('#') { - let hash_count = line.chars().take_while(|&c| c == '#').count(); - - if hash_count <= 6 && line.len() > hash_count + 1 && line.chars().nth(hash_count) == Some(' ') { - let content = &line[hash_count + 1..].trim(); - return Some(HeadingInfo { - level: hash_count, - content: content.to_string(), - }); - } - } - None - } - - fn parse_inline_formatting(&self, text: &str) -> Vec { - let mut segments = Vec::new(); - let mut current_pos = 0; - let chars: Vec = text.chars().collect(); - - while current_pos < chars.len() { - // Look for the next formatting marker - if let Some((marker_pos, marker_type, marker_len)) = self.find_next_marker(&chars, current_pos) { - // Add any plain text before the marker - if marker_pos > current_pos { - let plain_text: String = chars[current_pos..marker_pos].iter().collect(); - if !plain_text.is_empty() { - segments.push(TextSegment { - content: plain_text, - format_type: FormatType::Plain, - }); - } - } - - // Find the closing marker - if let Some((close_pos, close_len)) = self.find_closing_marker(&chars, marker_pos + marker_len, &marker_type) { - let content_start = marker_pos + marker_len; - let content_end = close_pos; - - if content_start < content_end { - let content: String = chars[content_start..content_end].iter().collect(); - segments.push(TextSegment { - content, - format_type: marker_type, - }); - } - - current_pos = close_pos + close_len; - } else { - // No closing marker found, treat as plain text - let plain_char: String = chars[marker_pos..marker_pos + marker_len].iter().collect(); - segments.push(TextSegment { - content: plain_char, - format_type: FormatType::Plain, - }); - current_pos = marker_pos + marker_len; - } - } else { - // No more markers, add the rest as plain text - let remaining: String = chars[current_pos..].iter().collect(); - if !remaining.is_empty() { - segments.push(TextSegment { - content: remaining, - format_type: FormatType::Plain, - }); - } - break; - } - } - - segments - } - - fn find_next_marker(&self, chars: &[char], start_pos: usize) -> Option<(usize, FormatType, usize)> { - let mut earliest_pos = None; - let mut earliest_type = FormatType::Plain; - let mut earliest_len = 0; - - for pos in start_pos..chars.len() { - // Check for inline code (backticks) - if chars[pos] == '`' { - if earliest_pos.is_none() || pos < earliest_pos.unwrap() { - earliest_pos = Some(pos); - earliest_type = FormatType::Code; - earliest_len = 1; - } - break; // Prioritize code as it can contain other markers - } - - // Check for bold/italic markers - if pos + 1 < chars.len() { - // Check for ** (bold) or *** (bold+italic) - if chars[pos] == '*' && chars[pos + 1] == '*' { - if pos + 2 < chars.len() && chars[pos + 2] == '*' { - // *** bold+italic - if earliest_pos.is_none() || pos < earliest_pos.unwrap() { - earliest_pos = Some(pos); - earliest_type = FormatType::BoldItalic; - earliest_len = 3; - } - } else { - // ** bold - if earliest_pos.is_none() || pos < earliest_pos.unwrap() { - earliest_pos = Some(pos); - earliest_type = FormatType::Bold; - earliest_len = 2; - } - } - break; - } - - // Check for __ (bold) - if chars[pos] == '_' && chars[pos + 1] == '_' { - if earliest_pos.is_none() || pos < earliest_pos.unwrap() { - earliest_pos = Some(pos); - earliest_type = FormatType::Bold; - earliest_len = 2; - } - break; - } - } - - // Check for single * or _ (italic) - if chars[pos] == '*' || chars[pos] == '_' { - // Make sure it's not part of a double marker - let is_single = (pos == 0 || chars[pos - 1] != chars[pos]) && - (pos + 1 >= chars.len() || chars[pos + 1] != chars[pos]); - - if is_single && (earliest_pos.is_none() || pos < earliest_pos.unwrap()) { - earliest_pos = Some(pos); - earliest_type = FormatType::Italic; - earliest_len = 1; - } - } - } - - earliest_pos.map(|pos| (pos, earliest_type, earliest_len)) - } - - fn find_closing_marker(&self, chars: &[char], start_pos: usize, marker_type: &FormatType) -> Option<(usize, usize)> { - match marker_type { - FormatType::Code => { - // Look for closing backtick - for pos in start_pos..chars.len() { - if chars[pos] == '`' { - return Some((pos, 1)); - } - } - } - FormatType::Bold => { - // Look for closing ** or __ - for pos in start_pos..chars.len().saturating_sub(1) { - if (chars[pos] == '*' && chars[pos + 1] == '*') || - (chars[pos] == '_' && chars[pos + 1] == '_') { - return Some((pos, 2)); - } - } - } - FormatType::Italic => { - // Look for closing * or _ - for pos in start_pos..chars.len() { - if chars[pos] == '*' || chars[pos] == '_' { - // Make sure it's not part of a double marker - let is_single = (pos == 0 || chars[pos - 1] != chars[pos]) && - (pos + 1 >= chars.len() || chars[pos + 1] != chars[pos]); - if is_single { - return Some((pos, 1)); - } - } - } - } - FormatType::BoldItalic => { - // Look for closing *** - for pos in start_pos..chars.len().saturating_sub(2) { - if chars[pos] == '*' && chars[pos + 1] == '*' && chars[pos + 2] == '*' { - return Some((pos, 3)); - } - } - } - _ => {} - } - None - } -} - -#[derive(Debug, Clone)] -struct TextSegment { - content: String, - format_type: FormatType, -} - -#[derive(Debug, Clone)] -struct HeadingInfo { - level: usize, - content: String, -} - -#[derive(Debug, Clone, PartialEq)] -enum FormatType { - Plain, - Heading(usize), - Bold, - Italic, - Code, - BoldItalic, -} \ No newline at end of file diff --git a/src/markdown_renderer.rs b/src/markdown_renderer.rs new file mode 100644 index 0000000..5cab358 --- /dev/null +++ b/src/markdown_renderer.rs @@ -0,0 +1,402 @@ +use gtk4::prelude::*; +use gtk4::{TextBuffer, TextTag, TextIter}; +use pulldown_cmark::{Parser, Event, Tag, TagEnd, HeadingLevel, Options}; +use crate::config::Config; + +pub struct MarkdownRenderer { + // Text formatting tags + h1_tag: TextTag, + h2_tag: TextTag, + h3_tag: TextTag, + h4_tag: TextTag, + h5_tag: TextTag, + h6_tag: TextTag, + bold_tag: TextTag, + italic_tag: TextTag, + code_tag: TextTag, + code_block_tag: TextTag, + link_tag: TextTag, + quote_tag: TextTag, + think_tag: TextTag, + + // State for nested formatting + format_stack: Vec, + // Track if tags are already setup + tags_setup: bool, + // State for streaming think tag processing + in_think_tag: bool, + think_buffer: String, +} + +impl MarkdownRenderer { + pub fn new() -> Self { + Self { + h1_tag: TextTag::new(Some("h1")), + h2_tag: TextTag::new(Some("h2")), + h3_tag: TextTag::new(Some("h3")), + h4_tag: TextTag::new(Some("h4")), + h5_tag: TextTag::new(Some("h5")), + h6_tag: TextTag::new(Some("h6")), + bold_tag: TextTag::new(Some("bold")), + italic_tag: TextTag::new(Some("italic")), + code_tag: TextTag::new(Some("code")), + code_block_tag: TextTag::new(Some("code_block")), + link_tag: TextTag::new(Some("link")), + quote_tag: TextTag::new(Some("quote")), + think_tag: TextTag::new(Some("think")), + format_stack: Vec::new(), + tags_setup: false, + in_think_tag: false, + think_buffer: String::new(), + } + } + + pub fn setup_tags(&mut self, buffer: &TextBuffer, config: &Config) { + if self.tags_setup { + return; // Tags already setup + } + + // Configure heading tags + self.h1_tag.set_weight(700); + self.h1_tag.set_scale(2.0); + self.h1_tag.set_property("pixels-above-lines", 12); + self.h1_tag.set_property("pixels-below-lines", 6); + + self.h2_tag.set_weight(700); + self.h2_tag.set_scale(1.5); + self.h2_tag.set_property("pixels-above-lines", 10); + self.h2_tag.set_property("pixels-below-lines", 5); + + self.h3_tag.set_weight(700); + self.h3_tag.set_scale(1.3); + self.h3_tag.set_property("pixels-above-lines", 8); + self.h3_tag.set_property("pixels-below-lines", 4); + + self.h4_tag.set_weight(700); + self.h4_tag.set_scale(1.1); + self.h4_tag.set_property("pixels-above-lines", 6); + self.h4_tag.set_property("pixels-below-lines", 3); + + self.h5_tag.set_weight(700); + self.h5_tag.set_scale(1.0); + self.h5_tag.set_property("pixels-above-lines", 4); + self.h5_tag.set_property("pixels-below-lines", 2); + + self.h6_tag.set_weight(600); + self.h6_tag.set_scale(0.9); + self.h6_tag.set_property("pixels-above-lines", 3); + self.h6_tag.set_property("pixels-below-lines", 2); + + // Configure text formatting tags + self.bold_tag.set_weight(700); + self.italic_tag.set_style(gtk4::pango::Style::Italic); + + // Configure code tags with config colors + self.code_tag.set_family(Some(&config.ui.code_font_family)); + if let Ok(bg_color) = parse_color(&config.colors.code_background) { + self.code_tag.set_background_rgba(Some(&bg_color)); + } + if let Ok(fg_color) = parse_color(&config.colors.code_text) { + self.code_tag.set_foreground_rgba(Some(&fg_color)); + } + + self.code_block_tag.set_family(Some(&config.ui.code_font_family)); + if let Ok(bg_color) = parse_color(&config.colors.code_background) { + self.code_block_tag.set_background_rgba(Some(&bg_color)); + } + if let Ok(fg_color) = parse_color(&config.colors.code_text) { + self.code_block_tag.set_foreground_rgba(Some(&fg_color)); + } + self.code_block_tag.set_property("left-margin", 20); + self.code_block_tag.set_property("right-margin", 20); + self.code_block_tag.set_property("pixels-above-lines", 8); + self.code_block_tag.set_property("pixels-below-lines", 8); + + // Configure link tag with config color + if let Ok(link_color) = parse_color(&config.colors.link_text) { + self.link_tag.set_foreground_rgba(Some(&link_color)); + } + self.link_tag.set_underline(gtk4::pango::Underline::Single); + + // Configure quote tag + self.quote_tag.set_foreground_rgba(Some(>k4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0))); + self.quote_tag.set_style(gtk4::pango::Style::Italic); + self.quote_tag.set_property("left-margin", 20); + self.quote_tag.set_property("pixels-above-lines", 4); + self.quote_tag.set_property("pixels-below-lines", 4); + + // Configure think tag with config color + if let Ok(think_color) = parse_color(&config.colors.think_text) { + self.think_tag.set_foreground_rgba(Some(&think_color)); + } + self.think_tag.set_style(gtk4::pango::Style::Italic); + self.think_tag.set_scale(0.9); + self.think_tag.set_property("left-margin", 15); + self.think_tag.set_property("right-margin", 15); + self.think_tag.set_property("pixels-above-lines", 6); + self.think_tag.set_property("pixels-below-lines", 6); + + // Add tags to buffer + let tag_table = buffer.tag_table(); + let tags = [ + &self.h1_tag, &self.h2_tag, &self.h3_tag, + &self.h4_tag, &self.h5_tag, &self.h6_tag, + &self.bold_tag, &self.italic_tag, &self.code_tag, + &self.code_block_tag, &self.link_tag, &self.quote_tag, + &self.think_tag, + ]; + + for tag in tags { + if let Some(tag_name) = tag.name() { + if tag_table.lookup(&tag_name).is_none() { + tag_table.add(tag); + } + } + } + + self.tags_setup = true; + } + + + /// Render markdown starting at the given iterator position without clearing the buffer + pub fn render_markdown_at_iter(&mut self, buffer: &TextBuffer, markdown_text: &str, iter: &mut TextIter, config: &Config) { + // Ensure tags are setup with current config + self.setup_tags(buffer, config); + + // Process text for think tags during streaming + let processed_text = self.process_streaming_text(buffer, markdown_text, iter); + + if !processed_text.is_empty() { + // Configure pulldown-cmark options + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + + let parser = Parser::new_ext(&processed_text, options); + + for event in parser { + self.process_event(buffer, iter, event); + } + } + } + + /// Process text for streaming, handling think tags in real-time + fn process_streaming_text(&mut self, buffer: &TextBuffer, text: &str, iter: &mut TextIter) -> String { + let mut result = String::new(); + let mut remaining = text; + + while !remaining.is_empty() { + if self.in_think_tag { + // We're currently inside a think tag, look for closing tag + if let Some(end_pos) = remaining.find("") { + // Found closing tag - stream the remaining think content + let final_think_content = &remaining[..end_pos]; + if !final_think_content.is_empty() { + buffer.insert_with_tags(iter, final_think_content, &[&self.think_tag]); + } + + // Close the think section + buffer.insert(iter, "\n\n"); + + // Reset think state + self.in_think_tag = false; + self.think_buffer.clear(); + + // Continue with text after closing tag + remaining = &remaining[end_pos + 8..]; // 8 = "".len() + } else { + // No closing tag yet, stream the think content as it arrives + if !remaining.is_empty() { + buffer.insert_with_tags(iter, remaining, &[&self.think_tag]); + } + break; // Wait for more streaming content + } + } else { + // Not in think tag, look for opening tag + if let Some(start_pos) = remaining.find("") { + // Add content before think tag to result for normal processing + result.push_str(&remaining[..start_pos]); + + // Start think mode and show the think indicator + self.in_think_tag = true; + self.think_buffer.clear(); + buffer.insert(iter, "\nšŸ’­ "); + + // Continue with content after opening tag + remaining = &remaining[start_pos + 7..]; // 7 = "".len() + } else { + // No think tag found, add all remaining text to result + result.push_str(remaining); + break; + } + } + } + + result + } + + fn process_event(&mut self, buffer: &TextBuffer, iter: &mut TextIter, event: Event) { + match event { + Event::Start(tag) => { + self.handle_start_tag(buffer, iter, tag); + } + Event::End(tag_end) => { + self.handle_end_tag(buffer, iter, tag_end); + } + Event::Text(text) => { + self.insert_text(buffer, iter, &text); + } + Event::Code(code) => { + let active_tags: Vec<&TextTag> = self.format_stack.iter().collect(); + let mut all_tags = vec![&self.code_tag]; + all_tags.extend(active_tags); + buffer.insert_with_tags(iter, &code, &all_tags); + } + Event::Html(html) => { + // Skip HTML for security - or you could sanitize it + buffer.insert(iter, &format!("[HTML: {}]", html)); + } + Event::FootnoteReference(name) => { + buffer.insert(iter, &format!("[^{}]", name)); + } + Event::SoftBreak => { + buffer.insert(iter, " "); + } + Event::HardBreak => { + buffer.insert(iter, "\n"); + } + Event::Rule => { + buffer.insert(iter, "\n────────────────────────────────────────\n"); + } + Event::TaskListMarker(checked) => { + let marker = if checked { "ā˜‘ " } else { "☐ " }; + buffer.insert(iter, marker); + } + Event::InlineMath(math) => { + // For inline math, you might want to render it differently + buffer.insert(iter, &format!("${}$", math)); + } + Event::DisplayMath(math) => { + // For display math, typically rendered in its own line + buffer.insert(iter, &format!("\n$$\n{}\n$$\n", math)); + } + Event::InlineHtml(html) => { + // Similar to HTML handling, you might want to skip or sanitize + buffer.insert(iter, &format!("[InlineHTML: {}]", html)); + } + } + } + + fn handle_start_tag(&mut self, buffer: &TextBuffer, iter: &mut TextIter, tag: Tag) { + let format_tag = match tag { + Tag::Heading { level, .. } => { + // Add some spacing before headings if not at start + if iter.offset() > 0 { + buffer.insert(iter, "\n\n"); + } + match level { + HeadingLevel::H1 => Some(&self.h1_tag), + HeadingLevel::H2 => Some(&self.h2_tag), + HeadingLevel::H3 => Some(&self.h3_tag), + HeadingLevel::H4 => Some(&self.h4_tag), + HeadingLevel::H5 => Some(&self.h5_tag), + HeadingLevel::H6 => Some(&self.h6_tag), + } + } + Tag::Paragraph => { + // Add spacing before paragraphs if not at start + if iter.offset() > 0 { + buffer.insert(iter, "\n\n"); + } + None + } + Tag::Emphasis => Some(&self.italic_tag), + Tag::Strong => Some(&self.bold_tag), + Tag::Link { .. } => Some(&self.link_tag), + Tag::CodeBlock(_) => { + if iter.offset() > 0 { + buffer.insert(iter, "\n\n"); + } + Some(&self.code_block_tag) + } + Tag::BlockQuote(_) => { + if iter.offset() > 0 { + buffer.insert(iter, "\n\n"); + } + Some(&self.quote_tag) + } + Tag::List(_) => { + if iter.offset() > 0 { + buffer.insert(iter, "\n\n"); + } + None + } + Tag::Item => { + buffer.insert(iter, "• "); + None + } + _ => None, + }; + + if let Some(tag_ref) = format_tag { + self.format_stack.push(tag_ref.clone()); + } + } + + fn handle_end_tag(&mut self, buffer: &TextBuffer, iter: &mut TextIter, tag_end: TagEnd) { + match tag_end { + TagEnd::Heading(_) => { + buffer.insert(iter, "\n"); + if !self.format_stack.is_empty() { + self.format_stack.pop(); + } + } + TagEnd::Paragraph => { + // Paragraph end handled by next element start + } + TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => { + if !self.format_stack.is_empty() { + self.format_stack.pop(); + } + } + TagEnd::CodeBlock | TagEnd::BlockQuote(_) => { + buffer.insert(iter, "\n"); + if !self.format_stack.is_empty() { + self.format_stack.pop(); + } + } + TagEnd::Item => { + buffer.insert(iter, "\n"); + } + _ => {} + } + } + + fn insert_text(&self, buffer: &TextBuffer, iter: &mut TextIter, text: &str) { + if self.format_stack.is_empty() { + buffer.insert(iter, text); + } else { + // Apply all active formatting tags + let tags: Vec<&TextTag> = self.format_stack.iter().collect(); + buffer.insert_with_tags(iter, text, &tags); + } + } +} + +/// Helper function to parse color strings (hex format) into RGBA +fn parse_color(color_str: &str) -> Result> { + let color_str = color_str.trim_start_matches('#'); + + if color_str.len() != 6 { + return Err("Color must be in #RRGGBB format".into()); + } + + let r = u8::from_str_radix(&color_str[0..2], 16)? as f32 / 255.0; + let g = u8::from_str_radix(&color_str[2..4], 16)? as f32 / 255.0; + let b = u8::from_str_radix(&color_str[4..6], 16)? as f32 / 255.0; + + Ok(gtk4::gdk::RGBA::new(r, g, b, 1.0)) +} \ No newline at end of file diff --git a/src/state.rs b/src/state.rs index cbb9392..a9d2e23 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,21 +1,107 @@ -use std::sync::{Arc, Mutex}; +use std::rc::Rc; +use std::cell::RefCell; +use tokio::task::JoinHandle; use crate::types::ChatMessage; +use crate::config::Config; + +pub type SharedState = Rc>; + +#[derive(Debug)] +pub enum AppError { + Api(String), + Ui(String), + State(String), + Validation(String), + Config(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::Api(msg) => write!(f, "API Error: {}", msg), + AppError::Ui(msg) => write!(f, "UI Error: {}", msg), + AppError::State(msg) => write!(f, "State Error: {}", msg), + AppError::Validation(msg) => write!(f, "Validation Error: {}", msg), + AppError::Config(msg) => write!(f, "Config Error: {}", msg), + } + } +} + +impl std::error::Error for AppError {} + +pub type AppResult = Result; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ButtonState { + Send, + Stop, +} -#[derive(Clone)] pub struct AppState { - pub conversation: Arc>>, + pub conversation: Vec, pub ollama_url: String, - pub thinking_enabled: Arc>, - pub current_request_handle: Arc>>>, + pub is_generating: bool, + pub button_state: ButtonState, + pub current_task: Option>, + pub selected_model: Option, + pub status_message: String, + pub config: Config, } impl Default for AppState { fn default() -> Self { + let config = Config::load().unwrap_or_else(|e| { + eprintln!("Warning: Failed to load config, using defaults: {}", e); + Config::default() + }); + Self { - conversation: Arc::new(Mutex::new(Vec::new())), - ollama_url: "http://localhost:11434".to_string(), - thinking_enabled: Arc::new(Mutex::new(false)), - current_request_handle: Arc::new(Mutex::new(None)), + conversation: Vec::new(), + ollama_url: config.ollama.url.clone(), + is_generating: false, + button_state: ButtonState::Send, + current_task: None, + selected_model: None, + status_message: "Ready".to_string(), + config, } } +} + +impl AppState { + pub fn set_generating(&mut self, generating: bool) { + self.is_generating = generating; + self.button_state = if generating { + ButtonState::Stop + } else { + ButtonState::Send + }; + } + + pub fn add_user_message(&mut self, content: String) { + self.conversation.push(ChatMessage { + role: "user".to_string(), + content, + }); + } + + pub fn add_assistant_message(&mut self, content: String) { + self.conversation.push(ChatMessage { + role: "assistant".to_string(), + content + }); + } + + pub fn set_status(&mut self, message: String) { + self.status_message = message; + } + + pub fn abort_current_task(&mut self) { + if let Some(task) = self.current_task.take() { + task.abort(); + } + self.set_generating(false); + self.set_status("Generation stopped".to_string()); + } + } \ No newline at end of file diff --git a/src/types.rs b/src/types.rs index a454324..1a16ef1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -11,8 +11,6 @@ pub struct ChatRequest { pub model: String, pub messages: Vec, pub stream: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub think: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index d37ea5d..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,572 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{glib, Application, ApplicationWindow, Button, DropDown, Label, ScrolledWindow, TextView, TextBuffer, StringList, Orientation, PolicyType, WrapMode, Align, CheckButton}; -use gtk4::Box as GtkBox; -use glib::{spawn_future_local, clone}; -use std::sync::{OnceLock, Arc, Mutex}; -use tokio::runtime::Runtime; - -use crate::api; -use crate::state::AppState; -use crate::types::ChatMessage; -use crate::markdown_processor::MarkdownProcessor; - -// enum to track button state -#[derive(Clone, PartialEq)] -enum ButtonState { - Send, - Stop, -} - -pub fn build_ui(app: &Application) { - let window = ApplicationWindow::builder() - .application(app) - .title("Ollama Chat") - .default_width(900) - .default_height(700) - .build(); - - let css_provider = gtk4::CssProvider::new(); - css_provider.load_from_string( - r#" - window { - font-size: 16px; - } - - .chat-text { - font-size: 18px; - padding: 24px; - border-radius: 12px; - } - - .input-text { - font-size: 16px; - padding: 16px; - border-radius: 12px; - } - - button { - font-size: 16px; - padding: 16px 24px; - border-radius: 12px; - } - - .stop-button { - background-color: #dc3545; - color: white; - } - - .send-button { - background-color: #007bff; - color: white; - } - - dropdown { - font-size: 16px; - border-radius: 12px; - } - - checkbutton { - font-size: 16px; - } - "# - ); - - gtk4::style_context_add_provider_for_display( - >k4::prelude::WidgetExt::display(&window), - &css_provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - - // Main container with padding - let main_container = GtkBox::new(Orientation::Vertical, 24); - main_container.set_margin_top(24); - main_container.set_margin_bottom(24); - main_container.set_margin_start(24); - main_container.set_margin_end(24); - - // Chat display area - let chat_scroll = ScrolledWindow::new(); - chat_scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - chat_scroll.set_vexpand(true); - - let (chat_view, chat_buffer, markdown_processor) = create_chat_view(); - chat_scroll.set_child(Some(&chat_view)); - - // Input area - let input_container = GtkBox::new(Orientation::Vertical, 16); - - let input_area_container = GtkBox::new(Orientation::Horizontal, 16); - - let input_scroll = ScrolledWindow::new(); - input_scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - input_scroll.set_max_content_height(150); - input_scroll.set_propagate_natural_height(true); - input_scroll.set_hexpand(true); - - let input_view = TextView::new(); - input_view.add_css_class("input-text"); - input_view.set_wrap_mode(WrapMode::WordChar); - input_view.set_accepts_tab(false); - let input_buffer = input_view.buffer(); - input_scroll.set_child(Some(&input_view)); - - // Single button that changes between Send and Stop - let action_button = Button::with_label("Send"); - action_button.add_css_class("send-button"); - action_button.set_valign(Align::End); - - // Track button state - let button_state = Arc::new(Mutex::new(ButtonState::Send)); - - input_area_container.append(&input_scroll); - input_area_container.append(&action_button); - - // Bottom controls - let controls_container = GtkBox::new(Orientation::Horizontal, 16); - - let model_label = Label::new(Some("Model:")); - - // Create StringList to hold model names - let model_list = StringList::new(&[]); - let model_dropdown = DropDown::new(Some(model_list.clone()), None::); - - // Add thinking checkbox - let thinking_checkbox = CheckButton::with_label("Think"); - thinking_checkbox.set_tooltip_text(Some("Enable thinking mode for reasoning models (e.g., deepseek-r1)")); - - let status_label = Label::new(Some("Ready")); - status_label.set_hexpand(true); - status_label.set_halign(Align::End); - - controls_container.append(&model_label); - controls_container.append(&model_dropdown); - controls_container.append(&thinking_checkbox); - controls_container.append(&status_label); - - input_container.append(&input_area_container); - input_container.append(&controls_container); - - // Assemble main UI - main_container.append(&chat_scroll); - main_container.append(&input_container); - window.set_child(Some(&main_container)); - - // Initialize app state - let app_state = AppState::default(); - - // Load available models - load_models(model_list.clone(), model_dropdown.clone(), status_label.clone(), app_state.clone()); - - // Set up event handlers - setup_action_button_handler( - action_button.clone(), - button_state.clone(), - input_buffer, - chat_buffer.clone(), - model_dropdown, - model_list, - thinking_checkbox.clone(), - status_label.clone(), - app_state.clone(), - markdown_processor, - chat_scroll.clone(), - ); - - // Connect thinking checkbox to app state - setup_thinking_checkbox_handler(thinking_checkbox, app_state.clone()); - - setup_keyboard_shortcut(input_view, action_button.clone(), button_state.clone()); - - window.present(); -} - -// Helper function to update button appearance -fn update_button_state(button: &Button, state: ButtonState) { - match state { - ButtonState::Send => { - button.set_label("Send"); - button.remove_css_class("stop-button"); - button.add_css_class("send-button"); - } - ButtonState::Stop => { - button.set_label("Stop"); - button.remove_css_class("send-button"); - button.add_css_class("stop-button"); - } - } -} - -fn setup_action_button_handler( - action_button: Button, - button_state: Arc>, - input_buffer: TextBuffer, - chat_buffer: TextBuffer, - model_dropdown: DropDown, - model_list: StringList, - thinking_checkbox: CheckButton, - status_label: Label, - app_state: AppState, - markdown_processor: Arc, - chat_scroll: ScrolledWindow, -) { - action_button.connect_clicked(clone!( - #[weak] input_buffer, - #[weak] chat_buffer, - #[weak] model_dropdown, - #[weak] model_list, - #[weak] thinking_checkbox, - #[weak] status_label, - #[weak] action_button, - #[strong] button_state, - #[strong] markdown_processor, - move |_| { - let current_state = { - let state = button_state.lock().unwrap(); - state.clone() - }; - - match current_state { - ButtonState::Send => { - // Handle send logic - let start_iter = input_buffer.start_iter(); - let end_iter = input_buffer.end_iter(); - let text = input_buffer.text(&start_iter, &end_iter, false); - - if text.trim().is_empty() { - return; - } - - // Get selected model - let selected_idx = model_dropdown.selected(); - if selected_idx == gtk4::INVALID_LIST_POSITION { - status_label.set_text("Please select a model first"); - return; - } - - let model = match model_list.string(selected_idx) { - Some(m) => m.to_string(), - None => { - status_label.set_text("Invalid model selection"); - return; - } - }; - - // Get thinking checkbox state - let thinking_enabled = thinking_checkbox.is_active(); - - input_buffer.delete(&mut input_buffer.start_iter(), &mut input_buffer.end_iter()); - - // Change button to Stop state - { - let mut state = button_state.lock().unwrap(); - *state = ButtonState::Stop; - } - update_button_state(&action_button, ButtonState::Stop); - - send_message( - text.to_string(), - model, - thinking_enabled, - chat_buffer.clone(), - chat_scroll.clone(), - status_label.clone(), - action_button.clone(), - button_state.clone(), - app_state.clone(), - markdown_processor.clone(), - ); - } - ButtonState::Stop => { - // Handle stop logic - let mut handle = app_state.current_request_handle.lock().unwrap(); - if let Some(task) = handle.take() { - task.abort(); - status_label.set_text("Generation stopped"); - - // Change button back to Send state - { - let mut state = button_state.lock().unwrap(); - *state = ButtonState::Send; - } - update_button_state(&action_button, ButtonState::Send); - } - } - } - } - )); -} - -fn setup_keyboard_shortcut(input_view: TextView, action_button: Button, button_state: Arc>) { - let input_controller = gtk4::EventControllerKey::new(); - input_controller.connect_key_pressed(clone!( - #[weak] action_button, - #[strong] button_state, - #[upgrade_or] glib::Propagation::Proceed, - move |_, key, _, modifier| { - if key == gtk4::gdk::Key::Return && modifier.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { - // Only trigger if in Send state (don't allow Ctrl+Enter to stop) - let current_state = { - let state = button_state.lock().unwrap(); - state.clone() - }; - - if current_state == ButtonState::Send { - action_button.emit_clicked(); - } - glib::Propagation::Stop - } else { - glib::Propagation::Proceed - } - } - )); - input_view.add_controller(input_controller); -} - -fn send_message( - message: String, - model: String, - thinking_enabled: bool, - chat_buffer: TextBuffer, - chat_scroll: ScrolledWindow, - status_label: Label, - action_button: Button, - button_state: Arc>, - app_state: AppState, - markdown_processor: Arc -) { - // Add user message to conversation - { - let mut conversation = app_state.conversation.lock().unwrap(); - conversation.push(ChatMessage { - role: "user".to_string(), - content: message.clone(), - }); - } - - append_to_chat(&chat_buffer, "You", &message, &markdown_processor); - - // Add placeholder for assistant message - let mut end_iter = chat_buffer.end_iter(); - let assistant_prefix = if thinking_enabled { - "\n\nAssistant (thinking enabled):\n" - } else { - "\n\nAssistant:\n" - }; - chat_buffer.insert(&mut end_iter, assistant_prefix); - - // Create mark where assistant response will be inserted - let assistant_start_mark = chat_buffer.create_mark(None, &chat_buffer.end_iter(), true); - - let status_text = if thinking_enabled { - "Assistant is thinking..." - } else { - "Assistant is typing..." - }; - status_label.set_text(status_text); - - // Create channels for streaming communication - let (stream_sender, stream_receiver) = async_channel::bounded::(50); - let (result_sender, result_receiver) = async_channel::bounded::>>(1); - - // Spawn tokio task for API streaming - let app_state_clone = app_state.clone(); - let model_clone = model.clone(); - let task_handle = runtime().spawn(async move { - let result = api::send_chat_request_streaming( - &app_state_clone.ollama_url, - &model_clone, - &app_state_clone.conversation, - stream_sender, - thinking_enabled, - ).await; - let _ = result_sender.send(result).await; - }); - - // Store the handle for potential cancellation - { - let mut handle = app_state.current_request_handle.lock().unwrap(); - *handle = Some(task_handle); - } - - // Handle streaming updates on the main loop - spawn_future_local(clone!( - #[weak] chat_buffer, - #[weak] assistant_start_mark, - #[weak] chat_scroll, - async move { - let mut accumulated_text = String::new(); - - while let Ok(token_batch) = stream_receiver.recv().await { - accumulated_text.push_str(&token_batch); - - // Update UI with accumulated text (plain text during streaming) - let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - let mut end_iter = chat_buffer.end_iter(); - - // Replace content from mark to end - chat_buffer.delete(&mut start_iter, &mut end_iter); - let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - chat_buffer.insert(&mut insert_iter, &accumulated_text); - - // Auto-scroll to bottom after each update - let adjustment = chat_scroll.vadjustment(); - adjustment.set_value(adjustment.upper() - adjustment.page_size()); - } - } - )); - - // Handle final result - apply markdown formatting when streaming completes - let app_state_final = app_state.clone(); - spawn_future_local(clone!( - #[weak] status_label, - #[weak] chat_buffer, - #[weak] assistant_start_mark, - #[weak] action_button, - #[strong] button_state, - #[strong] markdown_processor, - async move { - if let Ok(result) = result_receiver.recv().await { - match result { - Ok(response_text) => { - // Clear the plain streaming text - let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - let mut end_iter = chat_buffer.end_iter(); - chat_buffer.delete(&mut start_iter, &mut end_iter); - - // Insert formatted markdown text using the shared processor - let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - markdown_processor.insert_formatted_text(&chat_buffer, &response_text, &mut insert_iter); - - // Add complete response to conversation - { - let mut conversation = app_state_final.conversation.lock().unwrap(); - conversation.push(ChatMessage { - role: "assistant".to_string(), - content: response_text, - }); - } - - status_label.set_text("Ready"); - } - Err(e) => { - status_label.set_text(&format!("Error: {}", e)); - - // Show error in chat - let mut start_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - let mut end_iter = chat_buffer.end_iter(); - chat_buffer.delete(&mut start_iter, &mut end_iter); - let mut insert_iter = chat_buffer.iter_at_mark(&assistant_start_mark); - chat_buffer.insert(&mut insert_iter, &format!("[Error: {}]", e)); - } - } - - // Change button back to Send state when generation completes - { - let mut state = button_state.lock().unwrap(); - *state = ButtonState::Send; - } - update_button_state(&action_button, ButtonState::Send); - } - } - )); -} - -// Helper functions -fn runtime() -> &'static Runtime { - static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME.get_or_init(|| { - Runtime::new().expect("Setting up tokio runtime needs to succeed.") - }) -} - -fn create_chat_view() -> (TextView, TextBuffer, Arc) { - let chat_view = TextView::new(); - chat_view.set_editable(false); - chat_view.set_cursor_visible(false); - chat_view.set_wrap_mode(WrapMode::WordChar); - chat_view.add_css_class("chat-text"); - - let chat_buffer = TextBuffer::new(None); - chat_view.set_buffer(Some(&chat_buffer)); - - // Set up markdown formatting tags - let markdown_processor = Arc::new(MarkdownProcessor::new()); - markdown_processor.setup_tags(&chat_buffer); - - (chat_view, chat_buffer, markdown_processor) -} - -fn load_models(model_list: StringList, dropdown: DropDown, status_label: Label, app_state: AppState) { - status_label.set_text("Loading models..."); - - // Create communication channel - let (sender, receiver) = async_channel::bounded(1); - - // Spawn tokio task for API call - runtime().spawn(async move { - let result = api::fetch_models(&app_state.ollama_url).await; - let _ = sender.send(result).await; - }); - - // Handle response on main loop - spawn_future_local(clone!( - #[weak] model_list, - #[weak] dropdown, - #[weak] status_label, - async move { - if let Ok(result) = receiver.recv().await { - match result { - Ok(models) => { - // Clear existing items and add new ones - model_list.splice(0, model_list.n_items(), - &models.iter().map(|m| m.name.as_str()).collect::>()); - - // Select first model if available - if model_list.n_items() > 0 && dropdown.selected() == gtk4::INVALID_LIST_POSITION { - dropdown.set_selected(0); - } - - status_label.set_text("Ready"); - } - Err(e) => { - status_label.set_text(&format!("Error loading models: {}", e)); - } - } - } - } - )); -} - -fn setup_thinking_checkbox_handler(thinking_checkbox: CheckButton, app_state: AppState) { - thinking_checkbox.connect_toggled(clone!( - #[strong] app_state, - move |checkbox| { - let is_active = checkbox.is_active(); - if let Ok(mut thinking_enabled) = app_state.thinking_enabled.lock() { - *thinking_enabled = is_active; - } - } - )); -} - -fn append_to_chat(buffer: &TextBuffer, sender: &str, message: &str, markdown_processor: &MarkdownProcessor) { - let mut end_iter = buffer.end_iter(); - - // Add spacing if buffer is not empty - if buffer.char_count() > 0 { - buffer.insert(&mut end_iter, "\n\n"); - end_iter = buffer.end_iter(); - } - - // Add sender label - buffer.insert(&mut end_iter, &format!("{}:\n", sender)); - end_iter = buffer.end_iter(); - - // Add message - user messages are always plain text - if sender == "You" { - buffer.insert(&mut end_iter, message); - } else { - // For assistant messages, use markdown formatting - markdown_processor.insert_formatted_text(buffer, message, &mut end_iter); - } -} \ No newline at end of file diff --git a/src/ui/chat.rs b/src/ui/chat.rs new file mode 100644 index 0000000..84a2824 --- /dev/null +++ b/src/ui/chat.rs @@ -0,0 +1,164 @@ +use gtk4::prelude::*; +use gtk4::{TextView, TextBuffer, ScrolledWindow, WrapMode, PolicyType, Box as GtkBox, Orientation}; +use std::rc::Rc; +use std::cell::RefCell; +use crate::markdown_renderer::MarkdownRenderer; +use crate::config::Config; + +#[derive(Clone)] +pub struct ChatView { + scrolled_window: ScrolledWindow, + text_view: TextView, + text_buffer: TextBuffer, + main_container: GtkBox, + markdown_renderer: Rc>, +} + +impl ChatView { + pub fn new() -> Self { + // Create main container that can hold both text and widgets + let main_container = GtkBox::new(Orientation::Vertical, 8); + + let scrolled_window = ScrolledWindow::new(); + scrolled_window.add_css_class("chat-container"); + scrolled_window.set_policy(PolicyType::Never, PolicyType::Automatic); + scrolled_window.set_vexpand(true); // Allow vertical expansion + scrolled_window.set_hexpand(true); // Allow horizontal expansion + + let text_view = TextView::new(); + text_view.set_editable(false); + text_view.set_cursor_visible(false); + text_view.set_wrap_mode(WrapMode::WordChar); + text_view.set_hexpand(true); + text_view.set_vexpand(true); + text_view.add_css_class("chat-text"); + text_view.set_margin_start(12); + text_view.set_margin_end(12); + text_view.set_margin_top(12); + text_view.set_margin_bottom(12); + + let text_buffer = TextBuffer::new(None); + text_view.set_buffer(Some(&text_buffer)); + + let markdown_renderer = Rc::new(RefCell::new(MarkdownRenderer::new())); + + // Add text view to container + main_container.append(&text_view); + main_container.set_vexpand(true); + main_container.set_hexpand(true); + + scrolled_window.set_child(Some(&main_container)); + + Self { + scrolled_window, + text_view, + text_buffer, + main_container, + markdown_renderer, + } + } + + pub fn widget(&self) -> &ScrolledWindow { + &self.scrolled_window + } + + pub fn buffer(&self) -> &TextBuffer { + &self.text_buffer + } + + pub fn append_message(&self, sender: &str, message: &str, config: &Config) { + let mut end_iter = self.text_buffer.end_iter(); + + // Add spacing if buffer is not empty + if self.text_buffer.char_count() > 0 { + self.text_buffer.insert(&mut end_iter, "\n\n"); + end_iter = self.text_buffer.end_iter(); + } + + // Add sender label with bold formatting + let sender_tag = gtk4::TextTag::new(Some("sender")); + sender_tag.set_weight(700); + sender_tag.set_property("pixels-below-lines", 4); + + // Add the sender tag to the buffer's tag table if it's not already there + let tag_table = self.text_buffer.tag_table(); + if tag_table.lookup("sender").is_none() { + tag_table.add(&sender_tag); + } + + self.text_buffer.insert_with_tags(&mut end_iter, &format!("{}:\n", sender), &[&sender_tag]); + end_iter = self.text_buffer.end_iter(); + + // Add message - format markdown for assistant, plain text for user + if sender == "You" { + self.text_buffer.insert(&mut end_iter, message); + } else { + self.insert_formatted_text(message, &mut end_iter, config); + } + } + + pub fn insert_formatted_text(&self, markdown_text: &str, iter: &mut gtk4::TextIter, config: &Config) { + let mut renderer = self.markdown_renderer.borrow_mut(); + renderer.render_markdown_at_iter(&self.text_buffer, markdown_text, iter, config); + } + + pub fn scroll_to_bottom(&self) { + let adjustment = self.scrolled_window.vadjustment(); + adjustment.set_value(adjustment.upper() - adjustment.page_size()); + } + + pub fn create_mark_at_end(&self) -> gtk4::TextMark { + self.text_buffer.create_mark(None, &self.text_buffer.end_iter(), true) + } + + pub fn insert_formatted_at_mark(&self, mark: >k4::TextMark, content: &str, config: &Config) { + let mut start_iter = self.text_buffer.iter_at_mark(mark); + let mut end_iter = self.text_buffer.end_iter(); + + self.text_buffer.delete(&mut start_iter, &mut end_iter); + let mut insert_iter = self.text_buffer.iter_at_mark(mark); + self.insert_formatted_text(content, &mut insert_iter, config); + } + + pub fn update_streaming_markdown(&self, mark: >k4::TextMark, accumulated_content: &str, config: &Config) { + // Store the current scroll position + let adjustment = self.scrolled_window.vadjustment(); + let scroll_position = adjustment.value(); + let at_bottom = scroll_position >= (adjustment.upper() - adjustment.page_size() - 50.0); + + // Get the mark position + let mut start_iter = self.text_buffer.iter_at_mark(mark); + let mut end_iter = self.text_buffer.end_iter(); + + // Delete existing content from mark to end + self.text_buffer.delete(&mut start_iter, &mut end_iter); + + // Get a fresh iterator at the mark position after deletion + let _insert_iter = self.text_buffer.iter_at_mark(mark); + + // Render markdown directly to the main buffer + // We use a separate method to avoid conflicts with the borrow checker + self.render_markdown_at_mark(mark, accumulated_content, config); + + // Restore scroll position or scroll to bottom if we were at the bottom + if at_bottom { + self.scroll_to_bottom(); + } else { + adjustment.set_value(scroll_position); + } + } + + fn render_markdown_at_mark(&self, mark: >k4::TextMark, content: &str, config: &Config) { + let mut insert_iter = self.text_buffer.iter_at_mark(mark); + + // Create a new scope to ensure the borrow is dropped + { + let mut renderer = self.markdown_renderer.borrow_mut(); + renderer.render_markdown_at_iter(&self.text_buffer, content, &mut insert_iter, config); + } + } +} + +pub fn create_chat_view() -> ChatView { + ChatView::new() +} \ No newline at end of file diff --git a/src/ui/controls.rs b/src/ui/controls.rs new file mode 100644 index 0000000..dcd691a --- /dev/null +++ b/src/ui/controls.rs @@ -0,0 +1,65 @@ +use gtk4::prelude::*; +use gtk4::{Box as GtkBox, Orientation, DropDown, Label, StringList}; + +#[derive(Clone)] +pub struct ControlsArea { + pub container: GtkBox, + pub model_dropdown: DropDown, + pub status_label: Label, + models: StringList, +} + +impl ControlsArea { + pub fn new() -> Self { + let container = GtkBox::new(Orientation::Horizontal, 16); + container.set_margin_top(16); + + // Model selection + let models = StringList::new(&[]); + let model_dropdown = DropDown::new(Some(models.clone()), None::); + model_dropdown.set_hexpand(true); + + // Status label + let status_label = Label::new(Some("Ready")); + status_label.set_hexpand(true); + status_label.set_halign(gtk4::Align::End); + status_label.add_css_class("status-label"); + + container.append(&model_dropdown); + container.append(&status_label); + + Self { + container, + model_dropdown, + status_label, + models, + } + } + + pub fn set_models(&self, model_names: &[impl AsRef]) { + // Clear existing models + let model_names_refs: Vec<&str> = model_names.iter().map(|s| s.as_ref()).collect(); + self.models.splice(0, self.models.n_items(), &model_names_refs); + // Select first model if available + if !model_names.is_empty() { + self.model_dropdown.set_selected(0); + } + } + + pub fn get_selected_model(&self) -> Option { + let selected = self.model_dropdown.selected(); + if selected != gtk4::INVALID_LIST_POSITION { + self.models.string(selected).map(|s| s.to_string()) + } else { + None + } + } + + pub fn set_status(&self, status: &str) { + self.status_label.set_text(status); + } +} + +pub fn create_controls() -> ControlsArea { + ControlsArea::new() +} \ No newline at end of file diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs new file mode 100644 index 0000000..457caa6 --- /dev/null +++ b/src/ui/handlers.rs @@ -0,0 +1,352 @@ +use gtk4::prelude::*; +use gtk4::glib::{spawn_future_local, clone}; +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +use crate::state::{SharedState, AppResult, AppError, ButtonState}; +use crate::ui::{chat::ChatView, input::InputArea, controls::ControlsArea}; +use crate::api; + +pub fn setup_handlers( + shared_state: SharedState, + chat_view: ChatView, + input_area: InputArea, + controls_area: ControlsArea, +) { + // Load models on startup + load_models(shared_state.clone(), &controls_area); + + // Setup action button handler + setup_action_button_handler(shared_state.clone(), &chat_view, &input_area, &controls_area); + + // Setup keyboard shortcut + setup_keyboard_shortcut(&input_area, shared_state.clone()); + +} + +fn setup_action_button_handler( + shared_state: SharedState, + chat_view: &ChatView, + input_area: &InputArea, + controls_area: &ControlsArea, +) { + let action_button = &input_area.action_button; + let text_buffer = input_area.text_buffer.clone(); + let chat_view_clone = chat_view.clone(); + let controls_clone = controls_area.clone(); + let button_clone = action_button.clone(); + + action_button.connect_clicked(clone!( + #[strong] shared_state, + #[strong] text_buffer, + #[strong] chat_view_clone, + #[strong] controls_clone, + #[strong] button_clone, + move |_| { + if let Err(e) = handle_action_button_click( + &shared_state, + &text_buffer, + &chat_view_clone, + &controls_clone, + &button_clone + ) { + controls_clone.set_status(&format!("Error: {}", e)); + update_button_state(&shared_state, &button_clone); + } + } + )); + + // Initialize button appearance + update_button_state(&shared_state, action_button); +} + +fn handle_action_button_click( + shared_state: &SharedState, + text_buffer: >k4::TextBuffer, + chat_view: &ChatView, + controls: &ControlsArea, + button: >k4::Button, +) -> AppResult<()> { + let current_state = shared_state.borrow().button_state; + + match current_state { + ButtonState::Send => handle_send_click(shared_state, text_buffer, chat_view, controls, button), + ButtonState::Stop => handle_stop_click(shared_state, controls, button), + } +} + +fn handle_send_click( + shared_state: &SharedState, + text_buffer: >k4::TextBuffer, + chat_view: &ChatView, + controls: &ControlsArea, + button: >k4::Button, +) -> AppResult<()> { + // Validate input + let text = get_input_text(text_buffer)?; + let model = get_selected_model(controls)?; + + // Clear input and start generation + clear_input(text_buffer); + set_generating_state(shared_state, controls, button, true); + + // Add user message to conversation and chat + shared_state.borrow_mut().add_user_message(text.clone()); + let config = shared_state.borrow().config.clone(); + chat_view.append_message("You", &text, &config); + + // Start streaming + start_streaming_task(shared_state, chat_view, controls, button, model); + + Ok(()) +} + +fn handle_stop_click( + shared_state: &SharedState, + controls: &ControlsArea, + button: >k4::Button, +) -> AppResult<()> { + shared_state.borrow_mut().abort_current_task(); + update_button_state(shared_state, button); + controls.set_status("Generation stopped"); + Ok(()) +} + +fn set_generating_state( + shared_state: &SharedState, + controls: &ControlsArea, + button: >k4::Button, + generating: bool +) { + { + let mut state = shared_state.borrow_mut(); + state.set_generating(generating); + state.set_status(if generating { + "Assistant is typing...".to_string() + } else { + "Ready".to_string() + }); + } + update_button_state(shared_state, button); + controls.set_status(if generating { + "Assistant is typing..." + } else { + "Ready" + }); +} + +fn update_button_state(shared_state: &SharedState, button: >k4::Button) { + let is_generating = shared_state.borrow().is_generating; + + if is_generating { + button.set_label("Stop"); + button.remove_css_class("send-button"); + button.add_css_class("stop-button"); + } else { + button.set_label("Send"); + button.remove_css_class("stop-button"); + button.add_css_class("send-button"); + } +} + +fn get_input_text(text_buffer: >k4::TextBuffer) -> AppResult { + let text = text_buffer.text(&text_buffer.start_iter(), &text_buffer.end_iter(), false); + let text = text.trim(); + + if text.is_empty() { + return Err(AppError::Validation("Message cannot be empty".to_string())); + } + + Ok(text.to_string()) +} + +fn get_selected_model(controls: &ControlsArea) -> AppResult { + controls.get_selected_model() + .ok_or_else(|| AppError::Validation("Please select a model first".to_string())) +} + +fn clear_input(text_buffer: >k4::TextBuffer) { + text_buffer.delete(&mut text_buffer.start_iter(), &mut text_buffer.end_iter()); +} + +fn start_streaming_task( + shared_state: &SharedState, + chat_view: &ChatView, + controls: &ControlsArea, + button: >k4::Button, + model: String, +) { + let (content_sender, content_receiver) = async_channel::bounded::(100); + let (result_sender, result_receiver) = async_channel::bounded(1); + + // Extract data from shared state for API call + let (conversation, ollama_url) = { + let state = shared_state.borrow(); + (state.conversation.clone(), state.ollama_url.clone()) + }; + + // Spawn API task + let task_handle = runtime().spawn(async move { + let result = api::send_chat_request_streaming( + &ollama_url, + &model, + &std::sync::Arc::new(std::sync::Mutex::new(conversation)), + content_sender, + ).await; + let _ = result_sender.send(result).await; + }); + + // Store task handle + shared_state.borrow_mut().current_task = Some(task_handle); + + // Setup streaming handlers + setup_streaming_handlers( + shared_state, + chat_view, + controls, + button, + content_receiver, + result_receiver + ); +} + +fn setup_streaming_handlers( + shared_state: &SharedState, + chat_view: &ChatView, + controls: &ControlsArea, + button: >k4::Button, + content_receiver: async_channel::Receiver, + result_receiver: async_channel::Receiver), Box>>, +) { + // Setup UI structure for streaming + let mut end_iter = chat_view.buffer().end_iter(); + chat_view.buffer().insert(&mut end_iter, "\n\nAssistant:"); + + + // Create response mark for regular content + let mut end_iter = chat_view.buffer().end_iter(); + chat_view.buffer().insert(&mut end_iter, "\n"); + let response_mark = chat_view.create_mark_at_end(); + + // Handle response content updates with live markdown + let response_mark_clone = response_mark.clone(); + let chat_view_content = chat_view.clone(); + + let shared_state_streaming = shared_state.clone(); + + spawn_future_local(async move { + let mut accumulated_content = String::new(); + + while let Ok(content_batch) = content_receiver.recv().await { + accumulated_content.push_str(&content_batch); + let config = shared_state_streaming.borrow().config.clone(); + chat_view_content.update_streaming_markdown(&response_mark_clone, &accumulated_content, &config); + chat_view_content.scroll_to_bottom(); + } + }); + + // Handle final result + let shared_state_final = shared_state.clone(); + let controls_final = controls.clone(); + let button_final = button.clone(); + let chat_view_final = chat_view.clone(); + + + spawn_future_local(async move { + if let Ok(result) = result_receiver.recv().await { + match result { + Ok(response_text) => { + // Apply final markdown formatting + let config = shared_state_final.borrow().config.clone(); + chat_view_final.insert_formatted_at_mark(&response_mark, &response_text.0, &config); + + // Update conversation state + shared_state_final.borrow_mut().add_assistant_message(response_text.0); + set_generating_state(&shared_state_final, &controls_final, &button_final, false); + } + Err(e) => { + // Display error in response section + let error_message = format!("**Error:** {}", e); + let config = shared_state_final.borrow().config.clone(); + chat_view_final.insert_formatted_at_mark(&response_mark, &error_message, &config); + + // Update state + set_generating_state(&shared_state_final, &controls_final, &button_final, false); + controls_final.set_status(&format!("Error: {}", e)); + } + } + + chat_view_final.scroll_to_bottom(); + } + }); +} + +fn setup_keyboard_shortcut(input_area: &InputArea, shared_state: SharedState) { + let input_controller = gtk4::EventControllerKey::new(); + let action_button = input_area.action_button.clone(); + + input_controller.connect_key_pressed(clone!( + #[strong] shared_state, + #[strong] action_button, + move |_, key, _, modifier| { + if key == gtk4::gdk::Key::Return && + modifier.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { + + let is_ready = !shared_state.borrow().is_generating; + if is_ready { + action_button.emit_clicked(); + } + gtk4::glib::Propagation::Stop + } else { + gtk4::glib::Propagation::Proceed + } + } + )); + + input_area.text_view.add_controller(input_controller); +} + +fn load_models(shared_state: SharedState, controls: &ControlsArea) { + controls.set_status("Loading models..."); + + let (sender, receiver) = async_channel::bounded(1); + let controls_clone = controls.clone(); + + // Extract URL from shared state for API call + let ollama_url = shared_state.borrow().ollama_url.clone(); + + // Spawn API task + runtime().spawn(async move { + let result = api::fetch_models(&ollama_url).await; + let _ = sender.send(result).await; + }); + + // Handle response + spawn_future_local(async move { + if let Ok(result) = receiver.recv().await { + match result { + Ok(models) => { + let model_names: Vec = models.into_iter().map(|m| m.name).collect(); + controls_clone.set_models(&model_names); + + // Update shared state with first model + if !model_names.is_empty() { + shared_state.borrow_mut().selected_model = Some(model_names[0].clone()); + } + + controls_clone.set_status("Ready"); + } + Err(e) => { + controls_clone.set_status(&format!("Error loading models: {}", e)); + } + } + } + }); +} + +fn runtime() -> &'static Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + Runtime::new().expect("Failed to create tokio runtime") + }) +} \ No newline at end of file diff --git a/src/ui/input.rs b/src/ui/input.rs new file mode 100644 index 0000000..a65afaf --- /dev/null +++ b/src/ui/input.rs @@ -0,0 +1,64 @@ +use gtk4::prelude::*; +use gtk4::{Box as GtkBox, Orientation, TextView, TextBuffer, Button, ScrolledWindow, PolicyType, WrapMode}; + +#[derive(Clone)] +pub struct InputArea { + pub container: GtkBox, + pub text_view: TextView, + pub text_buffer: TextBuffer, + pub action_button: Button, +} + +impl InputArea { + pub fn new() -> Self { + let container = GtkBox::new(Orientation::Horizontal, 0); + + // Text input area with proper sizing + let scrolled_window = ScrolledWindow::new(); + scrolled_window.add_css_class("input-container"); + scrolled_window.set_policy(PolicyType::Never, PolicyType::Automatic); + scrolled_window.set_min_content_height(80); // Minimum height + scrolled_window.set_max_content_height(200); // Maximum height + scrolled_window.set_propagate_natural_height(true); + scrolled_window.set_hexpand(true); + scrolled_window.set_vexpand(false); + + let text_view = TextView::new(); + text_view.set_wrap_mode(WrapMode::WordChar); + text_view.set_editable(true); // Explicitly set editable + text_view.set_cursor_visible(true); // Make cursor visible + text_view.set_accepts_tab(false); // Don't consume tab events + text_view.add_css_class("input-text"); + text_view.set_hexpand(true); + text_view.set_vexpand(true); + + // Set some placeholder-like behavior + let text_buffer = text_view.buffer(); + + scrolled_window.set_child(Some(&text_view)); + + // Action button container + let button_container = GtkBox::new(Orientation::Horizontal, 8); + button_container.set_halign(gtk4::Align::End); + + let action_button = Button::with_label("Send"); + action_button.add_css_class("send-button"); + + button_container.append(&action_button); + + // Assemble container + container.append(&scrolled_window); + container.append(&button_container); + + Self { + container, + text_view, + text_buffer, + action_button, + } + } +} + +pub fn create_input_area() -> InputArea { + InputArea::new() +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..64057a1 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod chat; +pub mod input; +pub mod controls; +pub mod handlers;