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
//! The GTK user interface.

use std::path::{PathBuf, Path};
use std::process::Command;
use std::time::Instant;

use anyhow::anyhow;
use gdk::keys;
use gio::Cancellable;
use gtk::prelude::*;
use log::{debug, warn};
use pathbuftools::PathBufTools;
use webkit2gtk::{WebContext, WebView, WebViewExt};

use crate::assets::{Assets, PageState};
use crate::input::{InputFile, Config};
use crate::markdown::RenderedContent;

/// The container for all the GTK widgets of the app -- window, webview, etc.
/// All of these are reference-counted, so should be cheap to clone.
///
#[derive(Clone)]
pub struct App {
    window: gtk::Window,
    browser: Browser,
    assets: Assets,
    filename: PathBuf,
    config: Config,
}

impl App {
    /// Construct a new app. Input params:
    ///
    /// - input_file: Used as the window title and for other actions on the file.
    /// - assets:     Encapsulates the HTML layout that will be wrapping the rendered markdown.
    ///
    /// Initialization could fail due to a `WebContext` failure.
    ///
    pub fn init(config: Config, input_file: InputFile, assets: Assets) -> anyhow::Result<Self> {
        let window = gtk::Window::new(gtk::WindowType::Toplevel);
        window.set_default_size(1024, 768);

        let title = match &input_file {
            InputFile::Filesystem(p) => format!("{} - Quickmd", p.short_path().display()),
            InputFile::Stdin(_)      => String::from("Quickmd"),
        };
        window.set_title(&title);

        let browser = Browser::new(config.clone())?;
        window.add(&browser.webview);

        Ok(App { window, browser, assets, config, filename: input_file.path().to_path_buf() })
    }

    /// Start listening to events from the `ui_receiver` and trigger the relevant methods on the
    /// `App`. Doesn't block.
    ///
    pub fn init_render_loop(&self, ui_receiver: glib::Receiver<Event>) {
        let mut app_clone = self.clone();

        ui_receiver.attach(None, move |event| {
            match event {
                Event::LoadHtml(content) => {
                    app_clone.load_content(&content).
                        unwrap_or_else(|e| warn!("Couldn't update HTML: {}", e))
                },
                Event::Reload => app_clone.reload(),
            }
            glib::Continue(true)
        });
    }

    /// Actually start the UI, blocking the main thread.
    ///
    pub fn run(&mut self) {
        self.connect_events();
        self.window.show_all();

        gtk::main();

        self.assets.clean_up();
    }

    fn load_content(&mut self, content: &RenderedContent) -> anyhow::Result<()> {
        let page_state = self.browser.get_page_state();
        let output_path = self.assets.build(content, &page_state)?;

        debug!("Loading HTML:");
        debug!(" > output_path = {}", output_path.display());

        self.browser.load_uri(&format!("file://{}", output_path.display()));
        Ok(())
    }

    fn reload(&self) {
        self.browser.reload();
    }

