@@ -2,7 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | use anyhow::Result; |
| 4 | 4 | use garcalc_cas::{Evaluator, parser}; |
| 5 | | -use garcalc_graph::{Graph2D, Graph3D}; |
| 5 | +use garcalc_graph::{CameraPreset, Graph2D, Graph3D, COLOR_PALETTE}; |
| 6 | 6 | use garcalc_ipc::Mode; |
| 7 | 7 | use garcalc_math::input::SpecialKey; |
| 8 | 8 | use garcalc_math::{ |
@@ -10,6 +10,7 @@ use garcalc_math::{ |
| 10 | 10 | }; |
| 11 | 11 | use gartk_core::{InputEvent, Key, Modifiers, MouseButton}; |
| 12 | 12 | use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig}; |
| 13 | +use std::process::Command; |
| 13 | 14 | use std::time::{Duration, Instant}; |
| 14 | 15 | |
| 15 | 16 | use crate::config::Config; |
@@ -65,6 +66,40 @@ pub struct App { |
| 65 | 66 | has_focus: bool, |
| 66 | 67 | /// Mouse drag state for graph panning |
| 67 | 68 | drag_start: Option<(f64, f64)>, |
| 69 | + /// Whether to show the function list panel |
| 70 | + show_function_list: bool, |
| 71 | + /// Whether zeros markers are visible |
| 72 | + zeros_visible: bool, |
| 73 | + /// Cached zero points: (func_index, points) |
| 74 | + cached_zeros: Vec<(usize, Vec<(f64, f64)>)>, |
| 75 | + /// Cached intersection points: ((idx_i, idx_j), points) |
| 76 | + cached_intersections: Vec<((usize, usize), Vec<(f64, f64)>)>, |
| 77 | + /// Whether table view is visible |
| 78 | + table_visible: bool, |
| 79 | + /// Which function index to tabulate |
| 80 | + table_func_index: usize, |
| 81 | + /// Table scroll offset |
| 82 | + table_scroll_offset: usize, |
| 83 | + /// Table step size |
| 84 | + table_step: f64, |
| 85 | + /// Cached table data |
| 86 | + table_data: Vec<(f64, Option<f64>)>, |
| 87 | + /// Whether auto-rotate is enabled (3D mode) |
| 88 | + auto_rotate: bool, |
| 89 | + /// Auto-rotate speed (radians per tick) |
| 90 | + auto_rotate_speed: f64, |
| 91 | + /// Whether the viewport settings panel is open (2D graph) |
| 92 | + viewport_panel_open: bool, |
| 93 | + /// Which viewport field is being edited (0=xmin, 1=xmax, 2=ymin, 3=ymax) |
| 94 | + viewport_edit_field: usize, |
| 95 | + /// Buffer for editing viewport values |
| 96 | + viewport_edit_buffer: String, |
| 97 | + /// Whether the color picker popup is open |
| 98 | + color_picker_open: bool, |
| 99 | + /// Which function the color picker targets |
| 100 | + color_picker_func_index: usize, |
| 101 | + /// Whether the 3D settings panel is open |
| 102 | + settings3d_panel_open: bool, |
| 68 | 103 | /// Configuration |
| 69 | 104 | #[allow(dead_code)] |
| 70 | 105 | config: Config, |
@@ -136,6 +171,23 @@ impl App { |
| 136 | 171 | last_cursor_blink: Instant::now(), |
| 137 | 172 | has_focus: false, |
| 138 | 173 | drag_start: None, |
| 174 | + show_function_list: false, |
| 175 | + zeros_visible: false, |
| 176 | + cached_zeros: Vec::new(), |
| 177 | + cached_intersections: Vec::new(), |
| 178 | + table_visible: false, |
| 179 | + table_func_index: 0, |
| 180 | + table_scroll_offset: 0, |
| 181 | + table_step: 1.0, |
| 182 | + table_data: Vec::new(), |
| 183 | + auto_rotate: false, |
| 184 | + auto_rotate_speed: 0.02, |
| 185 | + viewport_panel_open: false, |
| 186 | + viewport_edit_field: 0, |
| 187 | + viewport_edit_buffer: String::new(), |
| 188 | + color_picker_open: false, |
| 189 | + color_picker_func_index: 0, |
| 190 | + settings3d_panel_open: false, |
| 139 | 191 | config, |
| 140 | 192 | }) |
| 141 | 193 | } |
@@ -185,8 +237,30 @@ impl App { |
| 185 | 237 | } |
| 186 | 238 | } else if self.mode == Mode::Graph { |
| 187 | 239 | if mouse_ev.button == Some(MouseButton::Left) { |
| 188 | | - // Left click - start drag for pan |
| 189 | | - self.drag_start = Some((x, y)); |
| 240 | + // Check viewport panel preset clicks |
| 241 | + if self.viewport_panel_open { |
| 242 | + if let Some(preset) = self.ui.viewport_preset_hit(x, y) { |
| 243 | + self.graph.apply_viewport_preset(preset); |
| 244 | + self.viewport_panel_open = false; |
| 245 | + self.invalidate_caches(); |
| 246 | + ev.request_redraw(); |
| 247 | + // Don't start drag |
| 248 | + } else { |
| 249 | + self.drag_start = Some((x, y)); |
| 250 | + } |
| 251 | + } else if self.color_picker_open { |
| 252 | + if let Some(palette_idx) = self.ui.color_picker_hit(x, y) { |
| 253 | + self.graph.set_function_color(self.color_picker_func_index, COLOR_PALETTE[palette_idx]); |
| 254 | + self.color_picker_open = false; |
| 255 | + ev.request_redraw(); |
| 256 | + } else { |
| 257 | + self.color_picker_open = false; |
| 258 | + self.drag_start = Some((x, y)); |
| 259 | + } |
| 260 | + } else { |
| 261 | + // Left click - start drag for pan |
| 262 | + self.drag_start = Some((x, y)); |
| 263 | + } |
| 190 | 264 | } else if mouse_ev.button == Some(MouseButton::Right) { |
| 191 | 265 | // Right click - toggle trace mode |
| 192 | 266 | self.graph.trace_enabled = !self.graph.trace_enabled; |
@@ -279,6 +353,10 @@ impl App { |
| 279 | 353 | if self.tick_cursor_blink() { |
| 280 | 354 | ev.request_redraw(); |
| 281 | 355 | } |
| 356 | + if self.auto_rotate && self.mode == Mode::Graph3D { |
| 357 | + self.graph3d.camera.rotate(self.auto_rotate_speed, 0.0); |
| 358 | + ev.request_redraw(); |
| 359 | + } |
| 282 | 360 | } |
| 283 | 361 | _ => {} |
| 284 | 362 | } |
@@ -356,6 +434,13 @@ impl App { |
| 356 | 434 | self.mode = Mode::Graph3D; |
| 357 | 435 | return; |
| 358 | 436 | } |
| 437 | + Key::F4 if self.mode == Mode::Graph => { |
| 438 | + self.table_visible = !self.table_visible; |
| 439 | + if self.table_visible { |
| 440 | + self.regenerate_table(); |
| 441 | + } |
| 442 | + return; |
| 443 | + } |
| 359 | 444 | _ => {} |
| 360 | 445 | } |
| 361 | 446 | |
@@ -363,16 +448,55 @@ impl App { |
| 363 | 448 | match key { |
| 364 | 449 | Key::Char('r') if ctrl && self.mode == Mode::Graph => { |
| 365 | 450 | self.graph.reset_viewport(); |
| 451 | + self.invalidate_caches(); |
| 366 | 452 | return; |
| 367 | 453 | } |
| 368 | 454 | Key::Char('c') if ctrl && self.mode == Mode::Graph => { |
| 369 | 455 | self.graph.clear_functions(); |
| 456 | + self.invalidate_caches(); |
| 370 | 457 | return; |
| 371 | 458 | } |
| 372 | 459 | Key::Char('t') if ctrl && self.mode == Mode::Graph => { |
| 373 | 460 | self.graph.trace_enabled = !self.graph.trace_enabled; |
| 374 | 461 | return; |
| 375 | 462 | } |
| 463 | + Key::Char('l') if ctrl && self.mode == Mode::Graph => { |
| 464 | + self.show_function_list = !self.show_function_list; |
| 465 | + return; |
| 466 | + } |
| 467 | + Key::Char(c @ '1'..='9') if ctrl && self.mode == Mode::Graph => { |
| 468 | + let idx = (*c as usize) - ('1' as usize); |
| 469 | + self.graph.toggle_visibility(idx); |
| 470 | + self.invalidate_caches(); |
| 471 | + return; |
| 472 | + } |
| 473 | + Key::Char('z') if ctrl && self.mode == Mode::Graph => { |
| 474 | + self.zeros_visible = !self.zeros_visible; |
| 475 | + if self.zeros_visible { |
| 476 | + self.cached_zeros = self.graph.find_zeros(); |
| 477 | + self.cached_intersections = self.graph.find_intersections(); |
| 478 | + } |
| 479 | + return; |
| 480 | + } |
| 481 | + // 2D graph: viewport panel |
| 482 | + Key::Char('w') if ctrl && self.mode == Mode::Graph => { |
| 483 | + self.viewport_panel_open = !self.viewport_panel_open; |
| 484 | + if self.viewport_panel_open { |
| 485 | + self.viewport_edit_field = 0; |
| 486 | + self.viewport_edit_buffer = format!("{:.4}", self.graph.viewport.x_min); |
| 487 | + } |
| 488 | + return; |
| 489 | + } |
| 490 | + // 2D graph: color picker |
| 491 | + Key::Char('k') if ctrl && self.mode == Mode::Graph => { |
| 492 | + if !self.graph.functions.is_empty() { |
| 493 | + self.color_picker_open = !self.color_picker_open; |
| 494 | + self.color_picker_func_index = |
| 495 | + self.color_picker_func_index.min(self.graph.functions.len().saturating_sub(1)); |
| 496 | + } |
| 497 | + return; |
| 498 | + } |
| 499 | + // 3D shortcuts |
| 376 | 500 | Key::Char('r') if ctrl && self.mode == Mode::Graph3D => { |
| 377 | 501 | self.graph3d.reset_camera(); |
| 378 | 502 | return; |
@@ -381,6 +505,39 @@ impl App { |
| 381 | 505 | self.graph3d.clear_surfaces(); |
| 382 | 506 | return; |
| 383 | 507 | } |
| 508 | + Key::Char('m') if ctrl && self.mode == Mode::Graph3D => { |
| 509 | + self.graph3d.cycle_render_mode(); |
| 510 | + return; |
| 511 | + } |
| 512 | + Key::Char('a') if ctrl && self.mode == Mode::Graph3D => { |
| 513 | + self.auto_rotate = !self.auto_rotate; |
| 514 | + return; |
| 515 | + } |
| 516 | + Key::Char('g') if ctrl && self.mode == Mode::Graph3D => { |
| 517 | + self.graph3d.config.show_coord_planes = !self.graph3d.config.show_coord_planes; |
| 518 | + return; |
| 519 | + } |
| 520 | + Key::Char('s') if ctrl && self.mode == Mode::Graph3D => { |
| 521 | + self.settings3d_panel_open = !self.settings3d_panel_open; |
| 522 | + return; |
| 523 | + } |
| 524 | + // 3D camera presets |
| 525 | + Key::Char('1') if ctrl && self.mode == Mode::Graph3D => { |
| 526 | + self.graph3d.camera.apply_preset(CameraPreset::Front); |
| 527 | + return; |
| 528 | + } |
| 529 | + Key::Char('3') if ctrl && self.mode == Mode::Graph3D => { |
| 530 | + self.graph3d.camera.apply_preset(CameraPreset::Side); |
| 531 | + return; |
| 532 | + } |
| 533 | + Key::Char('7') if ctrl && self.mode == Mode::Graph3D => { |
| 534 | + self.graph3d.camera.apply_preset(CameraPreset::Top); |
| 535 | + return; |
| 536 | + } |
| 537 | + Key::Char('5') if ctrl && self.mode == Mode::Graph3D => { |
| 538 | + self.graph3d.camera.apply_preset(CameraPreset::Isometric); |
| 539 | + return; |
| 540 | + } |
| 384 | 541 | _ => {} |
| 385 | 542 | } |
| 386 | 543 | |
@@ -389,6 +546,113 @@ impl App { |
| 389 | 546 | return; |
| 390 | 547 | } |
| 391 | 548 | |
| 549 | + // Viewport panel keys (graph mode only) |
| 550 | + if self.viewport_panel_open && self.mode == Mode::Graph { |
| 551 | + match key { |
| 552 | + Key::Tab => { |
| 553 | + self.commit_viewport_edit(); |
| 554 | + self.viewport_edit_field = (self.viewport_edit_field + 1) % 4; |
| 555 | + self.load_viewport_edit_buffer(); |
| 556 | + return; |
| 557 | + } |
| 558 | + Key::Return => { |
| 559 | + self.commit_viewport_edit(); |
| 560 | + self.viewport_panel_open = false; |
| 561 | + self.invalidate_caches(); |
| 562 | + return; |
| 563 | + } |
| 564 | + Key::Escape => { |
| 565 | + self.viewport_panel_open = false; |
| 566 | + return; |
| 567 | + } |
| 568 | + Key::Backspace => { |
| 569 | + self.viewport_edit_buffer.pop(); |
| 570 | + return; |
| 571 | + } |
| 572 | + Key::Char(c @ ('0'..='9' | '.' | '-')) => { |
| 573 | + self.viewport_edit_buffer.push(*c); |
| 574 | + return; |
| 575 | + } |
| 576 | + _ => {} |
| 577 | + } |
| 578 | + } |
| 579 | + |
| 580 | + // Color picker keys (graph mode only) |
| 581 | + if self.color_picker_open && self.mode == Mode::Graph { |
| 582 | + if matches!(key, Key::Escape) { |
| 583 | + self.color_picker_open = false; |
| 584 | + return; |
| 585 | + } |
| 586 | + } |
| 587 | + |
| 588 | + // 3D settings panel keys |
| 589 | + if self.settings3d_panel_open && self.mode == Mode::Graph3D { |
| 590 | + match key { |
| 591 | + Key::Escape => { |
| 592 | + self.settings3d_panel_open = false; |
| 593 | + return; |
| 594 | + } |
| 595 | + Key::Char(']') => { |
| 596 | + self.graph3d.config.grid_lines = (self.graph3d.config.grid_lines + 5).min(100); |
| 597 | + return; |
| 598 | + } |
| 599 | + Key::Char('[') => { |
| 600 | + self.graph3d.config.grid_lines = self.graph3d.config.grid_lines.saturating_sub(5).max(10); |
| 601 | + return; |
| 602 | + } |
| 603 | + Key::Char('>') => { |
| 604 | + self.graph3d.config.surface_alpha = (self.graph3d.config.surface_alpha + 0.05).min(1.0); |
| 605 | + return; |
| 606 | + } |
| 607 | + Key::Char('<') => { |
| 608 | + self.graph3d.config.surface_alpha = (self.graph3d.config.surface_alpha - 0.05).max(0.1); |
| 609 | + return; |
| 610 | + } |
| 611 | + Key::Char('c') => { |
| 612 | + self.graph3d.config.colormap = self.graph3d.config.colormap.next(); |
| 613 | + return; |
| 614 | + } |
| 615 | + Key::Char('m') => { |
| 616 | + self.graph3d.cycle_render_mode(); |
| 617 | + return; |
| 618 | + } |
| 619 | + _ => {} |
| 620 | + } |
| 621 | + } |
| 622 | + |
| 623 | + // Table view keys (graph mode only) |
| 624 | + if self.table_visible && self.mode == Mode::Graph { |
| 625 | + match key { |
| 626 | + Key::Up => { |
| 627 | + if self.table_scroll_offset > 0 { |
| 628 | + self.table_scroll_offset -= 1; |
| 629 | + } |
| 630 | + return; |
| 631 | + } |
| 632 | + Key::Down => { |
| 633 | + if self.table_scroll_offset + 20 < self.table_data.len() { |
| 634 | + self.table_scroll_offset += 1; |
| 635 | + } |
| 636 | + return; |
| 637 | + } |
| 638 | + Key::Char('e') if ctrl => { |
| 639 | + self.export_table_to_clipboard(); |
| 640 | + return; |
| 641 | + } |
| 642 | + Key::Char('[') => { |
| 643 | + self.table_step = (self.table_step / 2.0).max(0.001); |
| 644 | + self.regenerate_table(); |
| 645 | + return; |
| 646 | + } |
| 647 | + Key::Char(']') => { |
| 648 | + self.table_step = (self.table_step * 2.0).min(100.0); |
| 649 | + self.regenerate_table(); |
| 650 | + return; |
| 651 | + } |
| 652 | + _ => {} |
| 653 | + } |
| 654 | + } |
| 655 | + |
| 392 | 656 | // Plain text editor for graph/3D modes |
| 393 | 657 | match key { |
| 394 | 658 | Key::Backspace => { |
@@ -759,8 +1023,30 @@ impl App { |
| 759 | 1023 | let lhs = input[..eq_pos].trim(); |
| 760 | 1024 | let rhs = input[eq_pos + 1..].trim(); |
| 761 | 1025 | |
| 762 | | - // Check if this is "y = f(x)" (explicit) or implicit |
| 763 | | - if lhs == "y" { |
| 1026 | + // Check if this is "r = f(theta)" (polar), "y = f(x)" (explicit), or implicit |
| 1027 | + if lhs == "r" { |
| 1028 | + match parser::parse(rhs) { |
| 1029 | + Ok(expr) => { |
| 1030 | + self.graph.add_polar(expr, (0.0, std::f64::consts::TAU)); |
| 1031 | + self.history.push(HistoryEntry { |
| 1032 | + input: input.clone(), |
| 1033 | + result: format!( |
| 1034 | + "Added polar curve {}", |
| 1035 | + self.graph.functions.len() |
| 1036 | + ), |
| 1037 | + error: None, |
| 1038 | + }); |
| 1039 | + self.invalidate_caches(); |
| 1040 | + } |
| 1041 | + Err(e) => { |
| 1042 | + self.history.push(HistoryEntry { |
| 1043 | + input: input.clone(), |
| 1044 | + result: String::new(), |
| 1045 | + error: Some(e.to_string()), |
| 1046 | + }); |
| 1047 | + } |
| 1048 | + } |
| 1049 | + } else if lhs == "y" { |
| 764 | 1050 | // Explicit function y = f(x) |
| 765 | 1051 | match parser::parse(rhs) { |
| 766 | 1052 | Ok(expr) => { |
@@ -770,6 +1056,7 @@ impl App { |
| 770 | 1056 | result: format!("Added function {}", self.graph.functions.len()), |
| 771 | 1057 | error: None, |
| 772 | 1058 | }); |
| 1059 | + self.invalidate_caches(); |
| 773 | 1060 | } |
| 774 | 1061 | Err(e) => { |
| 775 | 1062 | self.history.push(HistoryEntry { |
@@ -793,6 +1080,7 @@ impl App { |
| 793 | 1080 | ), |
| 794 | 1081 | error: None, |
| 795 | 1082 | }); |
| 1083 | + self.invalidate_caches(); |
| 796 | 1084 | } |
| 797 | 1085 | Err(e) => { |
| 798 | 1086 | self.history.push(HistoryEntry { |
@@ -803,6 +1091,8 @@ impl App { |
| 803 | 1091 | } |
| 804 | 1092 | } |
| 805 | 1093 | } |
| 1094 | + } else if input.starts_with("piecewise(") || input.starts_with("pw(") { |
| 1095 | + self.parse_piecewise(&input); |
| 806 | 1096 | } else if input.contains(',') && (input.contains('t') || input.starts_with('(')) { |
| 807 | 1097 | // Parametric curve: (x(t), y(t)) or x(t), y(t) |
| 808 | 1098 | self.parse_parametric(&input); |
@@ -816,6 +1106,7 @@ impl App { |
| 816 | 1106 | result: format!("Added function {}", self.graph.functions.len()), |
| 817 | 1107 | error: None, |
| 818 | 1108 | }); |
| 1109 | + self.invalidate_caches(); |
| 819 | 1110 | } |
| 820 | 1111 | Err(e) => { |
| 821 | 1112 | self.history.push(HistoryEntry { |
@@ -827,6 +1118,97 @@ impl App { |
| 827 | 1118 | } |
| 828 | 1119 | } |
| 829 | 1120 | } else if self.mode == Mode::Graph3D { |
| 1121 | + // Check for spherical surface: "sphere: expr" or "sph: expr" |
| 1122 | + let sph_prefix = input.strip_prefix("sphere:") |
| 1123 | + .or_else(|| input.strip_prefix("sph:")); |
| 1124 | + if let Some(sph_expr) = sph_prefix { |
| 1125 | + match parser::parse(sph_expr.trim()) { |
| 1126 | + Ok(expr) => { |
| 1127 | + self.graph3d.add_spherical(expr); |
| 1128 | + self.history.push(HistoryEntry { |
| 1129 | + input: input.clone(), |
| 1130 | + result: format!("Added spherical surface {}", self.graph3d.surfaces.len()), |
| 1131 | + error: None, |
| 1132 | + }); |
| 1133 | + } |
| 1134 | + Err(e) => { |
| 1135 | + self.history.push(HistoryEntry { |
| 1136 | + input: input.clone(), |
| 1137 | + result: String::new(), |
| 1138 | + error: Some(e.to_string()), |
| 1139 | + }); |
| 1140 | + } |
| 1141 | + } |
| 1142 | + self.input.clear(); |
| 1143 | + self.cursor = 0; |
| 1144 | + self.history_index = None; |
| 1145 | + return; |
| 1146 | + } |
| 1147 | + |
| 1148 | + // Check for cylindrical surface: "cyl: expr" or "cylinder: expr" |
| 1149 | + let cyl_prefix = input.strip_prefix("cyl:") |
| 1150 | + .or_else(|| input.strip_prefix("cylinder:")); |
| 1151 | + if let Some(cyl_expr) = cyl_prefix { |
| 1152 | + match parser::parse(cyl_expr.trim()) { |
| 1153 | + Ok(expr) => { |
| 1154 | + self.graph3d.add_cylindrical(expr); |
| 1155 | + self.history.push(HistoryEntry { |
| 1156 | + input: input.clone(), |
| 1157 | + result: format!("Added cylindrical surface {}", self.graph3d.surfaces.len()), |
| 1158 | + error: None, |
| 1159 | + }); |
| 1160 | + } |
| 1161 | + Err(e) => { |
| 1162 | + self.history.push(HistoryEntry { |
| 1163 | + input: input.clone(), |
| 1164 | + result: String::new(), |
| 1165 | + error: Some(e.to_string()), |
| 1166 | + }); |
| 1167 | + } |
| 1168 | + } |
| 1169 | + self.input.clear(); |
| 1170 | + self.cursor = 0; |
| 1171 | + self.history_index = None; |
| 1172 | + return; |
| 1173 | + } |
| 1174 | + |
| 1175 | + // Check for level surface: "level: expr = c" |
| 1176 | + if let Some(level_str) = input.strip_prefix("level:") { |
| 1177 | + let level_str = level_str.trim(); |
| 1178 | + if let Some(eq_pos) = level_str.find('=') { |
| 1179 | + let expr_str = level_str[..eq_pos].trim(); |
| 1180 | + let level_val_str = level_str[eq_pos + 1..].trim(); |
| 1181 | + let level_val = level_val_str.parse::<f64>().unwrap_or(0.0); |
| 1182 | + match parser::parse(expr_str) { |
| 1183 | + Ok(expr) => { |
| 1184 | + self.graph3d.add_level_surface(expr, level_val); |
| 1185 | + self.history.push(HistoryEntry { |
| 1186 | + input: input.clone(), |
| 1187 | + result: format!("Added level surface {}", self.graph3d.surfaces.len()), |
| 1188 | + error: None, |
| 1189 | + }); |
| 1190 | + } |
| 1191 | + Err(e) => { |
| 1192 | + self.history.push(HistoryEntry { |
| 1193 | + input: input.clone(), |
| 1194 | + result: String::new(), |
| 1195 | + error: Some(e.to_string()), |
| 1196 | + }); |
| 1197 | + } |
| 1198 | + } |
| 1199 | + } else { |
| 1200 | + self.history.push(HistoryEntry { |
| 1201 | + input: input.clone(), |
| 1202 | + result: String::new(), |
| 1203 | + error: Some("Level surface format: level: f(x,y,z) = c".to_string()), |
| 1204 | + }); |
| 1205 | + } |
| 1206 | + self.input.clear(); |
| 1207 | + self.cursor = 0; |
| 1208 | + self.history_index = None; |
| 1209 | + return; |
| 1210 | + } |
| 1211 | + |
| 830 | 1212 | // Check for parametric surface: (x(u,v), y(u,v), z(u,v)) |
| 831 | 1213 | if input.contains(',') && (input.contains('u') || input.starts_with('(')) { |
| 832 | 1214 | self.parse_parametric_surface(&input); |
@@ -951,6 +1333,7 @@ impl App { |
| 951 | 1333 | result: format!("Added parametric curve {}", self.graph.functions.len()), |
| 952 | 1334 | error: None, |
| 953 | 1335 | }); |
| 1336 | + self.invalidate_caches(); |
| 954 | 1337 | } |
| 955 | 1338 | (Err(e), _) | (_, Err(e)) => { |
| 956 | 1339 | self.history.push(HistoryEntry { |
@@ -1035,6 +1418,227 @@ impl App { |
| 1035 | 1418 | } |
| 1036 | 1419 | } |
| 1037 | 1420 | |
| 1421 | + /// Parse and add a piecewise function |
| 1422 | + fn parse_piecewise(&mut self, input: &str) { |
| 1423 | + let inner = input |
| 1424 | + .trim() |
| 1425 | + .strip_prefix("piecewise(") |
| 1426 | + .or_else(|| input.trim().strip_prefix("pw(")) |
| 1427 | + .and_then(|s| s.strip_suffix(')')); |
| 1428 | + |
| 1429 | + let inner = match inner { |
| 1430 | + Some(s) => s, |
| 1431 | + None => { |
| 1432 | + self.history.push(HistoryEntry { |
| 1433 | + input: input.to_string(), |
| 1434 | + result: String::new(), |
| 1435 | + error: Some( |
| 1436 | + "Invalid piecewise syntax. Use: piecewise(expr1, cond1, expr2, cond2, ...)" |
| 1437 | + .to_string(), |
| 1438 | + ), |
| 1439 | + }); |
| 1440 | + return; |
| 1441 | + } |
| 1442 | + }; |
| 1443 | + |
| 1444 | + let parts: Vec<&str> = inner.split(',').collect(); |
| 1445 | + if parts.len() < 2 || parts.len() % 2 != 0 { |
| 1446 | + self.history.push(HistoryEntry { |
| 1447 | + input: input.to_string(), |
| 1448 | + result: String::new(), |
| 1449 | + error: Some( |
| 1450 | + "Piecewise needs pairs: expr1, cond1, expr2, cond2, ...".to_string(), |
| 1451 | + ), |
| 1452 | + }); |
| 1453 | + return; |
| 1454 | + } |
| 1455 | + |
| 1456 | + let mut pieces = Vec::new(); |
| 1457 | + for chunk in parts.chunks(2) { |
| 1458 | + let expr_str = chunk[0].trim(); |
| 1459 | + let cond_str = chunk[1].trim(); |
| 1460 | + |
| 1461 | + let expr = match parser::parse(expr_str) { |
| 1462 | + Ok(e) => e, |
| 1463 | + Err(e) => { |
| 1464 | + self.history.push(HistoryEntry { |
| 1465 | + input: input.to_string(), |
| 1466 | + result: String::new(), |
| 1467 | + error: Some(format!("Parse error in '{}': {}", expr_str, e)), |
| 1468 | + }); |
| 1469 | + return; |
| 1470 | + } |
| 1471 | + }; |
| 1472 | + |
| 1473 | + let condition = match Self::parse_condition(cond_str) { |
| 1474 | + Some(c) => c, |
| 1475 | + None => { |
| 1476 | + self.history.push(HistoryEntry { |
| 1477 | + input: input.to_string(), |
| 1478 | + result: String::new(), |
| 1479 | + error: Some(format!( |
| 1480 | + "Invalid condition: '{}'. Use: x<0, x>=2, else", |
| 1481 | + cond_str |
| 1482 | + )), |
| 1483 | + }); |
| 1484 | + return; |
| 1485 | + } |
| 1486 | + }; |
| 1487 | + |
| 1488 | + pieces.push(garcalc_graph::PiecewisePiece { expr, condition }); |
| 1489 | + } |
| 1490 | + |
| 1491 | + self.graph.add_piecewise(pieces); |
| 1492 | + self.history.push(HistoryEntry { |
| 1493 | + input: input.to_string(), |
| 1494 | + result: format!("Added piecewise function {}", self.graph.functions.len()), |
| 1495 | + error: None, |
| 1496 | + }); |
| 1497 | + self.invalidate_caches(); |
| 1498 | + } |
| 1499 | + |
| 1500 | + fn parse_condition(s: &str) -> Option<garcalc_graph::PiecewiseCondition> { |
| 1501 | + use garcalc_graph::PiecewiseCondition; |
| 1502 | + let s = s.trim(); |
| 1503 | + |
| 1504 | + if s == "else" || s == "otherwise" { |
| 1505 | + return Some(PiecewiseCondition::Always); |
| 1506 | + } |
| 1507 | + |
| 1508 | + // Try range conditions like "0 <= x < 3" |
| 1509 | + if let Some(cond) = Self::parse_range_condition(s) { |
| 1510 | + return Some(cond); |
| 1511 | + } |
| 1512 | + |
| 1513 | + // Try "x >= val", "x > val", "x <= val", "x < val" |
| 1514 | + for (op, ctor) in [ |
| 1515 | + ( |
| 1516 | + ">=", |
| 1517 | + PiecewiseCondition::GreaterEqual as fn(f64) -> PiecewiseCondition, |
| 1518 | + ), |
| 1519 | + ( |
| 1520 | + "<=", |
| 1521 | + PiecewiseCondition::LessEqual as fn(f64) -> PiecewiseCondition, |
| 1522 | + ), |
| 1523 | + ( |
| 1524 | + ">", |
| 1525 | + PiecewiseCondition::GreaterThan as fn(f64) -> PiecewiseCondition, |
| 1526 | + ), |
| 1527 | + ( |
| 1528 | + "<", |
| 1529 | + PiecewiseCondition::LessThan as fn(f64) -> PiecewiseCondition, |
| 1530 | + ), |
| 1531 | + ] { |
| 1532 | + if let Some(rest) = s |
| 1533 | + .strip_prefix("x") |
| 1534 | + .and_then(|r| r.trim_start().strip_prefix(op)) |
| 1535 | + { |
| 1536 | + if let Ok(val) = rest.trim().parse::<f64>() { |
| 1537 | + return Some(ctor(val)); |
| 1538 | + } |
| 1539 | + } |
| 1540 | + } |
| 1541 | + |
| 1542 | + None |
| 1543 | + } |
| 1544 | + |
| 1545 | + fn parse_range_condition(s: &str) -> Option<garcalc_graph::PiecewiseCondition> { |
| 1546 | + use garcalc_graph::PiecewiseCondition; |
| 1547 | + let s = s.trim(); |
| 1548 | + |
| 1549 | + let x_pos = s.find('x')?; |
| 1550 | + let left = s[..x_pos].trim(); |
| 1551 | + let right = s[x_pos + 1..].trim(); |
| 1552 | + |
| 1553 | + if left.is_empty() || right.is_empty() { |
| 1554 | + return None; |
| 1555 | + } |
| 1556 | + |
| 1557 | + let (left_val, left_inclusive) = if let Some(rest) = left.strip_suffix("<=") { |
| 1558 | + (rest.trim().parse::<f64>().ok()?, true) |
| 1559 | + } else if let Some(rest) = left.strip_suffix("<") { |
| 1560 | + (rest.trim().parse::<f64>().ok()?, false) |
| 1561 | + } else { |
| 1562 | + return None; |
| 1563 | + }; |
| 1564 | + |
| 1565 | + let (right_val, right_inclusive) = if let Some(rest) = right.strip_prefix("<=") { |
| 1566 | + (rest.trim().parse::<f64>().ok()?, true) |
| 1567 | + } else if let Some(rest) = right.strip_prefix("<") { |
| 1568 | + (rest.trim().parse::<f64>().ok()?, false) |
| 1569 | + } else { |
| 1570 | + return None; |
| 1571 | + }; |
| 1572 | + |
| 1573 | + if left_inclusive && right_inclusive { |
| 1574 | + Some(PiecewiseCondition::BetweenInclusive(left_val, right_val)) |
| 1575 | + } else { |
| 1576 | + Some(PiecewiseCondition::Between(left_val, right_val)) |
| 1577 | + } |
| 1578 | + } |
| 1579 | + |
| 1580 | + fn commit_viewport_edit(&mut self) { |
| 1581 | + if let Ok(val) = self.viewport_edit_buffer.parse::<f64>() { |
| 1582 | + match self.viewport_edit_field { |
| 1583 | + 0 => self.graph.viewport.x_min = val, |
| 1584 | + 1 => self.graph.viewport.x_max = val, |
| 1585 | + 2 => self.graph.viewport.y_min = val, |
| 1586 | + 3 => self.graph.viewport.y_max = val, |
| 1587 | + _ => {} |
| 1588 | + } |
| 1589 | + } |
| 1590 | + } |
| 1591 | + |
| 1592 | + fn load_viewport_edit_buffer(&mut self) { |
| 1593 | + self.viewport_edit_buffer = match self.viewport_edit_field { |
| 1594 | + 0 => format!("{:.4}", self.graph.viewport.x_min), |
| 1595 | + 1 => format!("{:.4}", self.graph.viewport.x_max), |
| 1596 | + 2 => format!("{:.4}", self.graph.viewport.y_min), |
| 1597 | + 3 => format!("{:.4}", self.graph.viewport.y_max), |
| 1598 | + _ => String::new(), |
| 1599 | + }; |
| 1600 | + } |
| 1601 | + |
| 1602 | + fn invalidate_caches(&mut self) { |
| 1603 | + if self.zeros_visible { |
| 1604 | + self.cached_zeros = self.graph.find_zeros(); |
| 1605 | + self.cached_intersections = self.graph.find_intersections(); |
| 1606 | + } |
| 1607 | + if self.table_visible { |
| 1608 | + self.regenerate_table(); |
| 1609 | + } |
| 1610 | + } |
| 1611 | + |
| 1612 | + fn regenerate_table(&mut self) { |
| 1613 | + let x_min = self.graph.viewport.x_min; |
| 1614 | + let x_max = self.graph.viewport.x_max; |
| 1615 | + self.table_data = |
| 1616 | + self.graph |
| 1617 | + .generate_table(self.table_func_index, x_min, x_max, self.table_step); |
| 1618 | + self.table_scroll_offset = 0; |
| 1619 | + } |
| 1620 | + |
| 1621 | + fn export_table_to_clipboard(&self) { |
| 1622 | + let mut csv = String::from("x\tf(x)\n"); |
| 1623 | + for (x, y) in &self.table_data { |
| 1624 | + match y { |
| 1625 | + Some(yv) => csv.push_str(&format!("{:.6}\t{:.6}\n", x, yv)), |
| 1626 | + None => csv.push_str(&format!("{:.6}\tundefined\n", x)), |
| 1627 | + } |
| 1628 | + } |
| 1629 | + let _ = Command::new("xclip") |
| 1630 | + .args(["-selection", "clipboard"]) |
| 1631 | + .stdin(std::process::Stdio::piped()) |
| 1632 | + .spawn() |
| 1633 | + .and_then(|mut child| { |
| 1634 | + use std::io::Write; |
| 1635 | + if let Some(ref mut stdin) = child.stdin { |
| 1636 | + let _ = stdin.write_all(csv.as_bytes()); |
| 1637 | + } |
| 1638 | + child.wait() |
| 1639 | + }); |
| 1640 | + } |
| 1641 | + |
| 1038 | 1642 | fn render(&mut self) -> Result<()> { |
| 1039 | 1643 | let math_input = if self.mode == Mode::Calculator { |
| 1040 | 1644 | Some(&self.math_input) |
@@ -1052,6 +1656,21 @@ impl App { |
| 1052 | 1656 | &self.graph3d, |
| 1053 | 1657 | self.help_modal_open, |
| 1054 | 1658 | self.calc_buttons_extended, |
| 1659 | + self.show_function_list, |
| 1660 | + self.zeros_visible, |
| 1661 | + &self.cached_zeros, |
| 1662 | + &self.cached_intersections, |
| 1663 | + self.table_visible, |
| 1664 | + self.table_scroll_offset, |
| 1665 | + self.table_step, |
| 1666 | + &self.table_data, |
| 1667 | + self.viewport_panel_open, |
| 1668 | + self.viewport_edit_field, |
| 1669 | + &self.viewport_edit_buffer, |
| 1670 | + self.color_picker_open, |
| 1671 | + self.color_picker_func_index, |
| 1672 | + self.settings3d_panel_open, |
| 1673 | + self.auto_rotate, |
| 1055 | 1674 | )?; |
| 1056 | 1675 | Ok(()) |
| 1057 | 1676 | } |