@@ -4,9 +4,10 @@ |
| 4 | 4 | //! fraction bars, radical signs, integral symbols, etc. |
| 5 | 5 | |
| 6 | 6 | use crate::layout::{LayoutBox, MathLayoutEngine}; |
| 7 | | -use crate::mathbox::{MathBox, Operator, LimitDirection}; |
| 7 | +use crate::mathbox::{LimitDirection, MathBox, Operator}; |
| 8 | 8 | use cairo::Context; |
| 9 | 9 | use gartk_core::Color; |
| 10 | +use std::cell::Cell; |
| 10 | 11 | |
| 11 | 12 | /// Renderer for mathematical notation |
| 12 | 13 | pub struct MathRenderer<'a> { |
@@ -18,6 +19,8 @@ pub struct MathRenderer<'a> { |
| 18 | 19 | pub slot_bg_color: Color, |
| 19 | 20 | /// Slot border color when focused |
| 20 | 21 | pub slot_focus_color: Color, |
| 22 | + /// Whether the insertion cursor should be drawn |
| 23 | + cursor_visible: Cell<bool>, |
| 21 | 24 | } |
| 22 | 25 | |
| 23 | 26 | impl<'a> MathRenderer<'a> { |
@@ -29,18 +32,29 @@ impl<'a> MathRenderer<'a> { |
| 29 | 32 | fg_color: Color::new(0.0, 0.0, 0.0, 1.0), |
| 30 | 33 | slot_bg_color: Color::new(0.9, 0.9, 0.95, 1.0), |
| 31 | 34 | slot_focus_color: Color::new(0.3, 0.5, 0.9, 1.0), |
| 35 | + cursor_visible: Cell::new(true), |
| 32 | 36 | } |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | 39 | /// Render a MathBox tree at the given position |
| 36 | 40 | /// (x, y) is the baseline position |
| 37 | 41 | pub fn render(&self, mathbox: &MathBox, x: f64, y: f64) { |
| 38 | | - self.render_at_depth(mathbox, x, y, 0, None); |
| 42 | + self.render_at_depth(mathbox, x, y, 0, None, 0); |
| 39 | 43 | } |
| 40 | 44 | |
| 41 | 45 | /// Render with cursor highlighting |
| 42 | | - pub fn render_with_cursor(&self, mathbox: &MathBox, x: f64, y: f64, cursor_path: &[usize]) { |
| 43 | | - self.render_at_depth(mathbox, x, y, 0, Some(cursor_path)); |
| 46 | + pub fn render_with_cursor( |
| 47 | + &self, |
| 48 | + mathbox: &MathBox, |
| 49 | + x: f64, |
| 50 | + y: f64, |
| 51 | + cursor_path: &[usize], |
| 52 | + cursor_offset: usize, |
| 53 | + cursor_visible: bool, |
| 54 | + ) { |
| 55 | + self.cursor_visible.set(cursor_visible); |
| 56 | + self.render_at_depth(mathbox, x, y, 0, Some(cursor_path), cursor_offset); |
| 57 | + self.cursor_visible.set(true); |
| 44 | 58 | } |
| 45 | 59 | |
| 46 | 60 | /// Internal render with depth tracking |
@@ -51,24 +65,39 @@ impl<'a> MathRenderer<'a> { |
| 51 | 65 | y: f64, |
| 52 | 66 | depth: u32, |
| 53 | 67 | cursor_path: Option<&[usize]>, |
| 68 | + cursor_offset: usize, |
| 54 | 69 | ) { |
| 55 | 70 | let scale = self.scale_for_depth(depth); |
| 56 | 71 | let font_size = self.layout_engine.base_font_size * scale; |
| 57 | 72 | |
| 58 | 73 | // Check if cursor is at this node |
| 59 | 74 | let is_cursor_here = cursor_path.map(|p| p.is_empty()).unwrap_or(false); |
| 75 | + let is_active_container = cursor_path.map(|p| p.len() == 1).unwrap_or(false) |
| 76 | + && Self::is_focusable_container(mathbox); |
| 60 | 77 | |
| 61 | 78 | match mathbox { |
| 62 | 79 | MathBox::Number(s) => { |
| 63 | 80 | self.draw_text(s, x, y, font_size, false); |
| 64 | 81 | if is_cursor_here { |
| 65 | | - self.draw_cursor(x, y, font_size); |
| 82 | + let width = self.text_advance(s, font_size, false); |
| 83 | + self.draw_focus_frame(x, y, width, font_size, 1.4, 0.35); |
| 84 | + } |
| 85 | + if is_cursor_here && self.cursor_visible.get() { |
| 86 | + let cursor_x = |
| 87 | + x + self.text_advance_for_offset(s, font_size, false, cursor_offset); |
| 88 | + self.draw_cursor(cursor_x, y, font_size); |
| 66 | 89 | } |
| 67 | 90 | } |
| 68 | 91 | MathBox::Symbol(s) => { |
| 69 | 92 | self.draw_text(s, x, y, font_size, true); |
| 70 | 93 | if is_cursor_here { |
| 71 | | - self.draw_cursor(x, y, font_size); |
| 94 | + let width = self.text_advance(s, font_size, true); |
| 95 | + self.draw_focus_frame(x, y, width, font_size, 1.4, 0.35); |
| 96 | + } |
| 97 | + if is_cursor_here && self.cursor_visible.get() { |
| 98 | + let cursor_x = |
| 99 | + x + self.text_advance_for_offset(s, font_size, true, cursor_offset); |
| 100 | + self.draw_cursor(cursor_x, y, font_size); |
| 72 | 101 | } |
| 73 | 102 | } |
| 74 | 103 | MathBox::Operator(op) => { |
@@ -78,27 +107,40 @@ impl<'a> MathRenderer<'a> { |
| 78 | 107 | self.draw_slot(x, y, font_size, is_cursor_here); |
| 79 | 108 | } |
| 80 | 109 | MathBox::Fraction { num, den } => { |
| 81 | | - self.render_fraction(num, den, x, y, depth, cursor_path); |
| 110 | + self.render_fraction(num, den, x, y, depth, cursor_path, cursor_offset); |
| 82 | 111 | } |
| 83 | 112 | MathBox::Power { base, exp } => { |
| 84 | | - self.render_power(base, exp, x, y, depth, cursor_path); |
| 113 | + self.render_power(base, exp, x, y, depth, cursor_path, cursor_offset); |
| 85 | 114 | } |
| 86 | 115 | MathBox::Subscript { base, sub } => { |
| 87 | | - self.render_subscript(base, sub, x, y, depth, cursor_path); |
| 116 | + self.render_subscript(base, sub, x, y, depth, cursor_path, cursor_offset); |
| 88 | 117 | } |
| 89 | 118 | MathBox::Root { index, radicand } => { |
| 90 | | - self.render_root(index.as_deref(), radicand, x, y, depth, cursor_path); |
| 119 | + self.render_root( |
| 120 | + index.as_deref(), |
| 121 | + radicand, |
| 122 | + x, |
| 123 | + y, |
| 124 | + depth, |
| 125 | + cursor_path, |
| 126 | + cursor_offset, |
| 127 | + ); |
| 91 | 128 | } |
| 92 | 129 | MathBox::Func { name, args } => { |
| 93 | | - self.render_func(name, args, x, y, depth, cursor_path); |
| 130 | + self.render_func(name, args, x, y, depth, cursor_path, cursor_offset); |
| 94 | 131 | } |
| 95 | 132 | MathBox::Abs(inner) => { |
| 96 | | - self.render_abs(inner, x, y, depth, cursor_path); |
| 133 | + self.render_abs(inner, x, y, depth, cursor_path, cursor_offset); |
| 97 | 134 | } |
| 98 | 135 | MathBox::Parens(inner) => { |
| 99 | | - self.render_parens(inner, x, y, depth, cursor_path); |
| 136 | + self.render_parens(inner, x, y, depth, cursor_path, cursor_offset); |
| 100 | 137 | } |
| 101 | | - MathBox::Integral { lower, upper, body, var } => { |
| 138 | + MathBox::Integral { |
| 139 | + lower, |
| 140 | + upper, |
| 141 | + body, |
| 142 | + var, |
| 143 | + } => { |
| 102 | 144 | self.render_integral( |
| 103 | 145 | lower.as_deref(), |
| 104 | 146 | upper.as_deref(), |
@@ -108,27 +150,79 @@ impl<'a> MathRenderer<'a> { |
| 108 | 150 | y, |
| 109 | 151 | depth, |
| 110 | 152 | cursor_path, |
| 153 | + cursor_offset, |
| 111 | 154 | ); |
| 112 | 155 | } |
| 113 | 156 | MathBox::Derivative { order, var, body } => { |
| 114 | | - self.render_derivative(*order, var, body, x, y, depth, cursor_path); |
| 157 | + self.render_derivative(*order, var, body, x, y, depth, cursor_path, cursor_offset); |
| 115 | 158 | } |
| 116 | | - MathBox::Limit { var, to, direction, body } => { |
| 117 | | - self.render_limit(var, to, *direction, body, x, y, depth, cursor_path); |
| 159 | + MathBox::Limit { |
| 160 | + var, |
| 161 | + to, |
| 162 | + direction, |
| 163 | + body, |
| 164 | + } => { |
| 165 | + self.render_limit( |
| 166 | + var, |
| 167 | + to, |
| 168 | + *direction, |
| 169 | + body, |
| 170 | + x, |
| 171 | + y, |
| 172 | + depth, |
| 173 | + cursor_path, |
| 174 | + cursor_offset, |
| 175 | + ); |
| 118 | 176 | } |
| 119 | | - MathBox::Sum { var, lower, upper, body } => { |
| 120 | | - self.render_bigop("∑", var, lower, upper, body, x, y, depth, cursor_path); |
| 177 | + MathBox::Sum { |
| 178 | + var, |
| 179 | + lower, |
| 180 | + upper, |
| 181 | + body, |
| 182 | + } => { |
| 183 | + self.render_bigop( |
| 184 | + "∑", |
| 185 | + var, |
| 186 | + lower, |
| 187 | + upper, |
| 188 | + body, |
| 189 | + x, |
| 190 | + y, |
| 191 | + depth, |
| 192 | + cursor_path, |
| 193 | + cursor_offset, |
| 194 | + ); |
| 121 | 195 | } |
| 122 | | - MathBox::Product { var, lower, upper, body } => { |
| 123 | | - self.render_bigop("∏", var, lower, upper, body, x, y, depth, cursor_path); |
| 196 | + MathBox::Product { |
| 197 | + var, |
| 198 | + lower, |
| 199 | + upper, |
| 200 | + body, |
| 201 | + } => { |
| 202 | + self.render_bigop( |
| 203 | + "∏", |
| 204 | + var, |
| 205 | + lower, |
| 206 | + upper, |
| 207 | + body, |
| 208 | + x, |
| 209 | + y, |
| 210 | + depth, |
| 211 | + cursor_path, |
| 212 | + cursor_offset, |
| 213 | + ); |
| 124 | 214 | } |
| 125 | 215 | MathBox::Matrix { rows } => { |
| 126 | | - self.render_matrix(rows, x, y, depth, cursor_path); |
| 216 | + self.render_matrix(rows, x, y, depth, cursor_path, cursor_offset); |
| 127 | 217 | } |
| 128 | 218 | MathBox::Row(items) => { |
| 129 | | - self.render_row(items, x, y, depth, cursor_path); |
| 219 | + self.render_row(items, x, y, depth, cursor_path, cursor_offset); |
| 130 | 220 | } |
| 131 | 221 | } |
| 222 | + |
| 223 | + if is_active_container { |
| 224 | + self.draw_container_focus_frame(mathbox, x, y, depth); |
| 225 | + } |
| 132 | 226 | } |
| 133 | 227 | |
| 134 | 228 | fn scale_for_depth(&self, depth: u32) -> f64 { |
@@ -157,6 +251,43 @@ impl<'a> MathRenderer<'a> { |
| 157 | 251 | self.ctx.restore().unwrap(); |
| 158 | 252 | } |
| 159 | 253 | |
| 254 | + /// Measure text advance using the same style as `draw_text` |
| 255 | + fn text_advance(&self, text: &str, font_size: f64, italic: bool) -> f64 { |
| 256 | + self.ctx.save().unwrap(); |
| 257 | + let slant = if italic { |
| 258 | + cairo::FontSlant::Italic |
| 259 | + } else { |
| 260 | + cairo::FontSlant::Normal |
| 261 | + }; |
| 262 | + self.ctx.select_font_face( |
| 263 | + &self.layout_engine.font_family, |
| 264 | + slant, |
| 265 | + cairo::FontWeight::Normal, |
| 266 | + ); |
| 267 | + self.ctx.set_font_size(font_size); |
| 268 | + let advance = self |
| 269 | + .ctx |
| 270 | + .text_extents(text) |
| 271 | + .map(|e| e.x_advance()) |
| 272 | + .unwrap_or(0.0); |
| 273 | + self.ctx.restore().unwrap(); |
| 274 | + advance |
| 275 | + } |
| 276 | + |
| 277 | + fn text_advance_for_offset( |
| 278 | + &self, |
| 279 | + text: &str, |
| 280 | + font_size: f64, |
| 281 | + italic: bool, |
| 282 | + char_offset: usize, |
| 283 | + ) -> f64 { |
| 284 | + let prefix: String = text |
| 285 | + .chars() |
| 286 | + .take(char_offset.min(text.chars().count())) |
| 287 | + .collect(); |
| 288 | + self.text_advance(&prefix, font_size, italic) |
| 289 | + } |
| 290 | + |
| 160 | 291 | /// Draw an operator |
| 161 | 292 | fn draw_operator(&self, op: Operator, x: f64, y: f64, font_size: f64) { |
| 162 | 293 | let padding = font_size * 0.15; |
@@ -165,9 +296,7 @@ impl<'a> MathRenderer<'a> { |
| 165 | 296 | |
| 166 | 297 | /// Draw an empty slot |
| 167 | 298 | fn draw_slot(&self, x: f64, y: f64, font_size: f64, focused: bool) { |
| 168 | | - let width = font_size * 0.8; |
| 169 | | - let height = font_size * 0.8; |
| 170 | | - let ascent = height * 0.6; |
| 299 | + let (width, height, ascent) = Self::slot_geometry(font_size); |
| 171 | 300 | |
| 172 | 301 | self.ctx.save().unwrap(); |
| 173 | 302 | |
@@ -196,7 +325,7 @@ impl<'a> MathRenderer<'a> { |
| 196 | 325 | self.set_color(&self.slot_focus_color); |
| 197 | 326 | self.ctx.set_line_width(2.0); |
| 198 | 327 | |
| 199 | | - let height = font_size * 0.8; |
| 328 | + let (_, height, _) = Self::slot_geometry(font_size); |
| 200 | 329 | self.ctx.move_to(x, y - height * 0.6); |
| 201 | 330 | self.ctx.line_to(x, y + height * 0.4); |
| 202 | 331 | self.ctx.stroke().unwrap(); |
@@ -204,6 +333,89 @@ impl<'a> MathRenderer<'a> { |
| 204 | 333 | self.ctx.restore().unwrap(); |
| 205 | 334 | } |
| 206 | 335 | |
| 336 | + /// Draw a focus frame around the active token |
| 337 | + fn draw_focus_frame( |
| 338 | + &self, |
| 339 | + x: f64, |
| 340 | + y: f64, |
| 341 | + width: f64, |
| 342 | + font_size: f64, |
| 343 | + line_width: f64, |
| 344 | + alpha: f64, |
| 345 | + ) { |
| 346 | + let ascent = font_size * 0.72; |
| 347 | + let descent = font_size * 0.28; |
| 348 | + let padding = (font_size * 0.12).max(1.2); |
| 349 | + self.draw_focus_bounds(x, y, width, ascent, descent, padding, line_width, alpha); |
| 350 | + } |
| 351 | + |
| 352 | + /// Draw a focus frame around the active container (e.g. power/fraction) |
| 353 | + fn draw_container_focus_frame(&self, mathbox: &MathBox, x: f64, y: f64, depth: u32) { |
| 354 | + let layout = self |
| 355 | + .layout_engine |
| 356 | + .layout_with_depth(mathbox, self.ctx, depth); |
| 357 | + let scale = self.scale_for_depth(depth); |
| 358 | + let padding = (self.layout_engine.base_font_size * scale * 0.12).max(1.4); |
| 359 | + self.draw_focus_bounds( |
| 360 | + x, |
| 361 | + y, |
| 362 | + layout.width, |
| 363 | + layout.ascent, |
| 364 | + layout.descent, |
| 365 | + padding, |
| 366 | + 1.5, |
| 367 | + 0.28, |
| 368 | + ); |
| 369 | + } |
| 370 | + |
| 371 | + fn draw_focus_bounds( |
| 372 | + &self, |
| 373 | + x: f64, |
| 374 | + y: f64, |
| 375 | + width: f64, |
| 376 | + ascent: f64, |
| 377 | + descent: f64, |
| 378 | + padding: f64, |
| 379 | + line_width: f64, |
| 380 | + alpha: f64, |
| 381 | + ) { |
| 382 | + self.ctx.save().unwrap(); |
| 383 | + self.set_color(&Color::new( |
| 384 | + self.slot_focus_color.r, |
| 385 | + self.slot_focus_color.g, |
| 386 | + self.slot_focus_color.b, |
| 387 | + alpha, |
| 388 | + )); |
| 389 | + self.ctx.set_line_width(line_width); |
| 390 | + self.ctx.rectangle( |
| 391 | + x - padding, |
| 392 | + y - ascent - padding, |
| 393 | + width + padding * 2.0, |
| 394 | + ascent + descent + padding * 2.0, |
| 395 | + ); |
| 396 | + self.ctx.stroke().unwrap(); |
| 397 | + self.ctx.restore().unwrap(); |
| 398 | + } |
| 399 | + |
| 400 | + fn is_focusable_container(mathbox: &MathBox) -> bool { |
| 401 | + matches!( |
| 402 | + mathbox, |
| 403 | + MathBox::Fraction { .. } |
| 404 | + | MathBox::Power { .. } |
| 405 | + | MathBox::Subscript { .. } |
| 406 | + | MathBox::Root { .. } |
| 407 | + | MathBox::Func { .. } |
| 408 | + | MathBox::Abs(_) |
| 409 | + | MathBox::Parens(_) |
| 410 | + | MathBox::Integral { .. } |
| 411 | + | MathBox::Derivative { .. } |
| 412 | + | MathBox::Limit { .. } |
| 413 | + | MathBox::Sum { .. } |
| 414 | + | MathBox::Product { .. } |
| 415 | + | MathBox::Matrix { .. } |
| 416 | + ) |
| 417 | + } |
| 418 | + |
| 207 | 419 | /// Render a fraction |
| 208 | 420 | fn render_fraction( |
| 209 | 421 | &self, |
@@ -213,11 +425,15 @@ impl<'a> MathRenderer<'a> { |
| 213 | 425 | y: f64, |
| 214 | 426 | depth: u32, |
| 215 | 427 | cursor_path: Option<&[usize]>, |
| 428 | + cursor_offset: usize, |
| 216 | 429 | ) { |
| 217 | | - let layout = self.layout_engine.layout(&MathBox::Fraction { |
| 218 | | - num: Box::new(num.clone()), |
| 219 | | - den: Box::new(den.clone()), |
| 220 | | - }, self.ctx); |
| 430 | + let layout = self.layout_engine.layout( |
| 431 | + &MathBox::Fraction { |
| 432 | + num: Box::new(num.clone()), |
| 433 | + den: Box::new(den.clone()), |
| 434 | + }, |
| 435 | + self.ctx, |
| 436 | + ); |
| 221 | 437 | |
| 222 | 438 | let scale = self.scale_for_depth(depth); |
| 223 | 439 | let bar_thickness = 1.0 * scale; |
@@ -243,14 +459,22 @@ impl<'a> MathRenderer<'a> { |
| 243 | 459 | |
| 244 | 460 | // Draw numerator and denominator |
| 245 | 461 | let num_cursor = cursor_path.and_then(|p| { |
| 246 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 462 | + if !p.is_empty() && p[0] == 0 { |
| 463 | + Some(&p[1..]) |
| 464 | + } else { |
| 465 | + None |
| 466 | + } |
| 247 | 467 | }); |
| 248 | 468 | let den_cursor = cursor_path.and_then(|p| { |
| 249 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 469 | + if !p.is_empty() && p[0] == 1 { |
| 470 | + Some(&p[1..]) |
| 471 | + } else { |
| 472 | + None |
| 473 | + } |
| 250 | 474 | }); |
| 251 | 475 | |
| 252 | | - self.render_at_depth(num, num_x, num_y, depth + 1, num_cursor); |
| 253 | | - self.render_at_depth(den, den_x, den_y, depth + 1, den_cursor); |
| 476 | + self.render_at_depth(num, num_x, num_y, depth + 1, num_cursor, cursor_offset); |
| 477 | + self.render_at_depth(den, den_x, den_y, depth + 1, den_cursor, cursor_offset); |
| 254 | 478 | } |
| 255 | 479 | |
| 256 | 480 | /// Render a power (superscript) |
@@ -262,20 +486,42 @@ impl<'a> MathRenderer<'a> { |
| 262 | 486 | y: f64, |
| 263 | 487 | depth: u32, |
| 264 | 488 | cursor_path: Option<&[usize]>, |
| 489 | + cursor_offset: usize, |
| 265 | 490 | ) { |
| 266 | | - let base_layout = self.layout_engine.layout(base, self.ctx); |
| 491 | + let base_layout = self.layout_engine.layout_with_depth(base, self.ctx, depth); |
| 492 | + let exp_layout = self |
| 493 | + .layout_engine |
| 494 | + .layout_with_depth(exp, self.ctx, depth + 1); |
| 495 | + let scale = self.scale_for_depth(depth); |
| 496 | + let font_size = self.layout_engine.base_font_size * scale; |
| 267 | 497 | |
| 268 | 498 | let base_cursor = cursor_path.and_then(|p| { |
| 269 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 499 | + if !p.is_empty() && p[0] == 0 { |
| 500 | + Some(&p[1..]) |
| 501 | + } else { |
| 502 | + None |
| 503 | + } |
| 270 | 504 | }); |
| 271 | 505 | let exp_cursor = cursor_path.and_then(|p| { |
| 272 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 506 | + if !p.is_empty() && p[0] == 1 { |
| 507 | + Some(&p[1..]) |
| 508 | + } else { |
| 509 | + None |
| 510 | + } |
| 273 | 511 | }); |
| 274 | 512 | |
| 275 | | - self.render_at_depth(base, x, y, depth, base_cursor); |
| 276 | | - |
| 277 | | - let exp_raise = base_layout.ascent * 0.5; |
| 278 | | - self.render_at_depth(exp, x + base_layout.width, y - exp_raise, depth + 1, exp_cursor); |
| 513 | + self.render_at_depth(base, x, y, depth, base_cursor, cursor_offset); |
| 514 | + |
| 515 | + let exp_raise = base_layout.ascent * 0.58 + exp_layout.descent * 0.1; |
| 516 | + let exp_kern = (font_size * 0.06).max(0.8); |
| 517 | + self.render_at_depth( |
| 518 | + exp, |
| 519 | + x + base_layout.width + exp_kern, |
| 520 | + y - exp_raise, |
| 521 | + depth + 1, |
| 522 | + exp_cursor, |
| 523 | + cursor_offset, |
| 524 | + ); |
| 279 | 525 | } |
| 280 | 526 | |
| 281 | 527 | /// Render a subscript |
@@ -287,21 +533,43 @@ impl<'a> MathRenderer<'a> { |
| 287 | 533 | y: f64, |
| 288 | 534 | depth: u32, |
| 289 | 535 | cursor_path: Option<&[usize]>, |
| 536 | + cursor_offset: usize, |
| 290 | 537 | ) { |
| 291 | | - let base_layout = self.layout_engine.layout(base, self.ctx); |
| 292 | | - let sub_layout = self.layout_engine.layout(sub, self.ctx); |
| 538 | + let base_layout = self.layout_engine.layout_with_depth(base, self.ctx, depth); |
| 539 | + let sub_layout = self |
| 540 | + .layout_engine |
| 541 | + .layout_with_depth(sub, self.ctx, depth + 1); |
| 542 | + let scale = self.scale_for_depth(depth); |
| 543 | + let font_size = self.layout_engine.base_font_size * scale; |
| 293 | 544 | |
| 294 | 545 | let base_cursor = cursor_path.and_then(|p| { |
| 295 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 546 | + if !p.is_empty() && p[0] == 0 { |
| 547 | + Some(&p[1..]) |
| 548 | + } else { |
| 549 | + None |
| 550 | + } |
| 296 | 551 | }); |
| 297 | 552 | let sub_cursor = cursor_path.and_then(|p| { |
| 298 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 553 | + if !p.is_empty() && p[0] == 1 { |
| 554 | + Some(&p[1..]) |
| 555 | + } else { |
| 556 | + None |
| 557 | + } |
| 299 | 558 | }); |
| 300 | 559 | |
| 301 | | - self.render_at_depth(base, x, y, depth, base_cursor); |
| 302 | | - |
| 303 | | - let sub_lower = base_layout.descent + sub_layout.ascent * 0.3; |
| 304 | | - self.render_at_depth(sub, x + base_layout.width, y + sub_lower, depth + 1, sub_cursor); |
| 560 | + self.render_at_depth(base, x, y, depth, base_cursor, cursor_offset); |
| 561 | + |
| 562 | + let sub_lower = |
| 563 | + (base_layout.descent * 0.6 + sub_layout.ascent * 0.9).max(sub_layout.ascent * 0.75); |
| 564 | + let sub_kern = (font_size * 0.05).max(0.6); |
| 565 | + self.render_at_depth( |
| 566 | + sub, |
| 567 | + x + base_layout.width + sub_kern, |
| 568 | + y + sub_lower, |
| 569 | + depth + 1, |
| 570 | + sub_cursor, |
| 571 | + cursor_offset, |
| 572 | + ); |
| 305 | 573 | } |
| 306 | 574 | |
| 307 | 575 | /// Render a root (square or nth) |
@@ -313,6 +581,7 @@ impl<'a> MathRenderer<'a> { |
| 313 | 581 | y: f64, |
| 314 | 582 | depth: u32, |
| 315 | 583 | cursor_path: Option<&[usize]>, |
| 584 | + cursor_offset: usize, |
| 316 | 585 | ) { |
| 317 | 586 | let radicand_layout = self.layout_engine.layout(radicand, self.ctx); |
| 318 | 587 | let scale = self.scale_for_depth(depth); |
@@ -329,7 +598,11 @@ impl<'a> MathRenderer<'a> { |
| 329 | 598 | if let Some(idx) = index { |
| 330 | 599 | let idx_layout = self.layout_engine.layout(idx, self.ctx); |
| 331 | 600 | let idx_cursor = cursor_path.and_then(|p| { |
| 332 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 601 | + if !p.is_empty() && p[0] == 0 { |
| 602 | + Some(&p[1..]) |
| 603 | + } else { |
| 604 | + None |
| 605 | + } |
| 333 | 606 | }); |
| 334 | 607 | self.render_at_depth( |
| 335 | 608 | idx, |
@@ -337,6 +610,7 @@ impl<'a> MathRenderer<'a> { |
| 337 | 610 | y - radicand_layout.ascent * 0.5 - idx_layout.descent, |
| 338 | 611 | depth + 2, |
| 339 | 612 | idx_cursor, |
| 613 | + cursor_offset, |
| 340 | 614 | ); |
| 341 | 615 | radicand_x += idx_layout.width; |
| 342 | 616 | } |
@@ -349,21 +623,35 @@ impl<'a> MathRenderer<'a> { |
| 349 | 623 | // Radical checkmark |
| 350 | 624 | let check_width = radical_width * 0.4; |
| 351 | 625 | self.ctx.move_to(radicand_x - radical_width, y); |
| 352 | | - self.ctx.line_to(radicand_x - radical_width + check_width * 0.3, y + radicand_layout.descent * 0.3); |
| 353 | | - self.ctx.line_to(radicand_x - radical_width + check_width, y + radicand_layout.descent); |
| 354 | | - self.ctx.line_to(radicand_x, y - radicand_layout.ascent - gap); |
| 626 | + self.ctx.line_to( |
| 627 | + radicand_x - radical_width + check_width * 0.3, |
| 628 | + y + radicand_layout.descent * 0.3, |
| 629 | + ); |
| 630 | + self.ctx.line_to( |
| 631 | + radicand_x - radical_width + check_width, |
| 632 | + y + radicand_layout.descent, |
| 633 | + ); |
| 634 | + self.ctx |
| 635 | + .line_to(radicand_x, y - radicand_layout.ascent - gap); |
| 355 | 636 | |
| 356 | 637 | // Overbar |
| 357 | | - self.ctx.line_to(radicand_x + radicand_layout.width + bar_overhang, y - radicand_layout.ascent - gap); |
| 638 | + self.ctx.line_to( |
| 639 | + radicand_x + radicand_layout.width + bar_overhang, |
| 640 | + y - radicand_layout.ascent - gap, |
| 641 | + ); |
| 358 | 642 | self.ctx.stroke().unwrap(); |
| 359 | 643 | self.ctx.restore().unwrap(); |
| 360 | 644 | |
| 361 | 645 | // Draw radicand |
| 362 | 646 | let rad_cursor = cursor_path.and_then(|p| { |
| 363 | 647 | let idx = if index.is_some() { 1 } else { 0 }; |
| 364 | | - if !p.is_empty() && p[0] == idx { Some(&p[1..]) } else { None } |
| 648 | + if !p.is_empty() && p[0] == idx { |
| 649 | + Some(&p[1..]) |
| 650 | + } else { |
| 651 | + None |
| 652 | + } |
| 365 | 653 | }); |
| 366 | | - self.render_at_depth(radicand, radicand_x, y, depth, rad_cursor); |
| 654 | + self.render_at_depth(radicand, radicand_x, y, depth, rad_cursor, cursor_offset); |
| 367 | 655 | } |
| 368 | 656 | |
| 369 | 657 | /// Render a function call |
@@ -375,7 +663,13 @@ impl<'a> MathRenderer<'a> { |
| 375 | 663 | y: f64, |
| 376 | 664 | depth: u32, |
| 377 | 665 | cursor_path: Option<&[usize]>, |
| 666 | + cursor_offset: usize, |
| 378 | 667 | ) { |
| 668 | + if name == "factorial" && args.len() == 1 { |
| 669 | + self.render_factorial(&args[0], x, y, depth, cursor_path, cursor_offset); |
| 670 | + return; |
| 671 | + } |
| 672 | + |
| 379 | 673 | let scale = self.scale_for_depth(depth); |
| 380 | 674 | let font_size = self.layout_engine.base_font_size * scale; |
| 381 | 675 | let paren_width = font_size * 0.3; |
@@ -399,9 +693,13 @@ impl<'a> MathRenderer<'a> { |
| 399 | 693 | } |
| 400 | 694 | |
| 401 | 695 | let arg_cursor = cursor_path.and_then(|p| { |
| 402 | | - if !p.is_empty() && p[0] == i { Some(&p[1..]) } else { None } |
| 696 | + if !p.is_empty() && p[0] == i { |
| 697 | + Some(&p[1..]) |
| 698 | + } else { |
| 699 | + None |
| 700 | + } |
| 403 | 701 | }); |
| 404 | | - self.render_at_depth(arg, current_x, y, depth, arg_cursor); |
| 702 | + self.render_at_depth(arg, current_x, y, depth, arg_cursor, cursor_offset); |
| 405 | 703 | |
| 406 | 704 | let arg_layout = self.layout_engine.layout(arg, self.ctx); |
| 407 | 705 | current_x += arg_layout.width; |
@@ -411,6 +709,32 @@ impl<'a> MathRenderer<'a> { |
| 411 | 709 | self.draw_text(")", current_x, y, font_size, false); |
| 412 | 710 | } |
| 413 | 711 | |
| 712 | + fn render_factorial( |
| 713 | + &self, |
| 714 | + arg: &MathBox, |
| 715 | + x: f64, |
| 716 | + y: f64, |
| 717 | + depth: u32, |
| 718 | + cursor_path: Option<&[usize]>, |
| 719 | + cursor_offset: usize, |
| 720 | + ) { |
| 721 | + let scale = self.scale_for_depth(depth); |
| 722 | + let font_size = self.layout_engine.base_font_size * scale; |
| 723 | + let gap = (font_size * 0.06).max(0.6); |
| 724 | + |
| 725 | + let arg_cursor = cursor_path.and_then(|p| { |
| 726 | + if !p.is_empty() && p[0] == 0 { |
| 727 | + Some(&p[1..]) |
| 728 | + } else { |
| 729 | + None |
| 730 | + } |
| 731 | + }); |
| 732 | + self.render_at_depth(arg, x, y, depth, arg_cursor, cursor_offset); |
| 733 | + |
| 734 | + let arg_layout = self.layout_engine.layout_with_depth(arg, self.ctx, depth); |
| 735 | + self.draw_text("!", x + arg_layout.width + gap, y, font_size, false); |
| 736 | + } |
| 737 | + |
| 414 | 738 | /// Render absolute value |
| 415 | 739 | fn render_abs( |
| 416 | 740 | &self, |
@@ -419,6 +743,7 @@ impl<'a> MathRenderer<'a> { |
| 419 | 743 | y: f64, |
| 420 | 744 | depth: u32, |
| 421 | 745 | cursor_path: Option<&[usize]>, |
| 746 | + cursor_offset: usize, |
| 422 | 747 | ) { |
| 423 | 748 | let inner_layout = self.layout_engine.layout(inner, self.ctx); |
| 424 | 749 | let scale = self.scale_for_depth(depth); |
@@ -432,8 +757,10 @@ impl<'a> MathRenderer<'a> { |
| 432 | 757 | self.ctx.set_line_width(1.5 * scale); |
| 433 | 758 | |
| 434 | 759 | // Left bar |
| 435 | | - self.ctx.move_to(x + bar_width / 2.0, y - inner_layout.ascent); |
| 436 | | - self.ctx.line_to(x + bar_width / 2.0, y + inner_layout.descent); |
| 760 | + self.ctx |
| 761 | + .move_to(x + bar_width / 2.0, y - inner_layout.ascent); |
| 762 | + self.ctx |
| 763 | + .line_to(x + bar_width / 2.0, y + inner_layout.descent); |
| 437 | 764 | self.ctx.stroke().unwrap(); |
| 438 | 765 | |
| 439 | 766 | // Right bar |
@@ -446,9 +773,13 @@ impl<'a> MathRenderer<'a> { |
| 446 | 773 | |
| 447 | 774 | // Draw inner expression |
| 448 | 775 | let inner_cursor = cursor_path.and_then(|p| { |
| 449 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 776 | + if !p.is_empty() && p[0] == 0 { |
| 777 | + Some(&p[1..]) |
| 778 | + } else { |
| 779 | + None |
| 780 | + } |
| 450 | 781 | }); |
| 451 | | - self.render_at_depth(inner, x + bar_width, y, depth, inner_cursor); |
| 782 | + self.render_at_depth(inner, x + bar_width, y, depth, inner_cursor, cursor_offset); |
| 452 | 783 | } |
| 453 | 784 | |
| 454 | 785 | /// Render parenthesized expression |
@@ -459,6 +790,7 @@ impl<'a> MathRenderer<'a> { |
| 459 | 790 | y: f64, |
| 460 | 791 | depth: u32, |
| 461 | 792 | cursor_path: Option<&[usize]>, |
| 793 | + cursor_offset: usize, |
| 462 | 794 | ) { |
| 463 | 795 | let inner_layout = self.layout_engine.layout(inner, self.ctx); |
| 464 | 796 | let scale = self.scale_for_depth(depth); |
@@ -467,12 +799,29 @@ impl<'a> MathRenderer<'a> { |
| 467 | 799 | |
| 468 | 800 | // For now, draw text parentheses (could be replaced with curved paths) |
| 469 | 801 | self.draw_text("(", x, y, font_size * 1.2, false); |
| 470 | | - self.draw_text(")", x + paren_width + inner_layout.width, y, font_size * 1.2, false); |
| 802 | + self.draw_text( |
| 803 | + ")", |
| 804 | + x + paren_width + inner_layout.width, |
| 805 | + y, |
| 806 | + font_size * 1.2, |
| 807 | + false, |
| 808 | + ); |
| 471 | 809 | |
| 472 | 810 | let inner_cursor = cursor_path.and_then(|p| { |
| 473 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 811 | + if !p.is_empty() && p[0] == 0 { |
| 812 | + Some(&p[1..]) |
| 813 | + } else { |
| 814 | + None |
| 815 | + } |
| 474 | 816 | }); |
| 475 | | - self.render_at_depth(inner, x + paren_width, y, depth, inner_cursor); |
| 817 | + self.render_at_depth( |
| 818 | + inner, |
| 819 | + x + paren_width, |
| 820 | + y, |
| 821 | + depth, |
| 822 | + inner_cursor, |
| 823 | + cursor_offset, |
| 824 | + ); |
| 476 | 825 | } |
| 477 | 826 | |
| 478 | 827 | /// Render an integral |
@@ -486,76 +835,103 @@ impl<'a> MathRenderer<'a> { |
| 486 | 835 | y: f64, |
| 487 | 836 | depth: u32, |
| 488 | 837 | cursor_path: Option<&[usize]>, |
| 838 | + cursor_offset: usize, |
| 489 | 839 | ) { |
| 490 | | - let body_layout = self.layout_engine.layout(body, self.ctx); |
| 491 | 840 | let scale = self.scale_for_depth(depth); |
| 492 | 841 | let font_size = self.layout_engine.base_font_size * scale; |
| 842 | + let body_layout = self.layout_engine.layout_with_depth(body, self.ctx, depth); |
| 843 | + |
| 844 | + let symbol_size = body_layout.height().max(font_size * 1.8); |
| 845 | + self.ctx.set_font_size(symbol_size); |
| 846 | + let int_extents = self.ctx.text_extents("∫").unwrap(); |
| 847 | + let int_font_extents = self.ctx.font_extents().unwrap(); |
| 848 | + let symbol_width = int_extents.x_advance(); |
| 849 | + let symbol_ascent = int_font_extents.ascent(); |
| 850 | + let symbol_descent = int_font_extents.descent(); |
| 851 | + |
| 852 | + let lo_layout = lower.map(|lo| { |
| 853 | + self.layout_engine |
| 854 | + .layout_with_depth(lo, self.ctx, depth + 1) |
| 855 | + }); |
| 856 | + let hi_layout = upper.map(|hi| { |
| 857 | + self.layout_engine |
| 858 | + .layout_with_depth(hi, self.ctx, depth + 1) |
| 859 | + }); |
| 493 | 860 | |
| 494 | | - let int_height = body_layout.height().max(font_size * 1.5); |
| 495 | | - let int_width = font_size * 0.5; |
| 861 | + let bound_gap = font_size * 0.14; |
| 862 | + let bounds_width = symbol_width |
| 863 | + .max(lo_layout.as_ref().map(|l| l.width).unwrap_or(0.0)) |
| 864 | + .max(hi_layout.as_ref().map(|l| l.width).unwrap_or(0.0)); |
| 865 | + let body_gap = font_size * 0.22; |
| 866 | + let dx_gap = font_size * 0.14; |
| 496 | 867 | |
| 497 | | - // Draw integral symbol using a large font size |
| 868 | + // Draw integral symbol centered in bounds column |
| 498 | 869 | self.ctx.save().unwrap(); |
| 499 | 870 | self.set_color(&self.fg_color); |
| 500 | | - self.ctx.set_font_size(int_height); |
| 501 | | - self.ctx.move_to(x, y + int_height * 0.3); |
| 871 | + self.ctx.set_font_size(symbol_size); |
| 872 | + let symbol_x = x + (bounds_width - symbol_width) / 2.0; |
| 873 | + let symbol_baseline = y + (symbol_ascent - symbol_descent) * 0.5; |
| 874 | + self.ctx.move_to(symbol_x, symbol_baseline); |
| 502 | 875 | self.ctx.show_text("∫").unwrap(); |
| 503 | 876 | self.ctx.restore().unwrap(); |
| 504 | 877 | |
| 505 | | - let mut bounds_width = int_width; |
| 506 | 878 | let mut child_idx = 0; |
| 507 | 879 | |
| 508 | 880 | // Draw lower bound |
| 509 | | - if let Some(lo) = lower { |
| 510 | | - let lo_layout = self.layout_engine.layout(lo, self.ctx); |
| 881 | + if let (Some(lo), Some(lo_layout)) = (lower, lo_layout.as_ref()) { |
| 511 | 882 | let lo_cursor = cursor_path.and_then(|p| { |
| 512 | | - if !p.is_empty() && p[0] == child_idx { Some(&p[1..]) } else { None } |
| 883 | + if !p.is_empty() && p[0] == child_idx { |
| 884 | + Some(&p[1..]) |
| 885 | + } else { |
| 886 | + None |
| 887 | + } |
| 513 | 888 | }); |
| 514 | | - self.render_at_depth( |
| 515 | | - lo, |
| 516 | | - x, |
| 517 | | - y + int_height / 2.0 + lo_layout.ascent, |
| 518 | | - depth + 1, |
| 519 | | - lo_cursor, |
| 520 | | - ); |
| 521 | | - bounds_width = bounds_width.max(lo_layout.width); |
| 889 | + let lo_x = x + (bounds_width - lo_layout.width) / 2.0; |
| 890 | + let lo_baseline = y + symbol_descent + bound_gap + lo_layout.ascent; |
| 891 | + self.render_at_depth(lo, lo_x, lo_baseline, depth + 1, lo_cursor, cursor_offset); |
| 522 | 892 | child_idx += 1; |
| 523 | 893 | } |
| 524 | 894 | |
| 525 | 895 | // Draw upper bound |
| 526 | | - if let Some(hi) = upper { |
| 527 | | - let hi_layout = self.layout_engine.layout(hi, self.ctx); |
| 896 | + if let (Some(hi), Some(hi_layout)) = (upper, hi_layout.as_ref()) { |
| 528 | 897 | let hi_cursor = cursor_path.and_then(|p| { |
| 529 | | - if !p.is_empty() && p[0] == child_idx { Some(&p[1..]) } else { None } |
| 898 | + if !p.is_empty() && p[0] == child_idx { |
| 899 | + Some(&p[1..]) |
| 900 | + } else { |
| 901 | + None |
| 902 | + } |
| 530 | 903 | }); |
| 531 | | - self.render_at_depth( |
| 532 | | - hi, |
| 533 | | - x, |
| 534 | | - y - int_height / 2.0 - hi_layout.descent, |
| 535 | | - depth + 1, |
| 536 | | - hi_cursor, |
| 537 | | - ); |
| 538 | | - bounds_width = bounds_width.max(hi_layout.width); |
| 904 | + let hi_x = x + (bounds_width - hi_layout.width) / 2.0; |
| 905 | + let hi_baseline = y - symbol_ascent - bound_gap - hi_layout.descent; |
| 906 | + self.render_at_depth(hi, hi_x, hi_baseline, depth + 1, hi_cursor, cursor_offset); |
| 539 | 907 | child_idx += 1; |
| 540 | 908 | } |
| 541 | 909 | |
| 542 | 910 | // Draw body |
| 543 | | - let body_x = x + bounds_width + font_size * 0.2; |
| 911 | + let body_x = x + bounds_width + body_gap; |
| 544 | 912 | let body_cursor = cursor_path.and_then(|p| { |
| 545 | | - if !p.is_empty() && p[0] == child_idx { Some(&p[1..]) } else { None } |
| 913 | + if !p.is_empty() && p[0] == child_idx { |
| 914 | + Some(&p[1..]) |
| 915 | + } else { |
| 916 | + None |
| 917 | + } |
| 546 | 918 | }); |
| 547 | | - self.render_at_depth(body, body_x, y, depth, body_cursor); |
| 919 | + self.render_at_depth(body, body_x, y, depth, body_cursor, cursor_offset); |
| 548 | 920 | child_idx += 1; |
| 549 | 921 | |
| 550 | 922 | // Draw "dx" |
| 551 | | - let var_x = body_x + body_layout.width + font_size * 0.1; |
| 552 | | - self.draw_text("d", var_x, y, font_size, false); |
| 923 | + let d_width = self.text_advance("d", font_size, false); |
| 924 | + let d_x = body_x + body_layout.width + dx_gap; |
| 925 | + self.draw_text("d", d_x, y, font_size, false); |
| 553 | 926 | |
| 554 | | - let d_extents = self.ctx.text_extents("d").unwrap(); |
| 555 | 927 | let var_cursor = cursor_path.and_then(|p| { |
| 556 | | - if !p.is_empty() && p[0] == child_idx { Some(&p[1..]) } else { None } |
| 928 | + if !p.is_empty() && p[0] == child_idx { |
| 929 | + Some(&p[1..]) |
| 930 | + } else { |
| 931 | + None |
| 932 | + } |
| 557 | 933 | }); |
| 558 | | - self.render_at_depth(var, var_x + d_extents.x_advance(), y, depth, var_cursor); |
| 934 | + self.render_at_depth(var, d_x + d_width, y, depth, var_cursor, cursor_offset); |
| 559 | 935 | } |
| 560 | 936 | |
| 561 | 937 | /// Render a derivative |
@@ -568,9 +944,11 @@ impl<'a> MathRenderer<'a> { |
| 568 | 944 | y: f64, |
| 569 | 945 | depth: u32, |
| 570 | 946 | cursor_path: Option<&[usize]>, |
| 947 | + cursor_offset: usize, |
| 571 | 948 | ) { |
| 572 | 949 | let scale = self.scale_for_depth(depth); |
| 573 | 950 | let font_size = self.layout_engine.base_font_size * scale; |
| 951 | + let frac_font_size = font_size * 0.8; |
| 574 | 952 | |
| 575 | 953 | // Build strings for numerator and denominator |
| 576 | 954 | let num_str = if order > 1 { |
@@ -585,19 +963,31 @@ impl<'a> MathRenderer<'a> { |
| 585 | 963 | "d".to_string() |
| 586 | 964 | }; |
| 587 | 965 | |
| 588 | | - // Measure text |
| 589 | | - self.ctx.set_font_size(font_size * 0.8); |
| 590 | | - let num_extents = self.ctx.text_extents(&num_str).unwrap(); |
| 591 | | - let den_prefix_extents = self.ctx.text_extents(&den_prefix).unwrap(); |
| 592 | | - |
| 593 | | - let var_layout = self.layout_engine.layout(var, self.ctx); |
| 594 | | - let _body_layout = self.layout_engine.layout(body, self.ctx); |
| 595 | | - |
| 596 | | - let frac_width = num_extents.x_advance().max(den_prefix_extents.x_advance() + var_layout.width); |
| 597 | | - let bar_gap = font_size * 0.15; |
| 966 | + // Measure text and guard spacing |
| 967 | + let num_width = self.text_advance(&num_str, frac_font_size, false); |
| 968 | + let den_prefix_width = self.text_advance(&den_prefix, frac_font_size, false); |
| 969 | + self.ctx.set_font_size(frac_font_size); |
| 970 | + let frac_font_extents = self.ctx.font_extents().unwrap(); |
| 971 | + let frac_ascent = frac_font_extents.ascent(); |
| 972 | + let frac_descent = frac_font_extents.descent(); |
| 973 | + |
| 974 | + let var_layout = self |
| 975 | + .layout_engine |
| 976 | + .layout_with_depth(var, self.ctx, depth + 1); |
| 977 | + let den_sep = (frac_font_size * 0.08).max(0.6); |
| 978 | + let denom_width = den_prefix_width + den_sep + var_layout.width; |
| 979 | + let frac_width = num_width.max(denom_width) + font_size * 0.18; |
| 980 | + let bar_gap = font_size * 0.14; |
| 981 | + let body_gap = font_size * 0.3; |
| 598 | 982 | |
| 599 | 983 | // Draw numerator |
| 600 | | - self.draw_text(&num_str, x + (frac_width - num_extents.x_advance()) / 2.0, y - bar_gap - font_size * 0.3, font_size * 0.8, false); |
| 984 | + self.draw_text( |
| 985 | + &num_str, |
| 986 | + x + (frac_width - num_width) / 2.0, |
| 987 | + y - bar_gap - frac_descent, |
| 988 | + frac_font_size, |
| 989 | + false, |
| 990 | + ); |
| 601 | 991 | |
| 602 | 992 | // Draw fraction bar |
| 603 | 993 | self.ctx.save().unwrap(); |
@@ -609,19 +999,42 @@ impl<'a> MathRenderer<'a> { |
| 609 | 999 | self.ctx.restore().unwrap(); |
| 610 | 1000 | |
| 611 | 1001 | // Draw denominator |
| 612 | | - let den_x = x + (frac_width - den_prefix_extents.x_advance() - var_layout.width) / 2.0; |
| 613 | | - self.draw_text(&den_prefix, den_x, y + bar_gap + font_size * 0.6, font_size * 0.8, false); |
| 1002 | + let den_x = x + (frac_width - denom_width) / 2.0; |
| 1003 | + let den_baseline = y + bar_gap + frac_ascent.max(var_layout.ascent); |
| 1004 | + self.draw_text(&den_prefix, den_x, den_baseline, frac_font_size, false); |
| 614 | 1005 | |
| 615 | 1006 | let var_cursor = cursor_path.and_then(|p| { |
| 616 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 1007 | + if !p.is_empty() && p[0] == 0 { |
| 1008 | + Some(&p[1..]) |
| 1009 | + } else { |
| 1010 | + None |
| 1011 | + } |
| 617 | 1012 | }); |
| 618 | | - self.render_at_depth(var, den_x + den_prefix_extents.x_advance(), y + bar_gap + font_size * 0.6, depth + 1, var_cursor); |
| 1013 | + self.render_at_depth( |
| 1014 | + var, |
| 1015 | + den_x + den_prefix_width + den_sep, |
| 1016 | + den_baseline, |
| 1017 | + depth + 1, |
| 1018 | + var_cursor, |
| 1019 | + cursor_offset, |
| 1020 | + ); |
| 619 | 1021 | |
| 620 | 1022 | // Draw body |
| 621 | 1023 | let body_cursor = cursor_path.and_then(|p| { |
| 622 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 1024 | + if !p.is_empty() && p[0] == 1 { |
| 1025 | + Some(&p[1..]) |
| 1026 | + } else { |
| 1027 | + None |
| 1028 | + } |
| 623 | 1029 | }); |
| 624 | | - self.render_at_depth(body, x + frac_width + font_size * 0.3, y, depth, body_cursor); |
| 1030 | + self.render_at_depth( |
| 1031 | + body, |
| 1032 | + x + frac_width + body_gap, |
| 1033 | + y, |
| 1034 | + depth, |
| 1035 | + body_cursor, |
| 1036 | + cursor_offset, |
| 1037 | + ); |
| 625 | 1038 | } |
| 626 | 1039 | |
| 627 | 1040 | /// Render a limit |
@@ -635,37 +1048,71 @@ impl<'a> MathRenderer<'a> { |
| 635 | 1048 | y: f64, |
| 636 | 1049 | depth: u32, |
| 637 | 1050 | cursor_path: Option<&[usize]>, |
| 1051 | + cursor_offset: usize, |
| 638 | 1052 | ) { |
| 639 | 1053 | let scale = self.scale_for_depth(depth); |
| 640 | 1054 | let font_size = self.layout_engine.base_font_size * scale; |
| 1055 | + let sub_font_size = font_size * 0.7; |
| 1056 | + let body_gap = font_size * 0.3; |
| 1057 | + |
| 1058 | + let lim_width = self.text_advance("lim", font_size, false); |
| 1059 | + let var_layout = self |
| 1060 | + .layout_engine |
| 1061 | + .layout_with_depth(var, self.ctx, depth + 1); |
| 1062 | + let to_layout = self |
| 1063 | + .layout_engine |
| 1064 | + .layout_with_depth(to, self.ctx, depth + 1); |
| 1065 | + let arrow_width = self.text_advance("→", sub_font_size, false); |
| 1066 | + let dir_width = if direction.is_some() { |
| 1067 | + self.text_advance("⁺", sub_font_size * 0.6, false) |
| 1068 | + } else { |
| 1069 | + 0.0 |
| 1070 | + }; |
| 1071 | + let sub_sep = (sub_font_size * 0.08).max(0.5); |
| 1072 | + let subscript_width = |
| 1073 | + var_layout.width + sub_sep + arrow_width + sub_sep + to_layout.width + dir_width; |
| 1074 | + let lim_col_width = lim_width.max(subscript_width); |
| 1075 | + let lim_x = x + (lim_col_width - lim_width) / 2.0; |
| 1076 | + let sub_start_x = x + (lim_col_width - subscript_width) / 2.0; |
| 1077 | + let subscript_y = y + font_size * 0.2 + var_layout.ascent.max(to_layout.ascent); |
| 641 | 1078 | |
| 642 | 1079 | // Draw "lim" |
| 643 | | - self.draw_text("lim", x, y, font_size, false); |
| 644 | | - self.ctx.set_font_size(font_size); |
| 645 | | - let lim_extents = self.ctx.text_extents("lim").unwrap(); |
| 646 | | - |
| 647 | | - // Draw subscript: var → to |
| 648 | | - let subscript_y = y + font_size * 0.5; |
| 649 | | - let sub_font_size = font_size * 0.7; |
| 1080 | + self.draw_text("lim", lim_x, y, font_size, false); |
| 650 | 1081 | |
| 651 | 1082 | let var_cursor = cursor_path.and_then(|p| { |
| 652 | | - if !p.is_empty() && p[0] == 0 { Some(&p[1..]) } else { None } |
| 1083 | + if !p.is_empty() && p[0] == 0 { |
| 1084 | + Some(&p[1..]) |
| 1085 | + } else { |
| 1086 | + None |
| 1087 | + } |
| 653 | 1088 | }); |
| 654 | | - self.render_at_depth(var, x, subscript_y, depth + 1, var_cursor); |
| 1089 | + self.render_at_depth( |
| 1090 | + var, |
| 1091 | + sub_start_x, |
| 1092 | + subscript_y, |
| 1093 | + depth + 1, |
| 1094 | + var_cursor, |
| 1095 | + cursor_offset, |
| 1096 | + ); |
| 655 | 1097 | |
| 656 | | - let var_layout = self.layout_engine.layout(var, self.ctx); |
| 657 | | - let arrow_x = x + var_layout.width; |
| 1098 | + let arrow_x = sub_start_x + var_layout.width + sub_sep; |
| 658 | 1099 | self.draw_text("→", arrow_x, subscript_y, sub_font_size, false); |
| 659 | 1100 | |
| 660 | | - self.ctx.set_font_size(sub_font_size); |
| 661 | | - let arrow_extents = self.ctx.text_extents("→").unwrap(); |
| 662 | | - |
| 663 | 1101 | let to_cursor = cursor_path.and_then(|p| { |
| 664 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 1102 | + if !p.is_empty() && p[0] == 1 { |
| 1103 | + Some(&p[1..]) |
| 1104 | + } else { |
| 1105 | + None |
| 1106 | + } |
| 665 | 1107 | }); |
| 666 | | - self.render_at_depth(to, arrow_x + arrow_extents.x_advance(), subscript_y, depth + 1, to_cursor); |
| 667 | | - |
| 668 | | - let to_layout = self.layout_engine.layout(to, self.ctx); |
| 1108 | + self.render_at_depth( |
| 1109 | + to, |
| 1110 | + arrow_x + arrow_width + sub_sep, |
| 1111 | + subscript_y, |
| 1112 | + depth + 1, |
| 1113 | + to_cursor, |
| 1114 | + cursor_offset, |
| 1115 | + ); |
| 669 | 1116 | |
| 670 | 1117 | // Draw direction indicator if present |
| 671 | 1118 | if let Some(dir) = direction { |
@@ -673,22 +1120,32 @@ impl<'a> MathRenderer<'a> { |
| 673 | 1120 | LimitDirection::FromRight => "⁺", |
| 674 | 1121 | LimitDirection::FromLeft => "⁻", |
| 675 | 1122 | }; |
| 676 | | - self.draw_text(dir_str, arrow_x + arrow_extents.x_advance() + to_layout.width, subscript_y - sub_font_size * 0.3, sub_font_size * 0.6, false); |
| 1123 | + self.draw_text( |
| 1124 | + dir_str, |
| 1125 | + arrow_x + arrow_width + sub_sep + to_layout.width, |
| 1126 | + subscript_y - sub_font_size * 0.3, |
| 1127 | + sub_font_size * 0.6, |
| 1128 | + false, |
| 1129 | + ); |
| 677 | 1130 | } |
| 678 | 1131 | |
| 679 | 1132 | // Draw body |
| 680 | | - let body_x = x + lim_extents.x_advance() + font_size * 0.3; |
| 1133 | + let body_x = x + lim_col_width + body_gap; |
| 681 | 1134 | let body_cursor = cursor_path.and_then(|p| { |
| 682 | | - if !p.is_empty() && p[0] == 2 { Some(&p[1..]) } else { None } |
| 1135 | + if !p.is_empty() && p[0] == 2 { |
| 1136 | + Some(&p[1..]) |
| 1137 | + } else { |
| 1138 | + None |
| 1139 | + } |
| 683 | 1140 | }); |
| 684 | | - self.render_at_depth(body, body_x, y, depth, body_cursor); |
| 1141 | + self.render_at_depth(body, body_x, y, depth, body_cursor, cursor_offset); |
| 685 | 1142 | } |
| 686 | 1143 | |
| 687 | 1144 | /// Render a big operator (sum, product) |
| 688 | 1145 | fn render_bigop( |
| 689 | 1146 | &self, |
| 690 | 1147 | symbol: &str, |
| 691 | | - _var: &MathBox, |
| 1148 | + var: &MathBox, |
| 692 | 1149 | lower: &MathBox, |
| 693 | 1150 | upper: &MathBox, |
| 694 | 1151 | body: &MathBox, |
@@ -696,45 +1153,117 @@ impl<'a> MathRenderer<'a> { |
| 696 | 1153 | y: f64, |
| 697 | 1154 | depth: u32, |
| 698 | 1155 | cursor_path: Option<&[usize]>, |
| 1156 | + cursor_offset: usize, |
| 699 | 1157 | ) { |
| 700 | 1158 | let scale = self.scale_for_depth(depth); |
| 701 | 1159 | let font_size = self.layout_engine.base_font_size * scale; |
| 702 | 1160 | let symbol_size = font_size * 1.5; |
| 703 | | - |
| 704 | | - // Draw the big symbol |
| 1161 | + let bound_scale = self.scale_for_depth(depth + 1); |
| 1162 | + let bound_font_size = self.layout_engine.base_font_size * bound_scale; |
| 1163 | + |
| 1164 | + let var_layout = self |
| 1165 | + .layout_engine |
| 1166 | + .layout_with_depth(var, self.ctx, depth + 1); |
| 1167 | + let lower_layout = self |
| 1168 | + .layout_engine |
| 1169 | + .layout_with_depth(lower, self.ctx, depth + 1); |
| 1170 | + let upper_layout = self |
| 1171 | + .layout_engine |
| 1172 | + .layout_with_depth(upper, self.ctx, depth + 1); |
| 1173 | + let eq_width = self.text_advance("=", bound_font_size, false); |
| 1174 | + let lower_sep = (bound_font_size * 0.08).max(0.6); |
| 1175 | + let lower_block_width = |
| 1176 | + var_layout.width + lower_sep + eq_width + lower_sep + lower_layout.width; |
| 1177 | + |
| 1178 | + // Draw the big symbol centered in the operator column. |
| 705 | 1179 | self.ctx.save().unwrap(); |
| 706 | 1180 | self.set_color(&self.fg_color); |
| 707 | 1181 | self.ctx.set_font_size(symbol_size); |
| 708 | | - self.ctx.move_to(x, y + symbol_size * 0.3); |
| 709 | | - self.ctx.show_text(symbol).unwrap(); |
| 710 | 1182 | let symbol_extents = self.ctx.text_extents(symbol).unwrap(); |
| 1183 | + let symbol_width = symbol_extents.x_advance(); |
| 1184 | + let symbol_font_extents = self.ctx.font_extents().unwrap(); |
| 1185 | + let symbol_ascent = symbol_font_extents.ascent(); |
| 1186 | + let symbol_descent = symbol_font_extents.descent(); |
| 1187 | + let op_width = symbol_width.max(lower_block_width).max(upper_layout.width); |
| 1188 | + let symbol_x = x + (op_width - symbol_width) / 2.0; |
| 1189 | + let symbol_baseline = y + (symbol_ascent - symbol_descent) * 0.5; |
| 1190 | + self.ctx.move_to(symbol_x, symbol_baseline); |
| 1191 | + self.ctx.show_text(symbol).unwrap(); |
| 711 | 1192 | self.ctx.restore().unwrap(); |
| 712 | 1193 | |
| 713 | | - let op_width = symbol_extents.x_advance(); |
| 714 | | - let gap = font_size * 0.15; |
| 1194 | + let bounds_gap = font_size * 0.16; |
| 1195 | + let body_gap = font_size * 0.32; |
| 1196 | + let symbol_top = symbol_baseline - symbol_ascent; |
| 1197 | + let symbol_bottom = symbol_baseline + symbol_descent; |
| 1198 | + let upper_baseline = symbol_top - bounds_gap - upper_layout.descent; |
| 1199 | + let lower_baseline = |
| 1200 | + symbol_bottom + bounds_gap + lower_layout.ascent.max(var_layout.ascent); |
| 1201 | + let upper_x = x + (op_width - upper_layout.width) / 2.0; |
| 1202 | + let lower_start_x = x + (op_width - lower_block_width) / 2.0; |
| 715 | 1203 | |
| 716 | 1204 | // Draw upper bound |
| 717 | | - let upper_layout = self.layout_engine.layout(upper, self.ctx); |
| 718 | | - let upper_x = x + (op_width - upper_layout.width) / 2.0; |
| 719 | 1205 | let upper_cursor = cursor_path.and_then(|p| { |
| 720 | | - if !p.is_empty() && p[0] == 2 { Some(&p[1..]) } else { None } |
| 1206 | + if !p.is_empty() && p[0] == 2 { |
| 1207 | + Some(&p[1..]) |
| 1208 | + } else { |
| 1209 | + None |
| 1210 | + } |
| 721 | 1211 | }); |
| 722 | | - self.render_at_depth(upper, upper_x, y - symbol_size / 2.0 - gap - upper_layout.descent, depth + 1, upper_cursor); |
| 1212 | + self.render_at_depth( |
| 1213 | + upper, |
| 1214 | + upper_x, |
| 1215 | + upper_baseline, |
| 1216 | + depth + 1, |
| 1217 | + upper_cursor, |
| 1218 | + cursor_offset, |
| 1219 | + ); |
| 1220 | + |
| 1221 | + // Draw lower bound as "var = lower" |
| 1222 | + let var_cursor = cursor_path.and_then(|p| { |
| 1223 | + if !p.is_empty() && p[0] == 0 { |
| 1224 | + Some(&p[1..]) |
| 1225 | + } else { |
| 1226 | + None |
| 1227 | + } |
| 1228 | + }); |
| 1229 | + self.render_at_depth( |
| 1230 | + var, |
| 1231 | + lower_start_x, |
| 1232 | + lower_baseline, |
| 1233 | + depth + 1, |
| 1234 | + var_cursor, |
| 1235 | + cursor_offset, |
| 1236 | + ); |
| 1237 | + |
| 1238 | + let eq_x = lower_start_x + var_layout.width + lower_sep; |
| 1239 | + self.draw_text("=", eq_x, lower_baseline, bound_font_size, false); |
| 723 | 1240 | |
| 724 | | - // Draw lower bound (includes "var=") |
| 725 | | - let lower_layout = self.layout_engine.layout(lower, self.ctx); |
| 726 | | - let lower_x = x + (op_width - lower_layout.width) / 2.0; |
| 727 | 1241 | let lower_cursor = cursor_path.and_then(|p| { |
| 728 | | - if !p.is_empty() && p[0] == 1 { Some(&p[1..]) } else { None } |
| 1242 | + if !p.is_empty() && p[0] == 1 { |
| 1243 | + Some(&p[1..]) |
| 1244 | + } else { |
| 1245 | + None |
| 1246 | + } |
| 729 | 1247 | }); |
| 730 | | - self.render_at_depth(lower, lower_x, y + symbol_size / 2.0 + gap + lower_layout.ascent, depth + 1, lower_cursor); |
| 1248 | + self.render_at_depth( |
| 1249 | + lower, |
| 1250 | + eq_x + eq_width + lower_sep, |
| 1251 | + lower_baseline, |
| 1252 | + depth + 1, |
| 1253 | + lower_cursor, |
| 1254 | + cursor_offset, |
| 1255 | + ); |
| 731 | 1256 | |
| 732 | 1257 | // Draw body |
| 733 | | - let body_x = x + op_width + font_size * 0.3; |
| 1258 | + let body_x = x + op_width + body_gap; |
| 734 | 1259 | let body_cursor = cursor_path.and_then(|p| { |
| 735 | | - if !p.is_empty() && p[0] == 3 { Some(&p[1..]) } else { None } |
| 1260 | + if !p.is_empty() && p[0] == 3 { |
| 1261 | + Some(&p[1..]) |
| 1262 | + } else { |
| 1263 | + None |
| 1264 | + } |
| 736 | 1265 | }); |
| 737 | | - self.render_at_depth(body, body_x, y, depth, body_cursor); |
| 1266 | + self.render_at_depth(body, body_x, y, depth, body_cursor, cursor_offset); |
| 738 | 1267 | } |
| 739 | 1268 | |
| 740 | 1269 | /// Render a matrix |
@@ -745,6 +1274,7 @@ impl<'a> MathRenderer<'a> { |
| 745 | 1274 | y: f64, |
| 746 | 1275 | depth: u32, |
| 747 | 1276 | cursor_path: Option<&[usize]>, |
| 1277 | + cursor_offset: usize, |
| 748 | 1278 | ) { |
| 749 | 1279 | if rows.is_empty() { |
| 750 | 1280 | return; |
@@ -784,8 +1314,8 @@ impl<'a> MathRenderer<'a> { |
| 784 | 1314 | let total_width: f64 = col_widths.iter().sum::<f64>() |
| 785 | 1315 | + cell_padding * (num_cols as f64 - 1.0) |
| 786 | 1316 | + 2.0 * bracket_width; |
| 787 | | - let total_height: f64 = row_heights.iter().sum::<f64>() |
| 788 | | - + cell_padding * (rows.len() as f64 - 1.0); |
| 1317 | + let total_height: f64 = |
| 1318 | + row_heights.iter().sum::<f64>() + cell_padding * (rows.len() as f64 - 1.0); |
| 789 | 1319 | |
| 790 | 1320 | // Draw brackets |
| 791 | 1321 | self.ctx.save().unwrap(); |
@@ -802,10 +1332,12 @@ impl<'a> MathRenderer<'a> { |
| 802 | 1332 | |
| 803 | 1333 | // Right bracket |
| 804 | 1334 | let right_x = x + total_width - bracket_width; |
| 805 | | - self.ctx.move_to(right_x - bracket_gap, y - total_height / 2.0); |
| 1335 | + self.ctx |
| 1336 | + .move_to(right_x - bracket_gap, y - total_height / 2.0); |
| 806 | 1337 | self.ctx.line_to(right_x, y - total_height / 2.0); |
| 807 | 1338 | self.ctx.line_to(right_x, y + total_height / 2.0); |
| 808 | | - self.ctx.line_to(right_x - bracket_gap, y + total_height / 2.0); |
| 1339 | + self.ctx |
| 1340 | + .line_to(right_x - bracket_gap, y + total_height / 2.0); |
| 809 | 1341 | self.ctx.stroke().unwrap(); |
| 810 | 1342 | |
| 811 | 1343 | self.ctx.restore().unwrap(); |
@@ -827,9 +1359,13 @@ impl<'a> MathRenderer<'a> { |
| 827 | 1359 | let cy = cell_y + row_h / 2.0; |
| 828 | 1360 | |
| 829 | 1361 | let cell_cursor = cursor_path.and_then(|p| { |
| 830 | | - if !p.is_empty() && p[0] == cell_idx { Some(&p[1..]) } else { None } |
| 1362 | + if !p.is_empty() && p[0] == cell_idx { |
| 1363 | + Some(&p[1..]) |
| 1364 | + } else { |
| 1365 | + None |
| 1366 | + } |
| 831 | 1367 | }); |
| 832 | | - self.render_at_depth(cell, cx, cy, depth, cell_cursor); |
| 1368 | + self.render_at_depth(cell, cx, cy, depth, cell_cursor, cursor_offset); |
| 833 | 1369 | |
| 834 | 1370 | cell_x += col_w + cell_padding; |
| 835 | 1371 | cell_idx += 1; |
@@ -847,14 +1383,19 @@ impl<'a> MathRenderer<'a> { |
| 847 | 1383 | y: f64, |
| 848 | 1384 | depth: u32, |
| 849 | 1385 | cursor_path: Option<&[usize]>, |
| 1386 | + cursor_offset: usize, |
| 850 | 1387 | ) { |
| 851 | 1388 | let mut current_x = x; |
| 852 | 1389 | |
| 853 | 1390 | for (i, item) in items.iter().enumerate() { |
| 854 | 1391 | let item_cursor = cursor_path.and_then(|p| { |
| 855 | | - if !p.is_empty() && p[0] == i { Some(&p[1..]) } else { None } |
| 1392 | + if !p.is_empty() && p[0] == i { |
| 1393 | + Some(&p[1..]) |
| 1394 | + } else { |
| 1395 | + None |
| 1396 | + } |
| 856 | 1397 | }); |
| 857 | | - self.render_at_depth(item, current_x, y, depth, item_cursor); |
| 1398 | + self.render_at_depth(item, current_x, y, depth, item_cursor, cursor_offset); |
| 858 | 1399 | |
| 859 | 1400 | let layout = self.layout_engine.layout(item, self.ctx); |
| 860 | 1401 | current_x += layout.width; |
@@ -865,6 +1406,13 @@ impl<'a> MathRenderer<'a> { |
| 865 | 1406 | fn set_color(&self, color: &Color) { |
| 866 | 1407 | self.ctx.set_source_rgba(color.r, color.g, color.b, color.a); |
| 867 | 1408 | } |
| 1409 | + |
| 1410 | + fn slot_geometry(font_size: f64) -> (f64, f64, f64) { |
| 1411 | + let width = (font_size * 0.86).max(10.0); |
| 1412 | + let height = (font_size * 0.9).max(11.0); |
| 1413 | + let ascent = height * 0.62; |
| 1414 | + (width, height, ascent) |
| 1415 | + } |
| 868 | 1416 | } |
| 869 | 1417 | |
| 870 | 1418 | /// Convert a number to superscript Unicode digits |