    fn connect_events(&self) {
        let filename        = self.filename.clone();
        let editor_command  = self.config.editor_command.clone();

        // Key presses mapped to repeatable events:
        let browser = self.browser.clone();
        self.window.connect_key_press_event(move |_window, event| {
            let keyval   = event.get_keyval();
            let keystate = event.get_state();

            match (keystate, keyval) {
                // Scroll with j/k, J/K:
                (_, keys::constants::j) => browser.execute_js("window.scrollBy(0, 70)"),
                (_, keys::constants::J) => browser.execute_js("window.scrollBy(0, 250)"),
                (_, keys::constants::k) => browser.execute_js("window.scrollBy(0, -70)"),
                (_, keys::constants::K) => browser.execute_js("window.scrollBy(0, -250)"),
                // Jump to the top/bottom with g/G
                (_, keys::constants::g) => browser.execute_js("window.scroll({top: 0})"),
                (_, keys::constants::G) => {
                    browser.execute_js("window.scroll({top: document.body.scrollHeight})")
                },
                _ => (),
            }
            Inhibit(false)
        });

        // Key releases mapped to one-time events:
        let browser = self.browser.clone();
        self.window.connect_key_release_event(move |window, event| {
            let keyval   = event.get_keyval();
            let keystate = event.get_state();

            match (keystate, keyval) {
                // Ctrl+Q
                (gdk::ModifierType::CONTROL_MASK, keys::constants::q) => {
                    gtk::main_quit();
                },
                // e:
                (_, keys::constants::e) => {
                    debug!("Launching an editor");
                    launch_editor(&editor_command, &filename);
                },
                // E:
                (_, keys::constants::E) => {
                    debug!("Exec-ing into an editor");
                    exec_editor(&editor_command, &filename);
                },
                // +/-/=:
                (_, keys::constants::plus)  => browser.zoom_in(),
                (_, keys::constants::minus) => browser.zoom_out(),
                (_, keys::constants::equal) => browser.zoom_reset(),
                // F1
                (_, keys::constants::F1) => {
                    build_help_dialog(&window).run();
                },
                _ => (),
            }
            Inhibit(false)
        });

        // On Ctrl+Scroll, zoom:
        let browser = self.browser.clone();
        self.window.connect_scroll_event(move |_window, event| {
            if event.get_state().contains(gdk::ModifierType::CONTROL_MASK) {
                match event.get_direction() {
                    gdk::ScrollDirection::Up   => browser.zoom_in(),
                    gdk::ScrollDirection::Down => browser.zoom_out(),
                    _ => (),
                }
            }

            Inhibit(false)
        });

        self.window.connect_delete_event(|_, _| {
            gtk::main_quit();
            Inhibit(false)
        });
    }
}

/// Events that trigger UI changes.
///
#[derive(Debug)]
pub enum Event {
    /// Load the given content into the webview.
    LoadHtml(RenderedContent),

    /// Refresh the webview.
    Reload,
}

/// A thin layer on top of `webkit2gtk::WebView` to put helper methods into.
///
#[derive(Clone)]
pub struct Browser {
    webview: WebView,
    config: Config,
}

impl Browser {
    /// Construct a new instance with the provided `Config`.
    ///
    pub fn new(config: Config) -> anyhow::Result<Self> {
        let web_context = WebContext::get_default().
            ok_or_else(|| anyhow!("Couldn't initialize GTK WebContext"))?;
        let webview = WebView::with_context(&web_context);
        webview.set_zoom_level(config.zoom);

        Ok(Browser { webview, config })
    }

    /// Delegates to `webkit2gtk::WebView`
    pub fn load_uri(&self, uri: &str) {
        self.webview.load_uri(uri);
    }

    /// Delegates to `webkit2gtk::WebView`
    pub fn reload(&self) {
        self.webview.reload();
    }

    /// Increase zoom level by ~10%
    ///
    pub fn zoom_in(&self) {
        let zoom_level = self.webview.get_zoom_level();
        self.webview.set_zoom_level(zoom_level + 0.1);
        debug!("Zoom level set to: {}", zoom_level);
    }

    /// Decrease zoom level by ~10%, down till 20% or so.
    ///
    pub fn zoom_out(&self) {
        let zoom_level = self.webview.get_zoom_level();

        if zoom_level > 0.2 {
            self.webview.set_zoom_level(zoom_level - 0.1);
            debug!("Zoom level set to: {}", zoom_level);
        }
    }

    /// Reset to the base zoom level defined in the config (which defaults to 100%).
    ///
    pub fn zoom_reset(&self) {
        self.webview.set_zoom_level(self.config.zoom);
        debug!("Zoom level set to: {}", self.config.zoom);
    }

    /// Get the deserialized `PageState` from the current contents of the webview. This is later
    /// rendered unchanged into the HTML content.
    ///
    pub fn get_page_state(&self) -> PageState {
        match self.webview.get_title() {
            Some(t) => {
                serde_json::from_str(t.as_str()).unwrap_or_else(|e| {
                    warn!("Failed to get page state from {}: {:?}", t, e);
                    PageState::default()
                })
            },
            None => PageState::default(),
        }
    }

