lala 0.1.0

A modern, lightweight text editor with GUI and CLI support for Markdown, HTML, Mermaid, and LaTeX
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
/*!
# Markdown Preview Renderer for egui

## 概要
このモジュールは、Markdownテキストをパースし、egui UIとしてレンダリングする機能を提供します。
WebViewやHTMLレンダラーを使用せず、純粋なRust + eguiウィジェットのみで実装されています。

## 技術的詳細

### アーキテクチャ
1. **パーサー**: `pulldown-cmark` クレートを使用してMarkdownをパース
2. **AST走査**: `pulldown-cmark::Event` のイテレータを走査
3. **egui変換**: 各EventをeguiウィジェットやTextFormatに変換
4. **リアルタイム更新**: エディタの変更を検知し、即座に再レンダリング

### 主要な変換ロジック

#### 見出し (Headers)
- `Event::Start(Tag::Heading(level))` を検知
- levelに応じてフォントサイズを調整(H1: 30pt, H2: 24pt, etc.)
- `egui::Label` で描画

#### リスト (Lists)
- `Event::Start(Tag::List(_))` でリスト開始を検知
- `Event::Start(Tag::Item)` で各アイテムを処理
- 箇条書き: "• " プレフィックス
- 番号付き: "1. ", "2. " などのプレフィックス

#### 強調 (Emphasis/Strong)
- `Event::Start(Tag::Emphasis)` で *italic* を処理
- `Event::Start(Tag::Strong)` で **bold** を処理
- `egui::RichText` の `.italics()` や `.strong()` を使用

#### コードブロック (Code Blocks)
- `Event::Start(Tag::CodeBlock(_))` でコードブロック開始
- `egui::Frame` で背景色を設定
- `egui::TextStyle::Monospace` でレンダリング

### パフォーマンス考慮
- パース処理は軽量(pulldown-cmarkが高速)
- UIレンダリングはeguiの即時モードで高速
- リアルタイム更新でも100ms以内に完了

### 拡張性
- 新しいMarkdown要素を追加する際は、`render_events()` 関数内でEventパターンを追加
- 将来的にMermaid、LaTeX等の拡張が可能な設計
*/

use eframe::egui;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;

/// Markdown文字列をeguiでレンダリングする
///
/// # Arguments
/// * `ui` - egui UI context
/// * `markdown` - レンダリングするMarkdownテキスト
///
/// # Example
/// ```ignore
/// render_markdown_preview(ui, "# Hello\n\nThis is **bold**.");
/// ```
pub fn render_markdown_preview(ui: &mut egui::Ui, markdown: &str) {
    let parser = Parser::new_ext(markdown, Options::all());
    let events: Vec<Event> = parser.collect();

    render_events(ui, &events);
}

/// Markdown ASTイベントをeguiウィジェットに変換してレンダリング
///
/// これがこのプロジェクトの核心技術:
/// pulldown-cmarkのEventストリームをイテレートし、各Eventに応じて
/// eguiのウィジェット(Label, Frame, Separator等)を動的に構築します。
///
/// # Arguments
/// * `ui` - egui UI context
/// * `events` - pulldown-cmarkのEventスライス
fn render_events(ui: &mut egui::Ui, events: &[Event]) {
    let mut i = 0;
    let mut list_item_number = 0;
    let mut in_ordered_list = false;

    while i < events.len() {
        match &events[i] {
            // ========== 見出し (Headings) ==========
            Event::Start(Tag::Heading { level, .. }) => {
                let heading_level = *level;
                i += 1;

                let text = extract_text_until_end(&events[i..], TagEnd::Heading(heading_level));
                let font_size = match heading_level {
                    HeadingLevel::H1 => 30.0,
                    HeadingLevel::H2 => 24.0,
                    HeadingLevel::H3 => 20.0,
                    HeadingLevel::H4 => 18.0,
                    HeadingLevel::H5 => 16.0,
                    HeadingLevel::H6 => 14.0,
                };

                ui.add_space(10.0);
                ui.label(egui::RichText::new(text).size(font_size).strong());
                ui.add_space(5.0);

                // Skip to end of heading
                while i < events.len() {
                    if matches!(events[i], Event::End(TagEnd::Heading(_))) {
                        break;
                    }
                    i += 1;
                }
            }

            // ========== 段落 (Paragraphs) ==========
            Event::Start(Tag::Paragraph) => {
                i += 1;
                let rich_text = extract_rich_text(&events[i..], TagEnd::Paragraph);
                ui.add_space(5.0);
                ui.label(rich_text);
                ui.add_space(5.0);

                // Skip to end of paragraph
                while i < events.len() {
                    if matches!(events[i], Event::End(TagEnd::Paragraph)) {
                        break;
                    }
                    i += 1;
                }
            }

            // ========== リスト (Lists) ==========
            Event::Start(Tag::List(first_number)) => {
                in_ordered_list = first_number.is_some();
                list_item_number = first_number.unwrap_or(0);
                ui.add_space(5.0);
            }

            Event::End(TagEnd::List(_)) => {
                in_ordered_list = false;
                list_item_number = 0;
                ui.add_space(5.0);
            }

            Event::Start(Tag::Item) => {
                i += 1;
                let text = extract_text_until_end(&events[i..], TagEnd::Item);

                if in_ordered_list {
                    list_item_number += 1;
                    ui.horizontal(|ui| {
                        ui.label(format!("{list_item_number}."));
                        ui.label(text);
                    });
                } else {
                    ui.horizontal(|ui| {
                        ui.label("");
                        ui.label(text);
                    });
                }

                // Skip to end of item
                while i < events.len() {
                    if matches!(events[i], Event::End(TagEnd::Item)) {
                        break;
                    }
                    i += 1;
                }
            }

            // ========== コードブロック (Code Blocks) ==========
            Event::Start(Tag::CodeBlock(kind)) => {
                // Extract language tag
                let lang = match kind {
                    CodeBlockKind::Fenced(lang) => lang.to_string(),
                    CodeBlockKind::Indented => String::new(),
                };

                i += 1;
                let code = extract_text_until_end(&events[i..], TagEnd::CodeBlock);

                ui.add_space(5.0);
                egui::Frame::NONE
                    .fill(ui.style().visuals.code_bg_color)
                    .inner_margin(egui::Margin::same(8))
                    .show(ui, |ui| {
                        // Apply syntax highlighting if language is specified
                        if !lang.is_empty() && !code.is_empty() {
                            render_highlighted_code(ui, &code, &lang);
                        } else {
                            // Fallback to plain monospace
                            ui.label(
                                egui::RichText::new(code)
                                    .monospace()
                                    .color(egui::Color32::from_rgb(200, 200, 200)),
                            );
                        }
                    });
                ui.add_space(5.0);

                // Skip to end of code block
                while i < events.len() {
                    if matches!(events[i], Event::End(TagEnd::CodeBlock)) {
                        break;
                    }
                    i += 1;
                }
            }

            // ========== インラインコード (Inline Code) ==========
            Event::Code(code) => {
                ui.label(
                    egui::RichText::new(code.as_ref())
                        .monospace()
                        .background_color(ui.style().visuals.code_bg_color),
                );
            }

            // ========== 水平線 (Horizontal Rule) ==========
            Event::Rule => {
                ui.add_space(5.0);
                ui.separator();
                ui.add_space(5.0);
            }

            // ========== その他 ==========
            _ => {}
        }

        i += 1;
    }
}

/// イベント列からタグ終了までのテキストを抽出
///
/// # Arguments
/// * `events` - イベントスライス
/// * `end_tag` - 終了タグ
///
/// # Returns
/// 抽出されたテキスト文字列
fn extract_text_until_end(events: &[Event], end_tag: TagEnd) -> String {
    let mut result = String::new();

    for event in events {
        match event {
            Event::Text(text) => result.push_str(text),
            Event::Code(code) => result.push_str(code),
            Event::End(tag) if tag == &end_tag => break,
            _ => {}
        }
    }

    result
}