    /// Execute some (async) javascript code in the webview, without checking the result other than
    /// printing a warning if it errors out.
    ///
    pub fn execute_js(&self, js_code: &'static str) {
        let now = Instant::now();

        self.webview.run_javascript(js_code, None::<&Cancellable>, move |result| {
            if let Err(e) = result {
                warn!("Javascript execution error: {}", e);
            } else {
                debug!("Javascript executed in {}ms:\n> {}", now.elapsed().as_millis(), js_code);
            }
        });
    }
}

/// A popup to choose a file if it wasn't provided on the command-line.
///
pub struct FilePicker(gtk::FileChooserDialog);

impl FilePicker {
    /// Construct a new file picker that only shows markdown files by default
    ///
    pub fn new() -> FilePicker {
        let dialog = gtk::FileChooserDialog::new(
            Some("Open"),
            Some(&gtk::Window::new(gtk::WindowType::Popup)),
            gtk::FileChooserAction::Open,
        );

        // Only show markdown files
        let filter = gtk::FileFilter::new();
        filter.set_name(Some("Markdown files (*.md, *.markdown)"));
        filter.add_pattern("*.md");
        filter.add_pattern("*.markdown");
        dialog.add_filter(&filter);

        // Just in case, allow showing all files
        let filter = gtk::FileFilter::new();
        filter.add_pattern("*");
        filter.set_name(Some("All files"));
        dialog.add_filter(&filter);

        // Add the cancel and open buttons to that dialog.
        dialog.add_button("Cancel", gtk::ResponseType::Cancel);
        dialog.add_button("Open", gtk::ResponseType::Ok);

        FilePicker(dialog)
    }

    /// Open the file picker popup and get the selected file.
    ///
    pub fn run(&self) -> Option<PathBuf> {
        if self.0.run() == gtk::ResponseType::Ok {
            self.0.get_filename()
        } else {
            None
        }
    }
}

impl Drop for FilePicker {
    fn drop(&mut self) { self.0.close(); }
}

#[cfg(target_family="unix")]
fn exec_editor(editor_command: &[String], file_path: &Path) {
    if let Some(mut editor) = build_editor_command(editor_command, file_path) {
        gtk::main_quit();

        use std::os::unix::process::CommandExt;

        editor.exec();
    }
}

#[cfg(not(target_family="unix"))]
fn exec_editor(_editor_command: &[String], _filename_string: &Path) {
    warn!("Not on a UNIX system, can't exec to a text editor");
}

fn launch_editor(editor_command: &[String], file_path: &Path) {
    if let Some(mut editor) = build_editor_command(editor_command, file_path) {
        if let Err(e) = editor.spawn() {
            warn!("Couldn't launch editor ({:?}): {}", editor_command, e);
        }
    }
}

fn build_editor_command(editor_command: &[String], file_path: &Path) -> Option<Command> {
    let executable = editor_command.get(0).or_else(|| {
        warn!("No \"editor\" defined in the config ({})", Config::yaml_path().display());
        None
    })?;

    let mut command = Command::new(executable);

    for arg in editor_command.iter().skip(1) {
        if arg == "{path}" {
            command.arg(file_path);
        } else {
            command.arg(arg);
        }
    }

    Some(command)
}

fn build_help_dialog(window: &gtk::Window) -> gtk::MessageDialog {
    use gtk::{DialogFlags, MessageType, ButtonsType};

    let dialog = gtk::MessageDialog::new(
        Some(window),
        DialogFlags::MODAL | DialogFlags::DESTROY_WITH_PARENT,
        MessageType::Info,
        ButtonsType::Close,
        ""
    );

    let content = format!{
        include_str!("../res/help_popup.html"),
        yaml_path = Config::yaml_path().display(),
        css_path = Config::css_path().display(),
    };

    dialog.set_markup(&content);
    dialog.connect_response(|d, _response| d.close());

    dialog
}