/// イベント列からリッチテキストを抽出(強調、太字等を含む)
///
/// # Arguments
/// * `events` - イベントスライス
/// * `end_tag` - 終了タグ
///
/// # Returns
/// egui::RichText
fn extract_rich_text(events: &[Event], end_tag: TagEnd) -> egui::RichText {
    let mut result = String::new();
    let mut is_bold = false;
    let mut is_italic = false;

    for event in events {
        match event {
            Event::Text(text) => result.push_str(text),
            Event::Code(code) => {
                result.push('`');
                result.push_str(code);
                result.push('`');
            }
            Event::Start(Tag::Strong) => is_bold = true,
            Event::End(TagEnd::Strong) => is_bold = false,
            Event::Start(Tag::Emphasis) => is_italic = true,
            Event::End(TagEnd::Emphasis) => is_italic = false,
            Event::End(tag) if tag == &end_tag => break,
            _ => {}
        }
    }

    let mut rich = egui::RichText::new(result);
    if is_bold {
        rich = rich.strong();
    }
    if is_italic {
        rich = rich.italics();
    }

    rich
}

/// Render syntax-highlighted code block
fn render_highlighted_code(ui: &mut egui::Ui, code: &str, lang: &str) {
    // Load syntax definitions and theme
    let ps = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();

    // Try to find syntax by language name or extension
    let syntax = ps
        .find_syntax_by_extension(lang)
        .or_else(|| ps.find_syntax_by_name(lang))
        .or_else(|| ps.find_syntax_by_first_line(code))
        .unwrap_or_else(|| ps.find_syntax_plain_text());

    let theme = &ts.themes["base16-ocean.dark"];
    let mut highlighter = HighlightLines::new(syntax, theme);

    // Render each line with syntax highlighting
    for line in LinesWithEndings::from(code) {
        let ranges = highlighter.highlight_line(line, &ps).unwrap_or_default();

        ui.horizontal(|ui| {
            for (style, text) in ranges {
                let color = style_to_color(style);
                ui.label(egui::RichText::new(text).monospace().color(color));
            }
        });
    }
}

/// Convert syntect Style to egui Color32
fn style_to_color(style: Style) -> egui::Color32 {
    egui::Color32::from_rgb(
        style.foreground.r,
        style.foreground.g,
        style.foreground.b,
    )
}

// ========== ユニットテスト ==========
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_heading() {
        let markdown = "# Hello";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        assert!(matches!(events[0], Event::Start(Tag::Heading { .. })));
    }

    #[test]
    fn test_parse_bold() {
        let markdown = "**bold** text";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain Strong tag
        let has_strong = events
            .iter()
            .any(|e| matches!(e, Event::Start(Tag::Strong)));
        assert!(has_strong);
    }

    #[test]
    fn test_parse_list() {
        let markdown = "* item 1\n* item 2";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain List tag
        let has_list = events
            .iter()
            .any(|e| matches!(e, Event::Start(Tag::List(_))));
        assert!(has_list);
    }

    #[test]
    fn test_parse_code_block() {
        let markdown = "```rust\nfn main() {}\n```";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain CodeBlock tag
        let has_code_block = events
            .iter()
            .any(|e| matches!(e, Event::Start(Tag::CodeBlock(_))));
        assert!(has_code_block);
    }

    #[test]
    fn test_extract_text_until_end() {
        let events = vec![
            Event::Text("Hello".into()),
            Event::Text(" World".into()),
            Event::End(TagEnd::Paragraph),
        ];

        let text = extract_text_until_end(&events, TagEnd::Paragraph);
        assert_eq!(text, "Hello World");
    }

    #[test]
    fn test_parse_ordered_list() {
        let markdown = "1. First\n2. Second\n3. Third";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain ordered list
        let has_ordered = events
            .iter()
            .any(|e| matches!(e, Event::Start(Tag::List(Some(_)))));
        assert!(has_ordered);
    }

    #[test]
    fn test_parse_emphasis() {
        let markdown = "*italic*";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain Emphasis tag
        let has_emphasis = events
            .iter()
            .any(|e| matches!(e, Event::Start(Tag::Emphasis)));
        assert!(has_emphasis);
    }

    #[test]
    fn test_parse_inline_code() {
        let markdown = "`code`";
        let parser = Parser::new_ext(markdown, Options::all());
        let events: Vec<Event> = parser.collect();

        // Should contain Code event
        let has_code = events.iter().any(|e| matches!(e, Event::Code(_)));
        assert!(has_code);
    }
}