mirror of
https://github.com/rabfulton/ViewMD.git
synced 2026-11-05 09:40:18 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef00eebf5 | ||
|
|
14596044e0 | ||
|
|
2fe8624b64 | ||
|
|
02da8ed087 | ||
|
|
aa31530f22 | ||
|
|
2a5a438c32 | ||
|
|
12c53c15a1 | ||
|
|
d0dc69fda0 | ||
|
|
81e9cee18e | ||
|
|
81dac54a2a | ||
|
|
126616f585 | ||
|
|
703399976f | ||
|
|
c6f5b8f369 | ||
|
|
d38bace1d5 | ||
|
|
e3b229c464 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
*.o
|
||||
/viewmd
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Robert Fulton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -17,10 +17,27 @@ bindir ?= $(PREFIX)/bin
|
||||
datadir ?= $(PREFIX)/share
|
||||
applicationsdir ?= $(datadir)/applications
|
||||
|
||||
.PHONY: all clean install uninstall
|
||||
.PHONY: all clean install uninstall test test-ui
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
test: $(OBJDIR)/test-markdown $(OBJDIR)/test-app-watch
|
||||
./$(OBJDIR)/test-markdown
|
||||
./$(OBJDIR)/test-app-watch
|
||||
|
||||
$(OBJDIR)/test-app-watch: tests/test_app_watch.c $(OBJDIR)/app.o $(OBJDIR)/config.o $(SRCDIR)/app.h $(SRCDIR)/editor.h $(SRCDIR)/config.h $(SRCDIR)/window.h
|
||||
$(CC) $(CFLAGS) -I$(SRCDIR) tests/test_app_watch.c $(OBJDIR)/app.o $(OBJDIR)/config.o -o $@ $(LDFLAGS)
|
||||
|
||||
# Requires a GTK display (for example: xvfb-run -a make test-ui).
|
||||
test-ui: $(OBJDIR)/test-watch-ui
|
||||
./$(OBJDIR)/test-watch-ui
|
||||
|
||||
$(OBJDIR)/test-watch-ui: tests/test_watch_ui.c $(filter-out $(OBJDIR)/main.o,$(OBJECTS)) $(SRCDIR)/app.h $(SRCDIR)/editor.h $(SRCDIR)/config.h $(SRCDIR)/window.h
|
||||
$(CC) $(CFLAGS) -I$(SRCDIR) tests/test_watch_ui.c $(filter-out $(OBJDIR)/main.o,$(OBJECTS)) -o $@ $(LDFLAGS)
|
||||
|
||||
$(OBJDIR)/test-markdown: tests/test_markdown.c $(OBJDIR)/markdown.o $(OBJDIR)/code_highlight.o $(OBJDIR)/config.o $(OBJDIR)/md4c.o $(SRCDIR)/markdown.h $(SRCDIR)/config.h
|
||||
$(CC) $(CFLAGS) -I$(SRCDIR) tests/test_markdown.c $(OBJDIR)/markdown.o $(OBJDIR)/code_highlight.o $(OBJDIR)/config.o $(OBJDIR)/md4c.o -o $@ $(LDFLAGS)
|
||||
|
||||
$(TARGET): $(OBJECTS)
|
||||
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
||||
|
||||
@@ -49,7 +66,7 @@ $(OBJDIR)/main.o: $(SRCDIR)/app.h $(SRCDIR)/window.h
|
||||
$(OBJDIR)/app.o: $(SRCDIR)/app.h $(SRCDIR)/config.h $(SRCDIR)/window.h $(SRCDIR)/editor.h
|
||||
$(OBJDIR)/window.o: $(SRCDIR)/window.h $(SRCDIR)/app.h $(SRCDIR)/editor.h $(SRCDIR)/config.h
|
||||
$(OBJDIR)/editor.o: $(SRCDIR)/editor.h $(SRCDIR)/markdown.h $(SRCDIR)/app.h
|
||||
$(OBJDIR)/markdown.o: $(SRCDIR)/markdown.h $(SRCDIR)/code_highlight.h
|
||||
$(OBJDIR)/markdown.o: $(SRCDIR)/markdown.h $(SRCDIR)/code_highlight.h $(SRCDIR)/html_entities.h
|
||||
$(OBJDIR)/code_highlight.o: $(SRCDIR)/code_highlight.h
|
||||
$(OBJDIR)/config.o: $(SRCDIR)/config.h
|
||||
$(OBJDIR)/md4c.o: $(SRCDIR)/md4c/md4c.h
|
||||
@@ -8,6 +8,7 @@ A lightweight GTK 3 markdown viewer for desktop Linux. It is ideal as your defau
|
||||
|
||||
- **Native GTK viewer** - No webviews
|
||||
- **Read-only rendering** - Focused on viewing markdown files
|
||||
- **Automatic reload** - Updates the preview when the open file changes on disk
|
||||
- **Minimal UI** - Clean toolbar with open and settings buttons
|
||||
- **Lightweight** - Pure C, no web technologies, fast startup
|
||||
- **Hyperlink support** - Left click opens links and internal anchors
|
||||
@@ -64,6 +65,13 @@ Run `viewmd` to start the application.
|
||||
- **Reload button**: Reload the currently open document from disk
|
||||
- **Settings button**: Adjust theme, fonts, and markdown accent colors
|
||||
|
||||
Files reload automatically after they are saved in another editor, including
|
||||
editors that replace the file when saving. ViewMD keeps your scroll position and
|
||||
retains the last preview if the file is temporarily unavailable. To keep a static
|
||||
preview, disable **Automatically reload changed files** in Settings and click
|
||||
**Apply**. The preference is saved between sessions; the Reload button and F5
|
||||
remain available for manual updates.
|
||||
|
||||
### Find in Document
|
||||
|
||||
- Press `Ctrl+F` to open search.
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -11,6 +11,7 @@ static void on_open(GtkApplication *gtk_app, GFile **files, gint n_files,
|
||||
const gchar *hint, gpointer user_data);
|
||||
static void markyd_app_update_window_title(MarkydApp *self);
|
||||
static void markyd_app_ensure_window(MarkydApp *self);
|
||||
static void stop_file_watch(MarkydApp *self);
|
||||
|
||||
MarkydApp *markyd_app_new(void) {
|
||||
MarkydApp *self = g_new0(MarkydApp, 1);
|
||||
@@ -41,6 +42,7 @@ void markyd_app_free(MarkydApp *self) {
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
stop_file_watch(self);
|
||||
if (self->window) {
|
||||
markyd_window_free(self->window);
|
||||
}
|
||||
@@ -130,19 +132,133 @@ static void markyd_app_ensure_window(MarkydApp *self) {
|
||||
markyd_app_update_window_title(self);
|
||||
}
|
||||
|
||||
static void stop_file_watch(MarkydApp *self) {
|
||||
if (self->watch_reload_id) {
|
||||
g_source_remove(self->watch_reload_id);
|
||||
self->watch_reload_id = 0;
|
||||
}
|
||||
if (self->file_monitor) {
|
||||
g_signal_handlers_disconnect_by_data(self->file_monitor, self);
|
||||
g_file_monitor_cancel(self->file_monitor);
|
||||
g_clear_object(&self->file_monitor);
|
||||
}
|
||||
g_clear_object(&self->watched_file);
|
||||
}
|
||||
|
||||
static gboolean reload_changed_file(gpointer user_data) {
|
||||
MarkydApp *self = user_data;
|
||||
gchar *content = NULL;
|
||||
self->watch_reload_id = 0;
|
||||
|
||||
if (!config->auto_reload || !self->editor || !self->current_file_path ||
|
||||
(self->window && !self->window->window)) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* A save may temporarily remove the file. Keep the last good preview; the
|
||||
* directory monitor will also see the replacement or a later recreation. */
|
||||
if (g_file_get_contents(self->current_file_path, &content, NULL, NULL)) {
|
||||
gchar *valid = g_utf8_make_valid(content, -1);
|
||||
if (g_strcmp0(valid, self->editor->source_content) != 0) {
|
||||
markyd_editor_reload_content(self->editor, valid);
|
||||
}
|
||||
g_free(valid);
|
||||
g_free(content);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void queue_file_reload(MarkydApp *self) {
|
||||
if (self->watch_reload_id)
|
||||
g_source_remove(self->watch_reload_id);
|
||||
self->watch_reload_id = g_timeout_add(150, reload_changed_file, self);
|
||||
}
|
||||
|
||||
static void on_watched_file_changed(GFileMonitor *monitor, GFile *file,
|
||||
GFile *other_file, GFileMonitorEvent event,
|
||||
gpointer user_data) {
|
||||
MarkydApp *self = user_data;
|
||||
(void)monitor;
|
||||
|
||||
if (!self->watched_file ||
|
||||
!((file && g_file_equal(file, self->watched_file)) ||
|
||||
(other_file && g_file_equal(other_file, self->watched_file)))) {
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case G_FILE_MONITOR_EVENT_CHANGED:
|
||||
case G_FILE_MONITOR_EVENT_CHANGES_DONE_HINT:
|
||||
case G_FILE_MONITOR_EVENT_CREATED:
|
||||
case G_FILE_MONITOR_EVENT_DELETED:
|
||||
case G_FILE_MONITOR_EVENT_MOVED:
|
||||
case G_FILE_MONITOR_EVENT_RENAMED:
|
||||
case G_FILE_MONITOR_EVENT_MOVED_IN:
|
||||
case G_FILE_MONITOR_EVENT_MOVED_OUT:
|
||||
queue_file_reload(self);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void markyd_app_update_file_watch(MarkydApp *self) {
|
||||
GFile *file;
|
||||
GFile *directory;
|
||||
GError *error = NULL;
|
||||
|
||||
if (!self)
|
||||
return;
|
||||
if (!config->auto_reload || !self->current_file_path) {
|
||||
stop_file_watch(self);
|
||||
return;
|
||||
}
|
||||
|
||||
file = g_file_new_for_path(self->current_file_path);
|
||||
if (self->file_monitor && g_file_equal(file, self->watched_file)) {
|
||||
g_object_unref(file);
|
||||
return;
|
||||
}
|
||||
stop_file_watch(self);
|
||||
self->watched_file = file;
|
||||
|
||||
/* Watching the parent keeps working when editors save by renaming a new
|
||||
* file over the old one. Events for unrelated directory entries are ignored. */
|
||||
directory = g_file_get_parent(file);
|
||||
if (!directory)
|
||||
return;
|
||||
self->file_monitor = g_file_monitor_directory(
|
||||
directory, G_FILE_MONITOR_WATCH_MOVES, NULL, &error);
|
||||
g_object_unref(directory);
|
||||
if (!self->file_monitor) {
|
||||
g_printerr("ViewMD: could not watch '%s': %s\n", self->current_file_path,
|
||||
error ? error->message : "file monitoring unavailable");
|
||||
g_clear_error(&error);
|
||||
return;
|
||||
}
|
||||
g_signal_connect(self->file_monitor, "changed",
|
||||
G_CALLBACK(on_watched_file_changed), self);
|
||||
/* Catch changes between loading and attaching the monitor, or while the
|
||||
* preference was disabled. Identical content does not trigger rendering. */
|
||||
queue_file_reload(self);
|
||||
}
|
||||
|
||||
gboolean markyd_app_open_file(MarkydApp *self, const gchar *path) {
|
||||
gchar *content = NULL;
|
||||
gchar *new_path;
|
||||
GError *error = NULL;
|
||||
|
||||
if (!self || !self->editor || !path || path[0] == '\0') {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (!g_file_get_contents(path, &content, NULL, &error)) {
|
||||
/* Copy before replacing current_file_path: callers may pass that pointer. */
|
||||
new_path = g_canonicalize_filename(path, NULL);
|
||||
if (!g_file_get_contents(new_path, &content, NULL, &error)) {
|
||||
if (error) {
|
||||
g_printerr("Failed to load markdown file '%s': %s\n", path, error->message);
|
||||
g_error_free(error);
|
||||
}
|
||||
g_free(new_path);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
@@ -150,9 +266,10 @@ gboolean markyd_app_open_file(MarkydApp *self, const gchar *path) {
|
||||
g_free(content);
|
||||
|
||||
g_free(self->current_file_path);
|
||||
self->current_file_path = g_strdup(path);
|
||||
self->current_file_path = new_path;
|
||||
|
||||
markyd_app_update_window_title(self);
|
||||
markyd_app_update_file_watch(self);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ typedef struct _MarkydApp {
|
||||
MarkydWindow *window;
|
||||
MarkydEditor *editor;
|
||||
gchar *current_file_path;
|
||||
|
||||
GFileMonitor *file_monitor;
|
||||
GFile *watched_file;
|
||||
guint watch_reload_id;
|
||||
} MarkydApp;
|
||||
|
||||
/* Global app instance */
|
||||
@@ -26,6 +30,9 @@ int markyd_app_run(MarkydApp *app, int argc, char **argv);
|
||||
/* Document management */
|
||||
gboolean markyd_app_open_file(MarkydApp *app, const gchar *path);
|
||||
|
||||
/* Apply the auto-reload preference to the currently open file. */
|
||||
void markyd_app_update_file_watch(MarkydApp *app);
|
||||
|
||||
/* Utility */
|
||||
const gchar *markyd_app_get_current_path(MarkydApp *app);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ MarkydConfig *config_new(void) {
|
||||
|
||||
cfg->line_numbers = FALSE;
|
||||
cfg->word_wrap = TRUE;
|
||||
cfg->auto_reload = TRUE;
|
||||
|
||||
return cfg;
|
||||
}
|
||||
@@ -130,6 +131,13 @@ gboolean config_load(MarkydConfig *cfg) {
|
||||
if (g_key_file_has_key(keyfile, "Editor", "word_wrap", NULL))
|
||||
cfg->word_wrap =
|
||||
g_key_file_get_boolean(keyfile, "Editor", "word_wrap", NULL);
|
||||
if (g_key_file_has_key(keyfile, "Editor", "auto_reload", NULL)) {
|
||||
gboolean auto_reload =
|
||||
g_key_file_get_boolean(keyfile, "Editor", "auto_reload", &error);
|
||||
if (!error)
|
||||
cfg->auto_reload = auto_reload;
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
g_key_file_free(keyfile);
|
||||
return TRUE;
|
||||
@@ -166,6 +174,7 @@ gboolean config_save(MarkydConfig *cfg) {
|
||||
|
||||
/* Editor */
|
||||
g_key_file_set_boolean(keyfile, "Editor", "word_wrap", cfg->word_wrap);
|
||||
g_key_file_set_boolean(keyfile, "Editor", "auto_reload", cfg->auto_reload);
|
||||
|
||||
data = g_key_file_to_data(keyfile, &length, &error);
|
||||
if (error) {
|
||||
|
||||
@@ -26,6 +26,7 @@ typedef struct _MarkydConfig {
|
||||
/* Editor */
|
||||
gboolean line_numbers;
|
||||
gboolean word_wrap;
|
||||
gboolean auto_reload;
|
||||
} MarkydConfig;
|
||||
|
||||
/* Global config instance */
|
||||
|
||||
+89
-2
@@ -9,6 +9,7 @@ static gboolean on_motion_notify(GtkWidget *widget, GdkEventMotion *event,
|
||||
gpointer user_data);
|
||||
static gboolean on_leave_notify(GtkWidget *widget, GdkEventCrossing *event,
|
||||
gpointer user_data);
|
||||
static void on_text_view_destroyed(GtkWidget *widget, gpointer user_data);
|
||||
static void apply_markdown(MarkydEditor *self);
|
||||
static void schedule_markdown_apply(MarkydEditor *self);
|
||||
static void render_image_widgets(MarkydEditor *self);
|
||||
@@ -133,7 +134,7 @@ static void set_link_cursor(MarkydEditor *self, gboolean active) {
|
||||
}
|
||||
|
||||
static void apply_markdown(MarkydEditor *self) {
|
||||
if (!self) {
|
||||
if (!self || !self->buffer || !self->text_view) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -356,10 +357,32 @@ static void render_table_widgets(MarkydEditor *self) {
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean restore_scroll_idle(gpointer user_data) {
|
||||
MarkydEditor *self = user_data;
|
||||
self->scroll_restore_id = 0;
|
||||
if (self->text_view && self->buffer) {
|
||||
GtkTextIter end;
|
||||
GdkRectangle location;
|
||||
/* Validate the new layout before clamping to its scrollable extent. */
|
||||
gtk_text_buffer_get_end_iter(self->buffer, &end);
|
||||
gtk_text_view_get_iter_location(GTK_TEXT_VIEW(self->text_view), &end, &location);
|
||||
GtkAdjustment *hadj = gtk_scrollable_get_hadjustment(GTK_SCROLLABLE(self->text_view));
|
||||
GtkAdjustment *vadj = gtk_scrollable_get_vadjustment(GTK_SCROLLABLE(self->text_view));
|
||||
gtk_adjustment_set_value(hadj, self->reload_hscroll);
|
||||
gtk_adjustment_set_value(vadj, self->reload_vscroll);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean apply_markdown_idle(gpointer user_data) {
|
||||
MarkydEditor *self = (MarkydEditor *)user_data;
|
||||
self->markdown_idle_id = 0;
|
||||
apply_markdown(self);
|
||||
if (self->preserve_scroll) {
|
||||
self->preserve_scroll = FALSE;
|
||||
self->scroll_restore_id =
|
||||
g_idle_add_full(G_PRIORITY_LOW, restore_scroll_idle, self, NULL);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
@@ -383,6 +406,8 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) {
|
||||
self->markdown_idle_id = 0;
|
||||
|
||||
self->text_view = gtk_text_view_new();
|
||||
g_object_add_weak_pointer(G_OBJECT(self->text_view),
|
||||
(gpointer *)&self->text_view);
|
||||
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(self->text_view),
|
||||
GTK_WRAP_WORD_CHAR);
|
||||
gtk_text_view_set_left_margin(GTK_TEXT_VIEW(self->text_view), 16);
|
||||
@@ -404,6 +429,8 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) {
|
||||
G_CALLBACK(on_motion_notify), self);
|
||||
g_signal_connect(self->text_view, "leave-notify-event",
|
||||
G_CALLBACK(on_leave_notify), self);
|
||||
g_signal_connect(self->text_view, "destroy",
|
||||
G_CALLBACK(on_text_view_destroyed), self);
|
||||
g_signal_connect(self->text_view, "size-allocate",
|
||||
G_CALLBACK(on_text_view_size_allocate), self);
|
||||
|
||||
@@ -418,10 +445,44 @@ static void on_text_view_size_allocate(GtkWidget *widget, GtkAllocation *allocat
|
||||
refresh_image_widget_scales(self);
|
||||
}
|
||||
|
||||
static void on_text_view_destroyed(GtkWidget *widget, gpointer user_data) {
|
||||
/* GtkTextView clears its buffer on dispose and then sets text targets.
|
||||
It is no longer a drop destination after we unset it, which would warn;
|
||||
mark it as one again while the widget is still alive. */
|
||||
gtk_drag_dest_set(widget, 0, NULL, 0, 0);
|
||||
|
||||
MarkydEditor *self = (MarkydEditor *)user_data;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
self->text_view = NULL;
|
||||
self->buffer = NULL;
|
||||
if (self->scroll_restore_id) {
|
||||
g_source_remove(self->scroll_restore_id);
|
||||
self->scroll_restore_id = 0;
|
||||
}
|
||||
if (self->markdown_idle_id != 0) {
|
||||
g_source_remove(self->markdown_idle_id);
|
||||
self->markdown_idle_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void markyd_editor_free(MarkydEditor *self) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (self->scroll_restore_id) {
|
||||
g_source_remove(self->scroll_restore_id);
|
||||
}
|
||||
if (self->text_view) {
|
||||
/* The window may destroy the text view after this editor is freed.
|
||||
All view handlers carry this editor as user data, so disconnect them
|
||||
before freeing. */
|
||||
g_signal_handlers_disconnect_by_data(self->text_view, self);
|
||||
g_object_remove_weak_pointer(G_OBJECT(self->text_view),
|
||||
(gpointer *)&self->text_view);
|
||||
self->text_view = NULL;
|
||||
}
|
||||
if (self->markdown_idle_id != 0) {
|
||||
g_source_remove(self->markdown_idle_id);
|
||||
self->markdown_idle_id = 0;
|
||||
@@ -435,11 +496,37 @@ void markyd_editor_set_content(MarkydEditor *self, const gchar *content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self->scroll_restore_id) {
|
||||
g_source_remove(self->scroll_restore_id);
|
||||
self->scroll_restore_id = 0;
|
||||
}
|
||||
self->preserve_scroll = FALSE;
|
||||
g_free(self->source_content);
|
||||
self->source_content = g_strdup(content ? content : "");
|
||||
/* Guard against non-UTF-8 markdown (e.g. latin-1) crashing Pango later. */
|
||||
if (content) {
|
||||
self->source_content = g_utf8_make_valid(content, -1);
|
||||
} else {
|
||||
self->source_content = g_strdup("");
|
||||
}
|
||||
schedule_markdown_apply(self);
|
||||
}
|
||||
|
||||
void markyd_editor_reload_content(MarkydEditor *self, const gchar *content) {
|
||||
if (!self)
|
||||
return;
|
||||
gdouble hscroll = 0.0, vscroll = 0.0;
|
||||
if (self->text_view) {
|
||||
hscroll = gtk_adjustment_get_value(
|
||||
gtk_scrollable_get_hadjustment(GTK_SCROLLABLE(self->text_view)));
|
||||
vscroll = gtk_adjustment_get_value(
|
||||
gtk_scrollable_get_vadjustment(GTK_SCROLLABLE(self->text_view)));
|
||||
}
|
||||
markyd_editor_set_content(self, content);
|
||||
self->reload_hscroll = hscroll;
|
||||
self->reload_vscroll = vscroll;
|
||||
self->preserve_scroll = TRUE;
|
||||
}
|
||||
|
||||
gchar *markyd_editor_get_content(MarkydEditor *self) {
|
||||
if (!self) {
|
||||
return g_strdup("");
|
||||
|
||||
@@ -18,6 +18,10 @@ typedef struct _MarkydEditor {
|
||||
|
||||
/* Coalesce markdown re-rendering to idle to avoid invalidating GTK iterators. */
|
||||
guint markdown_idle_id;
|
||||
guint scroll_restore_id;
|
||||
gboolean preserve_scroll;
|
||||
gdouble reload_hscroll;
|
||||
gdouble reload_vscroll;
|
||||
} MarkydEditor;
|
||||
|
||||
/* Lifecycle */
|
||||
@@ -26,6 +30,8 @@ void markyd_editor_free(MarkydEditor *editor);
|
||||
|
||||
/* Content management */
|
||||
void markyd_editor_set_content(MarkydEditor *editor, const gchar *content);
|
||||
/* Update a changed file without moving the reader back to the start. */
|
||||
void markyd_editor_reload_content(MarkydEditor *editor, const gchar *content);
|
||||
gchar *markyd_editor_get_content(MarkydEditor *editor);
|
||||
|
||||
/* Widget access */
|
||||
|
||||
+2143
File diff suppressed because it is too large.
Load diff
+133
-28
@@ -2,6 +2,8 @@
|
||||
#include "code_highlight.h"
|
||||
#include "config.h"
|
||||
#include "md4c/md4c.h"
|
||||
#include "html_entities.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Tag names */
|
||||
@@ -23,6 +25,12 @@
|
||||
#define TAG_TABLE_SEPARATOR "table_separator"
|
||||
#define TAG_INVISIBLE "invisible"
|
||||
#define TABLE_MODEL_DATA_KEY "viewmd-table-model"
|
||||
#define RENDER_STATE_DATA_KEY "viewmd-render-state"
|
||||
|
||||
typedef struct {
|
||||
GPtrArray *link_tags; /* Owned GtkTextTag references. */
|
||||
GPtrArray *anchor_marks; /* Owned GtkTextMark references. */
|
||||
} RenderState;
|
||||
|
||||
typedef struct {
|
||||
gboolean ordered;
|
||||
@@ -63,6 +71,7 @@ typedef struct {
|
||||
|
||||
typedef struct {
|
||||
GtkTextBuffer *buffer;
|
||||
RenderState *render_state;
|
||||
GtkTextIter iter;
|
||||
GPtrArray *active_tags; /* GtkTextTag* */
|
||||
GArray *block_stack; /* BlockState */
|
||||
@@ -89,6 +98,42 @@ typedef struct {
|
||||
guint trailing_newlines;
|
||||
} RenderCtx;
|
||||
|
||||
static void render_state_free(gpointer data) {
|
||||
RenderState *state = data;
|
||||
g_ptr_array_free(state->link_tags, TRUE);
|
||||
g_ptr_array_free(state->anchor_marks, TRUE);
|
||||
g_free(state);
|
||||
}
|
||||
|
||||
static RenderState *reset_render_state(GtkTextBuffer *buffer) {
|
||||
RenderState *state = g_object_get_data(G_OBJECT(buffer), RENDER_STATE_DATA_KEY);
|
||||
|
||||
if (!state) {
|
||||
state = g_new0(RenderState, 1);
|
||||
state->link_tags = g_ptr_array_new_with_free_func(g_object_unref);
|
||||
state->anchor_marks = g_ptr_array_new_with_free_func(g_object_unref);
|
||||
g_object_set_data_full(G_OBJECT(buffer), RENDER_STATE_DATA_KEY, state,
|
||||
render_state_free);
|
||||
return state;
|
||||
}
|
||||
|
||||
/* Replacing text removes tag applications, but leaves tags and marks alive. */
|
||||
GtkTextTagTable *table = gtk_text_buffer_get_tag_table(buffer);
|
||||
for (guint i = 0; i < state->link_tags->len; i++) {
|
||||
gtk_text_tag_table_remove(table, g_ptr_array_index(state->link_tags, i));
|
||||
}
|
||||
g_ptr_array_set_size(state->link_tags, 0);
|
||||
|
||||
for (guint i = 0; i < state->anchor_marks->len; i++) {
|
||||
GtkTextMark *mark = g_ptr_array_index(state->anchor_marks, i);
|
||||
if (!gtk_text_mark_get_deleted(mark)) {
|
||||
gtk_text_buffer_delete_mark(buffer, mark);
|
||||
}
|
||||
}
|
||||
g_ptr_array_set_size(state->anchor_marks, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
static void viewmd_table_row_free(gpointer data) {
|
||||
ViewmdTableRow *row = (ViewmdTableRow *)data;
|
||||
if (!row) {
|
||||
@@ -362,10 +407,10 @@ static guint decimal_digits(guint value) {
|
||||
return digits;
|
||||
}
|
||||
|
||||
static guint list_marker_columns(RenderCtx *ctx) {
|
||||
static guint list_marker_columns(RenderCtx *ctx, gboolean is_task) {
|
||||
ListState *list;
|
||||
if (!ctx || !ctx->list_stack || ctx->list_stack->len == 0) {
|
||||
return 2; /* "• " */
|
||||
if (is_task || !ctx || !ctx->list_stack || ctx->list_stack->len == 0) {
|
||||
return 2; /* A bullet or checkbox followed by a space. */
|
||||
}
|
||||
list = &g_array_index(ctx->list_stack, ListState, ctx->list_stack->len - 1);
|
||||
if (list->ordered) {
|
||||
@@ -433,11 +478,74 @@ static void pop_active_tags(RenderCtx *ctx, guint count) {
|
||||
}
|
||||
}
|
||||
|
||||
static gint compare_entity_names(const void *key, const void *entry) {
|
||||
return strcmp(key, ((const HtmlEntity *)entry)->name);
|
||||
}
|
||||
|
||||
static gchar *decode_entity(const MD_CHAR *text, MD_SIZE size) {
|
||||
gchar *entity = g_strndup(text, size);
|
||||
|
||||
/* md4c has already validated the reference syntax, including the semicolon
|
||||
* and the number of digits. Decode only these parser-identified references. */
|
||||
if (size > 2 && entity[1] == '#') {
|
||||
const gchar *digits = entity + 2;
|
||||
guint base = 10;
|
||||
if (*digits == 'x' || *digits == 'X') {
|
||||
digits++;
|
||||
base = 16;
|
||||
}
|
||||
guint64 value = g_ascii_strtoull(digits, NULL, base);
|
||||
if (value == 0 || value > 0x10FFFF ||
|
||||
(value >= 0xD800 && value <= 0xDFFF)) {
|
||||
value = 0xFFFD;
|
||||
}
|
||||
gchar utf8[7] = {0};
|
||||
g_unichar_to_utf8((gunichar)value, utf8);
|
||||
g_free(entity);
|
||||
return g_strdup(utf8);
|
||||
}
|
||||
|
||||
const HtmlEntity *match = bsearch(entity, html_entities,
|
||||
G_N_ELEMENTS(html_entities), sizeof(html_entities[0]), compare_entity_names);
|
||||
if (match) {
|
||||
g_free(entity);
|
||||
return g_strdup(match->utf8);
|
||||
}
|
||||
return entity; /* Unknown names remain literal, as required by CommonMark. */
|
||||
}
|
||||
|
||||
static gchar *md_text_to_utf8(MD_TEXTTYPE type, const MD_CHAR *text,
|
||||
MD_SIZE size) {
|
||||
if (type == MD_TEXT_NULLCHAR) {
|
||||
GString *s = g_string_new(NULL);
|
||||
for (MD_SIZE i = 0; i < size; i++) {
|
||||
g_string_append(s, "\xEF\xBF\xBD");
|
||||
}
|
||||
return g_string_free(s, FALSE);
|
||||
}
|
||||
|
||||
if (!text || size == 0) {
|
||||
return g_strdup("");
|
||||
}
|
||||
if (type == MD_TEXT_ENTITY) {
|
||||
return decode_entity(text, size);
|
||||
}
|
||||
return g_strndup(text, size);
|
||||
}
|
||||
|
||||
static gchar *attr_to_string(const MD_ATTRIBUTE *attr) {
|
||||
if (!attr || !attr->text || attr->size == 0) {
|
||||
return g_strdup("");
|
||||
}
|
||||
return g_strndup(attr->text, attr->size);
|
||||
GString *result = g_string_new(NULL);
|
||||
for (guint i = 0; attr->substr_offsets[i] < attr->size; i++) {
|
||||
MD_OFFSET start = attr->substr_offsets[i];
|
||||
MD_SIZE size = attr->substr_offsets[i + 1] - start;
|
||||
gchar *part = md_text_to_utf8(attr->substr_types[i], attr->text + start, size);
|
||||
g_string_append(result, part);
|
||||
g_free(part);
|
||||
}
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
static gchar *extract_code_language_from_detail(MD_BLOCK_CODE_DETAIL *code) {
|
||||
@@ -549,23 +657,6 @@ static void apply_code_highlighting(GtkTextBuffer *buffer, GArray *code_blocks)
|
||||
}
|
||||
}
|
||||
|
||||
static gchar *md_text_to_utf8(MD_TEXTTYPE type, const MD_CHAR *text,
|
||||
MD_SIZE size) {
|
||||
if (type == MD_TEXT_NULLCHAR) {
|
||||
GString *s = g_string_new(NULL);
|
||||
for (MD_SIZE i = 0; i < size; i++) {
|
||||
g_string_append(s, "\xEF\xBF\xBD");
|
||||
}
|
||||
return g_string_free(s, FALSE);
|
||||
}
|
||||
|
||||
if (!text || size == 0) {
|
||||
return g_strdup("");
|
||||
}
|
||||
|
||||
return g_strndup(text, size);
|
||||
}
|
||||
|
||||
gchar *markdown_normalize_anchor_slug(const gchar *text) {
|
||||
gchar *decoded;
|
||||
const gchar *p;
|
||||
@@ -644,7 +735,10 @@ static void create_heading_anchor(RenderCtx *ctx) {
|
||||
|
||||
mark_name = g_strdup_printf("%s%s", VIEWMD_ANCHOR_MARK_PREFIX, slug);
|
||||
gtk_text_buffer_get_iter_at_offset(ctx->buffer, &at, ctx->heading_start_offset);
|
||||
gtk_text_buffer_create_mark(ctx->buffer, mark_name, &at, TRUE);
|
||||
GtkTextMark *mark = gtk_text_buffer_create_mark(ctx->buffer, mark_name, &at, TRUE);
|
||||
if (mark) {
|
||||
g_ptr_array_add(ctx->render_state->anchor_marks, g_object_ref(mark));
|
||||
}
|
||||
|
||||
g_free(mark_name);
|
||||
g_free(slug);
|
||||
@@ -917,7 +1011,7 @@ static void image_emit_anchor(RenderCtx *ctx) {
|
||||
insert_cstr(ctx, "\n");
|
||||
}
|
||||
|
||||
static void insert_list_marker(RenderCtx *ctx) {
|
||||
static void insert_list_marker(RenderCtx *ctx, const MD_BLOCK_LI_DETAIL *detail) {
|
||||
ListState *list;
|
||||
gchar *ordered = NULL;
|
||||
gint marker_start_offset;
|
||||
@@ -930,8 +1024,12 @@ static void insert_list_marker(RenderCtx *ctx) {
|
||||
list = &g_array_index(ctx->list_stack, ListState, ctx->list_stack->len - 1);
|
||||
|
||||
marker_start_offset = gtk_text_iter_get_offset(&ctx->iter);
|
||||
if (list->ordered) {
|
||||
ordered = g_strdup_printf("%u.", list->next_index++);
|
||||
if (detail && detail->is_task) {
|
||||
insert_cstr(ctx, (detail->task_mark == 'x' || detail->task_mark == 'X')
|
||||
? "\xE2\x98\x91" /* ☑ */
|
||||
: "\xE2\x98\x90"); /* ☐ */
|
||||
} else if (list->ordered) {
|
||||
ordered = g_strdup_printf("%u.", list->next_index);
|
||||
insert_cstr(ctx, ordered);
|
||||
} else {
|
||||
insert_cstr(ctx, "\xE2\x80\xA2");
|
||||
@@ -941,6 +1039,9 @@ static void insert_list_marker(RenderCtx *ctx) {
|
||||
marker_end_offset);
|
||||
|
||||
insert_cstr(ctx, " ");
|
||||
if (list->ordered) {
|
||||
list->next_index++;
|
||||
}
|
||||
g_free(ordered);
|
||||
}
|
||||
|
||||
@@ -1018,7 +1119,8 @@ static int on_enter_block(MD_BLOCKTYPE type, void *detail, void *userdata) {
|
||||
|
||||
case MD_BLOCK_LI:
|
||||
{
|
||||
guint marker_cols = list_marker_columns(ctx);
|
||||
MD_BLOCK_LI_DETAIL *li = detail;
|
||||
guint marker_cols = list_marker_columns(ctx, li && li->is_task);
|
||||
GtkTextTag *layout_tag = ensure_list_layout_tag(
|
||||
ctx, ctx->list_stack->len, ctx->quote_depth, marker_cols);
|
||||
|
||||
@@ -1027,7 +1129,7 @@ static int on_enter_block(MD_BLOCKTYPE type, void *detail, void *userdata) {
|
||||
&g_array_index(ctx->block_stack, BlockState,
|
||||
ctx->block_stack->len - 1)
|
||||
.pushed_tags);
|
||||
insert_list_marker(ctx);
|
||||
insert_list_marker(ctx, li);
|
||||
ctx->list_item_prefix_pending = TRUE;
|
||||
}
|
||||
break;
|
||||
@@ -1191,6 +1293,7 @@ static int on_enter_span(MD_SPANTYPE type, void *detail, void *userdata) {
|
||||
MD_SPAN_A_DETAIL *a = (MD_SPAN_A_DETAIL *)detail;
|
||||
gchar *href = attr_to_string(a ? &a->href : NULL);
|
||||
GtkTextTag *url_tag = gtk_text_buffer_create_tag(ctx->buffer, NULL, NULL);
|
||||
g_ptr_array_add(ctx->render_state->link_tags, g_object_ref(url_tag));
|
||||
g_object_set_data_full(G_OBJECT(url_tag), VIEWMD_LINK_URL_DATA, href, g_free);
|
||||
push_active_tag_by_name(ctx, TAG_LINK,
|
||||
&g_array_index(ctx->span_stack, SpanState,
|
||||
@@ -1511,11 +1614,12 @@ void markdown_apply_tags(GtkTextBuffer *buffer, const gchar *source) {
|
||||
return;
|
||||
}
|
||||
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.render_state = reset_render_state(buffer);
|
||||
gtk_text_buffer_set_text(buffer, "", -1);
|
||||
input = source ? source : "";
|
||||
normalized_source = normalize_markdown_source(input);
|
||||
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.buffer = buffer;
|
||||
ctx.active_tags = g_ptr_array_new();
|
||||
ctx.block_stack = g_array_new(FALSE, FALSE, sizeof(BlockState));
|
||||
@@ -1543,6 +1647,7 @@ void markdown_apply_tags(GtkTextBuffer *buffer, const gchar *source) {
|
||||
rc = md_parse(normalized_source, (MD_SIZE)strlen(normalized_source), &parser,
|
||||
&ctx);
|
||||
if (rc != 0) {
|
||||
reset_render_state(buffer);
|
||||
gtk_text_buffer_set_text(buffer, input, -1);
|
||||
} else {
|
||||
apply_code_highlighting(buffer, ctx.code_blocks);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
# The MIT License (MIT)
|
||||
|
||||
Copyright © 2016-2024 Martin Mitáš
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the “Software”),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
+184
-2
@@ -14,6 +14,7 @@ typedef struct {
|
||||
|
||||
#define TAG_SEARCH_MATCH "viewmd_search_match"
|
||||
#define TAG_SEARCH_CURRENT "viewmd_search_current"
|
||||
#define AUTO_RELOAD_TOGGLE_DATA "viewmd-auto-reload-toggle"
|
||||
|
||||
static void on_open_clicked(GtkButton *button, gpointer user_data);
|
||||
static void on_refresh_clicked(GtkButton *button, gpointer user_data);
|
||||
@@ -47,13 +48,20 @@ static void set_table_cell_highlight(GtkWidget *cell, gboolean match,
|
||||
static gboolean scroll_to_table_cell(MarkydWindow *self, GtkWidget *cell);
|
||||
static void show_search_ui(MarkydWindow *self);
|
||||
static void hide_search_ui(MarkydWindow *self);
|
||||
static void on_search_clicked(GtkButton *button, gpointer user_data);
|
||||
static gboolean on_key_press_event(GtkWidget *widget, GdkEventKey *event,
|
||||
gpointer user_data);
|
||||
static void on_drag_data_received(GtkWidget *widget, GdkDragContext *context,
|
||||
gint x, gint y, GtkSelectionData *data,
|
||||
guint info, guint time, gpointer user_data);
|
||||
static gboolean on_text_view_scroll(GtkWidget *widget, GdkEventScroll *event,
|
||||
gpointer user_data);
|
||||
static gboolean on_configure_event(GtkWidget *widget, GdkEventConfigure *event,
|
||||
gpointer user_data);
|
||||
static gboolean on_window_state_event(GtkWidget *widget,
|
||||
GdkEventWindowState *event,
|
||||
gpointer user_data);
|
||||
static void on_window_destroyed(GtkWidget *widget, gpointer user_data);
|
||||
|
||||
static void on_font_size_changed(GtkSpinButton *spin, gpointer user_data);
|
||||
static void on_font_family_changed(GtkComboBoxText *combo, gpointer user_data);
|
||||
@@ -535,6 +543,20 @@ static void hide_search_ui(MarkydWindow *self) {
|
||||
markyd_editor_focus(self->editor);
|
||||
}
|
||||
|
||||
static void on_search_clicked(GtkButton *button, gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
|
||||
(void)button;
|
||||
if (!self || !self->search_revealer) {
|
||||
return;
|
||||
}
|
||||
if (gtk_revealer_get_reveal_child(GTK_REVEALER(self->search_revealer))) {
|
||||
hide_search_ui(self);
|
||||
} else {
|
||||
show_search_ui(self);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_search_changed(GtkEditable *editable, gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
(void)editable;
|
||||
@@ -624,6 +646,14 @@ static void on_editor_buffer_changed(GtkTextBuffer *buffer, gpointer user_data)
|
||||
update_search_matches(self);
|
||||
}
|
||||
|
||||
static void setup_file_drop_target(GtkWidget *widget, MarkydWindow *self) {
|
||||
GtkTargetEntry targets[] = {{"text/uri-list", 0, 0}};
|
||||
|
||||
gtk_drag_dest_set(widget, GTK_DEST_DEFAULT_ALL, targets, 1, GDK_ACTION_COPY);
|
||||
g_signal_connect(widget, "drag-data-received",
|
||||
G_CALLBACK(on_drag_data_received), self);
|
||||
}
|
||||
|
||||
MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
MarkydWindow *self = g_new0(MarkydWindow, 1);
|
||||
GtkWidget *left_buttons;
|
||||
@@ -633,6 +663,9 @@ MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
self->app = app;
|
||||
|
||||
self->window = gtk_application_window_new(app->gtk_app);
|
||||
g_object_add_weak_pointer(G_OBJECT(self->window), (gpointer *)&self->window);
|
||||
g_signal_connect(self->window, "destroy", G_CALLBACK(on_window_destroyed),
|
||||
self);
|
||||
gtk_window_set_title(GTK_WINDOW(self->window), "ViewMD");
|
||||
|
||||
gtk_window_set_default_size(GTK_WINDOW(self->window), config->window_width,
|
||||
@@ -660,6 +693,8 @@ MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
g_signal_connect(self->window, "window-state-event",
|
||||
G_CALLBACK(on_window_state_event), self);
|
||||
|
||||
setup_file_drop_target(self->window, self);
|
||||
|
||||
self->header_bar = gtk_header_bar_new();
|
||||
gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(self->header_bar), TRUE);
|
||||
gtk_window_set_titlebar(GTK_WINDOW(self->window), self->header_bar);
|
||||
@@ -671,15 +706,21 @@ MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
|
||||
self->btn_open =
|
||||
gtk_button_new_from_icon_name("document-open-symbolic", GTK_ICON_SIZE_BUTTON);
|
||||
gtk_widget_set_tooltip_text(self->btn_open, "Open Markdown Document");
|
||||
gtk_widget_set_tooltip_text(self->btn_open, "Open a markdown document (Ctrl+O)");
|
||||
g_signal_connect(self->btn_open, "clicked", G_CALLBACK(on_open_clicked), self);
|
||||
|
||||
self->btn_refresh =
|
||||
gtk_button_new_from_icon_name("view-refresh-symbolic", GTK_ICON_SIZE_BUTTON);
|
||||
gtk_widget_set_tooltip_text(self->btn_refresh, "Reload Current Document");
|
||||
gtk_widget_set_tooltip_text(self->btn_refresh, "Reload document (F5)");
|
||||
g_signal_connect(self->btn_refresh, "clicked", G_CALLBACK(on_refresh_clicked),
|
||||
self);
|
||||
|
||||
self->btn_search =
|
||||
gtk_button_new_from_icon_name("edit-find-symbolic", GTK_ICON_SIZE_BUTTON);
|
||||
gtk_widget_set_tooltip_text(self->btn_search, "Find in document (Ctrl+F)");
|
||||
g_signal_connect(self->btn_search, "clicked", G_CALLBACK(on_search_clicked),
|
||||
self);
|
||||
|
||||
self->btn_settings =
|
||||
gtk_button_new_from_icon_name("emblem-system-symbolic", GTK_ICON_SIZE_BUTTON);
|
||||
gtk_widget_set_tooltip_text(self->btn_settings, "Settings");
|
||||
@@ -689,6 +730,7 @@ MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
left_buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
gtk_box_pack_start(GTK_BOX(left_buttons), self->btn_open, FALSE, FALSE, 0);
|
||||
gtk_box_pack_start(GTK_BOX(left_buttons), self->btn_refresh, FALSE, FALSE, 0);
|
||||
gtk_box_pack_start(GTK_BOX(left_buttons), self->btn_search, FALSE, FALSE, 0);
|
||||
gtk_box_pack_start(GTK_BOX(left_buttons), self->btn_settings, FALSE, FALSE, 0);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(self->header_bar), left_buttons);
|
||||
|
||||
@@ -745,6 +787,19 @@ MarkydWindow *markyd_window_new(MarkydApp *app) {
|
||||
self->editor = markyd_editor_new(app);
|
||||
gtk_container_add(GTK_CONTAINER(self->scroll),
|
||||
markyd_editor_get_widget(self->editor));
|
||||
g_signal_connect(self->editor->text_view, "scroll-event",
|
||||
G_CALLBACK(on_text_view_scroll), self);
|
||||
|
||||
/* The text view registers its own drop target (text/plain), stealing file
|
||||
drops in the document area from the window; let the window handle them.
|
||||
gtk_drag_dest_unset warns unless the widget is currently a destination,
|
||||
so only unset what was set. */
|
||||
if (gtk_drag_dest_get_target_list(self->editor->text_view) != NULL) {
|
||||
gtk_drag_dest_unset(self->editor->text_view);
|
||||
}
|
||||
if (gtk_drag_dest_get_target_list(self->scroll) != NULL) {
|
||||
gtk_drag_dest_unset(self->scroll);
|
||||
}
|
||||
self->search_matches = g_array_new(FALSE, FALSE, sizeof(SearchMatch));
|
||||
self->search_current_index = -1;
|
||||
g_signal_connect(self->editor->buffer, "changed",
|
||||
@@ -891,6 +946,7 @@ void markyd_window_free(MarkydWindow *self) {
|
||||
|
||||
if (self->window) {
|
||||
gtk_widget_destroy(self->window);
|
||||
self->window = NULL;
|
||||
}
|
||||
|
||||
g_free(self);
|
||||
@@ -1000,6 +1056,9 @@ static void on_settings_clicked(GtkButton *button, gpointer user_data) {
|
||||
response = gtk_dialog_run(GTK_DIALOG(dialog));
|
||||
|
||||
if (response == GTK_RESPONSE_APPLY || response == GTK_RESPONSE_OK) {
|
||||
GtkWidget *toggle = g_object_get_data(G_OBJECT(dialog), AUTO_RELOAD_TOGGLE_DATA);
|
||||
config->auto_reload = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle));
|
||||
markyd_app_update_file_watch(self->app);
|
||||
config_save(config);
|
||||
markyd_window_apply_css(self);
|
||||
markdown_update_accent_tags(self->editor->buffer);
|
||||
@@ -1009,6 +1068,90 @@ static void on_settings_clicked(GtkButton *button, gpointer user_data) {
|
||||
gtk_widget_destroy(dialog);
|
||||
}
|
||||
|
||||
static gboolean on_text_view_scroll(GtkWidget *widget, GdkEventScroll *event,
|
||||
gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
gboolean zoom_in;
|
||||
gint new_size;
|
||||
|
||||
(void)widget;
|
||||
/* Font size is only adjusted on Ctrl+Scroll. */
|
||||
if (!self || !event || (event->state & GDK_CONTROL_MASK) == 0) {
|
||||
if (self) {
|
||||
/* Do not carry a partial smooth-scroll delta into the next gesture. */
|
||||
self->zoom_accumulator = 0.0;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (event->direction == GDK_SCROLL_SMOOTH) {
|
||||
/* Touchpads emit many small smooth deltas. Accumulate them, so the font
|
||||
steps once per unit of scroll distance instead of once per event. */
|
||||
self->zoom_accumulator += event->delta_y;
|
||||
if (self->zoom_accumulator <= -1.0) {
|
||||
zoom_in = TRUE;
|
||||
self->zoom_accumulator = 0.0;
|
||||
} else if (self->zoom_accumulator >= 1.0) {
|
||||
zoom_in = FALSE;
|
||||
self->zoom_accumulator = 0.0;
|
||||
} else {
|
||||
return TRUE;
|
||||
}
|
||||
} else if (event->direction == GDK_SCROLL_UP) {
|
||||
zoom_in = TRUE;
|
||||
self->zoom_accumulator = 0.0;
|
||||
} else if (event->direction == GDK_SCROLL_DOWN) {
|
||||
zoom_in = FALSE;
|
||||
self->zoom_accumulator = 0.0;
|
||||
} else {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
new_size = config->font_size + (zoom_in ? 1 : -1);
|
||||
if (new_size < 8) {
|
||||
new_size = 8;
|
||||
}
|
||||
if (new_size > 48) {
|
||||
new_size = 48;
|
||||
}
|
||||
|
||||
if (new_size != config->font_size) {
|
||||
config->font_size = new_size;
|
||||
markyd_window_apply_css(self);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void on_drag_data_received(GtkWidget *widget, GdkDragContext *context,
|
||||
gint x, gint y, GtkSelectionData *data,
|
||||
guint info, guint time,
|
||||
gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
gchar **uris;
|
||||
gchar **uri;
|
||||
|
||||
(void)widget;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)info;
|
||||
|
||||
uris = gtk_selection_data_get_uris(data);
|
||||
if (!uris) {
|
||||
gtk_drag_finish(context, FALSE, FALSE, time);
|
||||
return;
|
||||
}
|
||||
for (uri = uris; *uri != NULL; uri++) {
|
||||
gchar *path = g_filename_from_uri(*uri, NULL, NULL);
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
markyd_app_open_file(self->app, path);
|
||||
g_free(path);
|
||||
}
|
||||
g_strfreev(uris);
|
||||
gtk_drag_finish(context, TRUE, FALSE, time);
|
||||
}
|
||||
|
||||
static gboolean on_key_press_event(GtkWidget *widget, GdkEventKey *event,
|
||||
gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
@@ -1017,12 +1160,35 @@ static gboolean on_key_press_event(GtkWidget *widget, GdkEventKey *event,
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if ((event->state & GDK_CONTROL_MASK) != 0 &&
|
||||
(event->keyval == GDK_KEY_w || event->keyval == GDK_KEY_W)) {
|
||||
gtk_window_close(GTK_WINDOW(widget));
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if ((event->state & GDK_CONTROL_MASK) != 0 &&
|
||||
(event->keyval == GDK_KEY_q || event->keyval == GDK_KEY_Q)) {
|
||||
gtk_window_close(GTK_WINDOW(widget));
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if ((event->state & GDK_CONTROL_MASK) != 0 &&
|
||||
(event->keyval == GDK_KEY_o || event->keyval == GDK_KEY_O)) {
|
||||
on_open_clicked(NULL, self);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if ((event->state & GDK_CONTROL_MASK) != 0 &&
|
||||
(event->keyval == GDK_KEY_f || event->keyval == GDK_KEY_F)) {
|
||||
show_search_ui(self);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (event->keyval == GDK_KEY_F5) {
|
||||
on_refresh_clicked(NULL, self);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (event->keyval == GDK_KEY_Escape &&
|
||||
self->search_revealer &&
|
||||
gtk_revealer_get_reveal_child(GTK_REVEALER(self->search_revealer))) {
|
||||
@@ -1121,6 +1287,14 @@ static gboolean on_configure_event(GtkWidget *widget, GdkEventConfigure *event,
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_window_destroyed(GtkWidget *widget, gpointer user_data) {
|
||||
MarkydWindow *self = (MarkydWindow *)user_data;
|
||||
(void)widget;
|
||||
if (self) {
|
||||
self->window = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean on_window_state_event(GtkWidget *widget,
|
||||
GdkEventWindowState *event,
|
||||
gpointer user_data) {
|
||||
@@ -1181,6 +1355,7 @@ static GtkWidget *create_settings_dialog(MarkydApp *app) {
|
||||
GtkWidget *font_family_combo;
|
||||
GtkWidget *font_size_spin;
|
||||
GtkWidget *theme_combo;
|
||||
GtkWidget *auto_reload_toggle;
|
||||
GtkWidget *h1_color_btn;
|
||||
GtkWidget *h2_color_btn;
|
||||
GtkWidget *h3_color_btn;
|
||||
@@ -1269,6 +1444,13 @@ static GtkWidget *create_settings_dialog(MarkydApp *app) {
|
||||
G_CALLBACK(on_font_size_changed), NULL);
|
||||
gtk_grid_attach(GTK_GRID(grid), font_size_spin, 1, row++, 1, 1);
|
||||
|
||||
auto_reload_toggle = gtk_check_button_new_with_label(
|
||||
"Automatically reload changed files");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(auto_reload_toggle),
|
||||
config->auto_reload);
|
||||
gtk_grid_attach(GTK_GRID(grid), auto_reload_toggle, 0, row++, 2, 1);
|
||||
g_object_set_data(G_OBJECT(dialog), AUTO_RELOAD_TOGGLE_DATA, auto_reload_toggle);
|
||||
|
||||
{
|
||||
GtkWidget *separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_grid_attach(GTK_GRID(grid), separator, 0, row++, 2, 1);
|
||||
|
||||
@@ -11,6 +11,7 @@ typedef struct _MarkydWindow {
|
||||
GtkWidget *header_bar;
|
||||
GtkWidget *btn_open;
|
||||
GtkWidget *btn_refresh;
|
||||
GtkWidget *btn_search;
|
||||
GtkWidget *btn_settings;
|
||||
GtkWidget *search_revealer;
|
||||
GtkWidget *search_entry;
|
||||
@@ -23,6 +24,7 @@ typedef struct _MarkydWindow {
|
||||
MarkydApp *app;
|
||||
GArray *search_matches;
|
||||
gint search_current_index;
|
||||
gdouble zoom_accumulator;
|
||||
} MarkydWindow;
|
||||
|
||||
/* Lifecycle */
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
#include "app.h"
|
||||
#include "config.h"
|
||||
#include "editor.h"
|
||||
#include "window.h"
|
||||
#include <fcntl.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Exercise the real application and filesystem monitors without a display.
|
||||
* Only the view is replaced; rendering has its own tests. */
|
||||
static guint reload_count;
|
||||
MarkydWindow *markyd_window_new(MarkydApp *application) {
|
||||
(void)application;
|
||||
g_assert_not_reached();
|
||||
}
|
||||
void markyd_window_free(MarkydWindow *window) { (void)window; }
|
||||
void markyd_window_show(MarkydWindow *window) { (void)window; }
|
||||
void markyd_editor_set_content(MarkydEditor *editor, const gchar *content) {
|
||||
g_free(editor->source_content);
|
||||
editor->source_content = g_utf8_make_valid(content, -1);
|
||||
}
|
||||
void markyd_editor_reload_content(MarkydEditor *editor, const gchar *content) {
|
||||
reload_count++;
|
||||
markyd_editor_set_content(editor, content);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
MarkydApp *application;
|
||||
MarkydEditor *editor;
|
||||
gchar *directory;
|
||||
gchar *path;
|
||||
} Fixture;
|
||||
|
||||
static void pump(guint milliseconds) {
|
||||
gint64 until = g_get_monotonic_time() + milliseconds * 1000;
|
||||
do {
|
||||
while (g_main_context_iteration(NULL, FALSE)) {}
|
||||
g_usleep(1000);
|
||||
} while (g_get_monotonic_time() < until);
|
||||
}
|
||||
|
||||
static void wait_for_content(Fixture *fixture, const gchar *expected) {
|
||||
gint64 until = g_get_monotonic_time() + 2500000;
|
||||
while (g_strcmp0(fixture->editor->source_content, expected) != 0 &&
|
||||
g_get_monotonic_time() < until) {
|
||||
pump(5);
|
||||
}
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, expected);
|
||||
}
|
||||
|
||||
static void write_file(const gchar *path, const gchar *content) {
|
||||
GError *error = NULL;
|
||||
g_assert_true(g_file_set_contents(path, content, -1, &error));
|
||||
g_assert_no_error(error);
|
||||
}
|
||||
|
||||
static void write_in_place(const gchar *path, const gchar *content) {
|
||||
gint fd = g_open(path, O_WRONLY | O_TRUNC, 0600);
|
||||
g_assert_cmpint(fd, >=, 0);
|
||||
g_assert_cmpint(write(fd, content, strlen(content)), ==, (gssize)strlen(content));
|
||||
g_assert_cmpint(close(fd), ==, 0);
|
||||
}
|
||||
|
||||
static void setup(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
g_remove(config_get_path());
|
||||
fixture->application = markyd_app_new();
|
||||
fixture->editor = g_new0(MarkydEditor, 1);
|
||||
fixture->application->editor = fixture->editor;
|
||||
fixture->directory = g_dir_make_tmp("viewmd-watch-test-XXXXXX", NULL);
|
||||
g_assert_nonnull(fixture->directory);
|
||||
fixture->path = g_build_filename(fixture->directory, "document.md", NULL);
|
||||
write_file(fixture->path, "Original");
|
||||
reload_count = 0;
|
||||
}
|
||||
|
||||
static void teardown(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
if (fixture->application)
|
||||
markyd_app_free(fixture->application);
|
||||
g_free(fixture->editor->source_content);
|
||||
g_free(fixture->editor);
|
||||
GDir *directory = g_dir_open(fixture->directory, 0, NULL);
|
||||
const gchar *name;
|
||||
while ((name = g_dir_read_name(directory))) {
|
||||
gchar *path = g_build_filename(fixture->directory, name, NULL);
|
||||
g_assert_cmpint(g_remove(path), ==, 0);
|
||||
g_free(path);
|
||||
}
|
||||
g_dir_close(directory);
|
||||
g_assert_cmpint(g_rmdir(fixture->directory), ==, 0);
|
||||
g_free(fixture->directory);
|
||||
g_free(fixture->path);
|
||||
}
|
||||
|
||||
static void open_document(Fixture *fixture) {
|
||||
g_assert_true(markyd_app_open_file(fixture->application, fixture->path));
|
||||
g_assert_nonnull(fixture->application->file_monitor);
|
||||
}
|
||||
|
||||
static void test_preference(Fixture *fixture, gconstpointer unused) {
|
||||
(void)fixture; (void)unused;
|
||||
g_assert_true(config->auto_reload);
|
||||
config->auto_reload = FALSE;
|
||||
g_assert_true(config_save(config));
|
||||
MarkydConfig *loaded = config_new();
|
||||
g_assert_true(config_load(loaded));
|
||||
g_assert_false(loaded->auto_reload);
|
||||
config_free(loaded);
|
||||
config->auto_reload = TRUE;
|
||||
g_assert_true(config_save(config));
|
||||
loaded = config_new();
|
||||
g_assert_true(config_load(loaded));
|
||||
g_assert_true(loaded->auto_reload);
|
||||
config_free(loaded);
|
||||
|
||||
write_file(config_get_path(), "[Appearance]\ntheme=light\n");
|
||||
loaded = config_new();
|
||||
g_assert_true(config_load(loaded));
|
||||
g_assert_true(loaded->auto_reload);
|
||||
config_free(loaded);
|
||||
write_file(config_get_path(), "[Editor]\nauto_reload=invalid\n");
|
||||
loaded = config_new();
|
||||
g_assert_true(config_load(loaded));
|
||||
g_assert_true(loaded->auto_reload);
|
||||
config_free(loaded);
|
||||
}
|
||||
|
||||
static void test_in_place_saves(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
write_in_place(fixture->path, "First");
|
||||
write_in_place(fixture->path, "Second");
|
||||
write_in_place(fixture->path, "Final");
|
||||
wait_for_content(fixture, "Final");
|
||||
g_assert_cmpuint(reload_count, ==, 1);
|
||||
write_in_place(fixture->path, "Next save");
|
||||
wait_for_content(fixture, "Next save");
|
||||
}
|
||||
|
||||
static void replace_file(Fixture *fixture, const gchar *content) {
|
||||
gchar *temporary = g_build_filename(fixture->directory, ".save.tmp", NULL);
|
||||
write_file(temporary, content);
|
||||
g_assert_cmpint(g_rename(temporary, fixture->path), ==, 0);
|
||||
g_free(temporary);
|
||||
}
|
||||
|
||||
static void test_atomic_saves(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
replace_file(fixture, "Replacement");
|
||||
wait_for_content(fixture, "Replacement");
|
||||
replace_file(fixture, "Another replacement");
|
||||
wait_for_content(fixture, "Another replacement");
|
||||
}
|
||||
|
||||
static void test_delete_recreate(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
g_assert_cmpint(g_remove(fixture->path), ==, 0);
|
||||
pump(350);
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, "Original");
|
||||
write_file(fixture->path, "Recreated");
|
||||
wait_for_content(fixture, "Recreated");
|
||||
write_in_place(fixture->path, "");
|
||||
wait_for_content(fixture, "");
|
||||
}
|
||||
|
||||
static void test_switch_files(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
write_in_place(fixture->path, "Old pending change");
|
||||
pump(20);
|
||||
gchar *other = g_build_filename(fixture->directory, "other.md", NULL);
|
||||
write_file(other, "Other document");
|
||||
g_assert_true(markyd_app_open_file(fixture->application, other));
|
||||
write_in_place(fixture->path, "Old later change");
|
||||
pump(350);
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, "Other document");
|
||||
g_assert_cmpuint(reload_count, ==, 0);
|
||||
write_in_place(other, "New document changed");
|
||||
wait_for_content(fixture, "New document changed");
|
||||
g_free(other);
|
||||
}
|
||||
|
||||
static void test_disable_reenable(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
config->auto_reload = FALSE;
|
||||
markyd_app_update_file_watch(fixture->application);
|
||||
g_assert_null(fixture->application->file_monitor);
|
||||
g_assert_cmpuint(fixture->application->watch_reload_id, ==, 0);
|
||||
write_in_place(fixture->path, "Changed while disabled");
|
||||
pump(350);
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, "Original");
|
||||
config->auto_reload = TRUE;
|
||||
markyd_app_update_file_watch(fixture->application);
|
||||
wait_for_content(fixture, "Changed while disabled");
|
||||
write_in_place(fixture->path, "Pending when disabled");
|
||||
pump(20);
|
||||
config->auto_reload = FALSE;
|
||||
markyd_app_update_file_watch(fixture->application);
|
||||
pump(350);
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, "Changed while disabled");
|
||||
g_assert_true(markyd_app_open_file(fixture->application, fixture->path));
|
||||
g_assert_cmpstr(fixture->editor->source_content, ==, "Pending when disabled");
|
||||
g_assert_null(fixture->application->file_monitor);
|
||||
}
|
||||
|
||||
static void test_ignore_unchanged(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
gchar *other = g_build_filename(fixture->directory, "unrelated.md", NULL);
|
||||
write_file(other, "Unrelated");
|
||||
write_in_place(fixture->path, "Original");
|
||||
pump(350);
|
||||
g_assert_cmpuint(reload_count, ==, 0);
|
||||
g_free(other);
|
||||
}
|
||||
|
||||
static void test_manual_reload(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
GFileMonitor *monitor = g_object_ref(fixture->application->file_monitor);
|
||||
const gchar *path = markyd_app_get_current_path(fixture->application);
|
||||
g_assert_true(markyd_app_open_file(fixture->application, path));
|
||||
g_assert_cmpstr(fixture->application->current_file_path, ==, fixture->path);
|
||||
g_assert_true(fixture->application->file_monitor == monitor);
|
||||
gchar *missing = g_build_filename(fixture->directory, "missing.md", NULL);
|
||||
g_assert_false(markyd_app_open_file(fixture->application, missing));
|
||||
g_assert_cmpstr(fixture->application->current_file_path, ==, fixture->path);
|
||||
g_assert_true(fixture->application->file_monitor == monitor);
|
||||
write_in_place(fixture->path, "Still watched");
|
||||
wait_for_content(fixture, "Still watched");
|
||||
g_free(missing);
|
||||
g_object_unref(monitor);
|
||||
}
|
||||
|
||||
static void test_cleanup_pending(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
open_document(fixture);
|
||||
write_in_place(fixture->path, "Pending");
|
||||
pump(20);
|
||||
markyd_app_free(fixture->application);
|
||||
fixture->application = NULL;
|
||||
pump(350);
|
||||
g_assert_cmpuint(reload_count, ==, 0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
gchar *config_directory = g_dir_make_tmp("viewmd-watch-config-XXXXXX", NULL);
|
||||
g_assert_nonnull(config_directory);
|
||||
g_setenv("XDG_CONFIG_HOME", config_directory, TRUE);
|
||||
g_test_init(&argc, &argv, NULL);
|
||||
#define ADD_TEST(name, function) \
|
||||
g_test_add("/watch/" name, Fixture, NULL, setup, function, teardown)
|
||||
ADD_TEST("preference", test_preference);
|
||||
ADD_TEST("in-place-saves", test_in_place_saves);
|
||||
ADD_TEST("atomic-saves", test_atomic_saves);
|
||||
ADD_TEST("delete-recreate", test_delete_recreate);
|
||||
ADD_TEST("switch-files", test_switch_files);
|
||||
ADD_TEST("disable-reenable", test_disable_reenable);
|
||||
ADD_TEST("ignore-unchanged", test_ignore_unchanged);
|
||||
ADD_TEST("manual-reload", test_manual_reload);
|
||||
ADD_TEST("cleanup-pending", test_cleanup_pending);
|
||||
gint result = g_test_run();
|
||||
g_remove(config_get_path());
|
||||
gchar *app_directory = g_path_get_dirname(config_get_path());
|
||||
g_rmdir(app_directory);
|
||||
g_free(app_directory);
|
||||
g_rmdir(config_directory);
|
||||
g_free(config_directory);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
#include "config.h"
|
||||
#include "markdown.h"
|
||||
#include <string.h>
|
||||
|
||||
static GtkTextBuffer *new_buffer(void) {
|
||||
GtkTextBuffer *buffer = gtk_text_buffer_new(NULL);
|
||||
markdown_init_tags(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static gchar *buffer_text(GtkTextBuffer *buffer) {
|
||||
GtkTextIter start, end;
|
||||
gtk_text_buffer_get_bounds(buffer, &start, &end);
|
||||
return gtk_text_buffer_get_text(buffer, &start, &end, FALSE);
|
||||
}
|
||||
|
||||
static void assert_text(GtkTextBuffer *buffer, const gchar *expected) {
|
||||
gchar *text = buffer_text(buffer);
|
||||
g_assert_cmpstr(text, ==, expected);
|
||||
g_free(text);
|
||||
}
|
||||
|
||||
static void test_reload_link_tags(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
GtkTextTagTable *table = gtk_text_buffer_get_tag_table(buffer);
|
||||
gint baseline = gtk_text_tag_table_get_size(table);
|
||||
|
||||
for (guint i = 0; i < 50; i++) {
|
||||
markdown_apply_tags(buffer, "[One](https://one.example) [Two](#two)");
|
||||
g_assert_cmpint(gtk_text_tag_table_get_size(table), ==, baseline + 2);
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
GSList *tags = gtk_text_iter_get_tags(&at);
|
||||
gboolean found_url = FALSE;
|
||||
for (GSList *item = tags; item; item = item->next) {
|
||||
const gchar *url = g_object_get_data(G_OBJECT(item->data),
|
||||
VIEWMD_LINK_URL_DATA);
|
||||
if (g_strcmp0(url, "https://one.example") == 0) {
|
||||
found_url = TRUE;
|
||||
}
|
||||
}
|
||||
g_assert_true(found_url);
|
||||
g_slist_free(tags);
|
||||
}
|
||||
|
||||
markdown_apply_tags(buffer, "No links");
|
||||
g_assert_cmpint(gtk_text_tag_table_get_size(table), ==, baseline);
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_reload_heading_anchors(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "# Removed\n\n# Kept\n\n# Kept\n");
|
||||
g_assert_nonnull(gtk_text_buffer_get_mark(buffer, "viewmd-anchor-kept-1"));
|
||||
|
||||
for (guint i = 0; i < 3; i++) {
|
||||
markdown_apply_tags(buffer, "Intro\n\n# Kept\n");
|
||||
g_assert_null(gtk_text_buffer_get_mark(buffer, "viewmd-anchor-removed"));
|
||||
g_assert_null(gtk_text_buffer_get_mark(buffer, "viewmd-anchor-kept-1"));
|
||||
GtkTextMark *mark = gtk_text_buffer_get_mark(buffer, "viewmd-anchor-kept");
|
||||
g_assert_nonnull(mark);
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_iter_at_mark(buffer, &at, mark);
|
||||
g_assert_cmpint(gtk_text_iter_get_offset(&at), ==, 7);
|
||||
}
|
||||
|
||||
markdown_apply_tags(buffer, NULL);
|
||||
g_assert_null(gtk_text_buffer_get_mark(buffer, "viewmd-anchor-kept"));
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_reload_preserves_other_state(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
GtkTextTag *custom = gtk_text_buffer_create_tag(buffer, "custom", NULL);
|
||||
GtkTextTag *anonymous = gtk_text_buffer_create_tag(buffer, NULL, NULL);
|
||||
g_object_ref(anonymous);
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
GtkTextMark *bookmark = gtk_text_buffer_create_mark(buffer, "bookmark", &at, TRUE);
|
||||
|
||||
markdown_apply_tags(buffer, "# Heading\n\n[link](https://example.com)");
|
||||
markdown_apply_tags(buffer, "# Heading\n\n[link](https://other.example)");
|
||||
GtkTextTagTable *table = gtk_text_buffer_get_tag_table(buffer);
|
||||
g_assert_true(gtk_text_tag_table_lookup(table, "custom") == custom);
|
||||
g_assert_true(gtk_text_buffer_get_mark(buffer, "bookmark") == bookmark);
|
||||
GtkTextIter end;
|
||||
gtk_text_buffer_get_bounds(buffer, &at, &end);
|
||||
gtk_text_buffer_apply_tag(buffer, anonymous, &at, &end);
|
||||
g_assert_true(gtk_text_iter_has_tag(&at, anonymous));
|
||||
g_object_unref(anonymous);
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_reload_buffers_independent(void) {
|
||||
GtkTextBuffer *first = new_buffer();
|
||||
GtkTextBuffer *second = new_buffer();
|
||||
markdown_apply_tags(first, "# First\n\n[link](#first)");
|
||||
markdown_apply_tags(second, "# Second\n\n[link](#second)");
|
||||
gint second_tags = gtk_text_tag_table_get_size(gtk_text_buffer_get_tag_table(second));
|
||||
markdown_apply_tags(first, "");
|
||||
g_object_unref(first);
|
||||
g_assert_nonnull(gtk_text_buffer_get_mark(second, "viewmd-anchor-second"));
|
||||
g_assert_cmpint(gtk_text_tag_table_get_size(gtk_text_buffer_get_tag_table(second)),
|
||||
==, second_tags);
|
||||
g_object_unref(second);
|
||||
}
|
||||
|
||||
static void test_task_statuses(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "- [x] Done\n- [ ] Open\n- [X] Also done\n- Plain\n");
|
||||
assert_text(buffer, "☑ Done\n☐ Open\n☑ Also done\n• Plain\n");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_nested_tasks(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "- [ ] Parent\n - [x] Child\n - Plain child\n- [x] Last\n");
|
||||
assert_text(buffer, "☐ Parent\n☑ Child\n• Plain child\n☑ Last\n");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_ordered_tasks(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "9. [ ] Open\n10. Plain\n11. [X] Done\n12. Last\n");
|
||||
assert_text(buffer, "☐ Open\n10. Plain\n☑ Done\n12. Last\n");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_task_hanging_indent(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "- [ ] Task\n continuation\n");
|
||||
assert_text(buffer, "☐ Task\ncontinuation\n");
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
GSList *tags = gtk_text_iter_get_tags(&at);
|
||||
gboolean found_indent = FALSE;
|
||||
for (GSList *item = tags; item; item = item->next) {
|
||||
gint indent = 0;
|
||||
g_object_get(item->data, "indent", &indent, NULL);
|
||||
if (indent < 0) {
|
||||
found_indent = TRUE;
|
||||
gtk_text_buffer_get_iter_at_offset(buffer, &at, 7);
|
||||
g_assert_true(gtk_text_iter_has_tag(&at, item->data));
|
||||
}
|
||||
}
|
||||
g_assert_true(found_indent);
|
||||
g_slist_free(tags);
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entity_names(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer,
|
||||
"© — café A B & < > " ' "
|
||||
"≂̸ 𝔄 ∳");
|
||||
assert_text(buffer, "© — café A\302\240B & < > \" ' ≂̸ 𝔄 ∳");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entity_numbers(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "© — 😀 � � "
|
||||
"� � �");
|
||||
assert_text(buffer, "© — 😀 � � � � �");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entity_literals(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
const gchar *text = "&MadeUpEntity; ©wrong; © &#; &#x; &#xGG; "
|
||||
"� �";
|
||||
markdown_apply_tags(buffer, text);
|
||||
assert_text(buffer, text);
|
||||
markdown_apply_tags(buffer, "&copy; &copy; \\© *literal*");
|
||||
assert_text(buffer, "© © © *literal*");
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_iter_at_offset(buffer, &at, 23);
|
||||
g_assert_false(gtk_text_iter_has_tag(&at, gtk_text_tag_table_lookup(
|
||||
gtk_text_buffer_get_tag_table(buffer), "italic")));
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entities_in_code(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "`© ©`\n\n```\n& —\n```\n");
|
||||
assert_text(buffer, "© ©\n\n& —\n");
|
||||
markdown_apply_tags(buffer, " © ©\n");
|
||||
assert_text(buffer, "© ©\n");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entity_attributes(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "[A & B](https://example.com/?a=1&b=2#part)");
|
||||
assert_text(buffer, "A & B");
|
||||
GtkTextIter at;
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
GSList *tags = gtk_text_iter_get_tags(&at);
|
||||
gboolean found_url = FALSE;
|
||||
for (GSList *item = tags; item; item = item->next) {
|
||||
const gchar *url = g_object_get_data(G_OBJECT(item->data), VIEWMD_LINK_URL_DATA);
|
||||
if (url) {
|
||||
g_assert_cmpstr(url, ==, "https://example.com/?a=1&b=2#part");
|
||||
found_url = TRUE;
|
||||
}
|
||||
}
|
||||
g_assert_true(found_url);
|
||||
g_slist_free(tags);
|
||||
|
||||
markdown_apply_tags(buffer, "");
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
GtkTextChildAnchor *anchor = gtk_text_iter_get_child_anchor(&at);
|
||||
g_assert_nonnull(anchor);
|
||||
g_assert_cmpstr(g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_SRC_DATA),
|
||||
==, "images/a&b.png");
|
||||
g_assert_cmpstr(g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_ALT_DATA),
|
||||
==, "A & B");
|
||||
|
||||
markdown_apply_tags(buffer, "");
|
||||
gtk_text_buffer_get_start_iter(buffer, &at);
|
||||
anchor = gtk_text_iter_get_child_anchor(&at);
|
||||
g_assert_nonnull(anchor);
|
||||
g_assert_cmpstr(g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_SRC_DATA),
|
||||
==, "images/a&b.png");
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
static void test_entities_in_headings_and_tables(void) {
|
||||
GtkTextBuffer *buffer = new_buffer();
|
||||
markdown_apply_tags(buffer, "# Tom & Jerry\n\n| Header |\n| --- |\n"
|
||||
"| © & <b> ≂̸ |\n");
|
||||
g_assert_nonnull(gtk_text_buffer_get_mark(buffer, "viewmd-anchor-tom-jerry"));
|
||||
GtkTextIter start, end;
|
||||
gtk_text_buffer_get_bounds(buffer, &start, &end);
|
||||
gchar *text = gtk_text_buffer_get_text(buffer, &start, &end, TRUE);
|
||||
g_assert_nonnull(strstr(text, "© & <b> ≂̸"));
|
||||
g_free(text);
|
||||
g_object_unref(buffer);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
g_test_init(&argc, &argv, NULL);
|
||||
gtk_init_check(&argc, &argv);
|
||||
/* GtkTextBuffer works without a display. Register the boxed color types
|
||||
* normally registered by a successful GTK display initialization. */
|
||||
G_GNUC_BEGIN_IGNORE_DEPRECATIONS
|
||||
g_type_ensure(GDK_TYPE_COLOR);
|
||||
G_GNUC_END_IGNORE_DEPRECATIONS
|
||||
g_type_ensure(GDK_TYPE_RGBA);
|
||||
config = config_new();
|
||||
|
||||
g_test_add_func("/markdown/reload/link-tags", test_reload_link_tags);
|
||||
g_test_add_func("/markdown/reload/heading-anchors", test_reload_heading_anchors);
|
||||
g_test_add_func("/markdown/reload/preserve-other-state", test_reload_preserves_other_state);
|
||||
g_test_add_func("/markdown/reload/independent-buffers", test_reload_buffers_independent);
|
||||
g_test_add_func("/markdown/tasks/statuses", test_task_statuses);
|
||||
g_test_add_func("/markdown/tasks/nested", test_nested_tasks);
|
||||
g_test_add_func("/markdown/tasks/ordered", test_ordered_tasks);
|
||||
g_test_add_func("/markdown/tasks/hanging-indent", test_task_hanging_indent);
|
||||
g_test_add_func("/markdown/entities/names", test_entity_names);
|
||||
g_test_add_func("/markdown/entities/numbers", test_entity_numbers);
|
||||
g_test_add_func("/markdown/entities/literals", test_entity_literals);
|
||||
g_test_add_func("/markdown/entities/code", test_entities_in_code);
|
||||
g_test_add_func("/markdown/entities/attributes", test_entity_attributes);
|
||||
g_test_add_func("/markdown/entities/headings-and-tables", test_entities_in_headings_and_tables);
|
||||
gint result = g_test_run();
|
||||
config_free(config);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "app.h"
|
||||
#include "config.h"
|
||||
#include "editor.h"
|
||||
#include "window.h"
|
||||
#include <glib/gstdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
MarkydApp *application;
|
||||
gchar *directory;
|
||||
gchar *path;
|
||||
} Fixture;
|
||||
|
||||
static GtkApplication *test_application;
|
||||
|
||||
static void pump(guint milliseconds) {
|
||||
gint64 until = g_get_monotonic_time() + milliseconds * 1000;
|
||||
do {
|
||||
while (g_main_context_iteration(NULL, FALSE)) {}
|
||||
g_usleep(1000);
|
||||
} while (g_get_monotonic_time() < until);
|
||||
}
|
||||
|
||||
static void setup(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
g_remove(config_get_path());
|
||||
fixture->application = markyd_app_new();
|
||||
/* GTK expects one registered application per process. Share it across
|
||||
* fixtures, while each fixture owns fresh ViewMD state and a fresh window. */
|
||||
g_object_unref(fixture->application->gtk_app);
|
||||
fixture->application->gtk_app = g_object_ref(test_application);
|
||||
fixture->application->window = markyd_window_new(fixture->application);
|
||||
fixture->application->editor = fixture->application->window->editor;
|
||||
fixture->directory = g_dir_make_tmp("viewmd-watch-ui-XXXXXX", NULL);
|
||||
fixture->path = g_build_filename(fixture->directory, "document.md", NULL);
|
||||
GString *content = g_string_new(NULL);
|
||||
for (guint i = 0; i < 200; i++) {
|
||||
g_string_append_printf(content, "Paragraph %u: some text to read.\n\n", i);
|
||||
}
|
||||
g_assert_true(g_file_set_contents(fixture->path, content->str, -1, NULL));
|
||||
g_string_free(content, TRUE);
|
||||
g_assert_true(markyd_app_open_file(fixture->application, fixture->path));
|
||||
pump(250);
|
||||
}
|
||||
|
||||
static void teardown(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
if (fixture->application) {
|
||||
gtk_widget_destroy(fixture->application->window->window);
|
||||
markyd_app_free(fixture->application);
|
||||
}
|
||||
g_remove(fixture->path);
|
||||
g_rmdir(fixture->directory);
|
||||
g_free(fixture->path);
|
||||
g_free(fixture->directory);
|
||||
}
|
||||
|
||||
static void test_scroll_position(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
MarkydEditor *editor = fixture->application->editor;
|
||||
GtkAdjustment *vadj = gtk_scrollable_get_vadjustment(GTK_SCROLLABLE(editor->text_view));
|
||||
g_assert_cmpfloat(gtk_adjustment_get_upper(vadj), >,
|
||||
gtk_adjustment_get_page_size(vadj) + 400);
|
||||
gtk_adjustment_set_value(vadj, 400);
|
||||
pump(50);
|
||||
gdouble position = gtk_adjustment_get_value(vadj);
|
||||
gchar *changed = g_strconcat(editor->source_content, "A new paragraph.\n", NULL);
|
||||
g_assert_true(g_file_set_contents(fixture->path, changed, -1, NULL));
|
||||
pump(450);
|
||||
g_assert_cmpstr(editor->source_content, ==, changed);
|
||||
g_assert_cmpfloat_with_epsilon(gtk_adjustment_get_value(vadj), position, 2.0);
|
||||
g_assert_cmpuint(editor->scroll_restore_id, ==, 0);
|
||||
g_free(changed);
|
||||
}
|
||||
|
||||
static GtkWidget *find_toggle(GtkWidget *widget) {
|
||||
if (GTK_IS_CHECK_BUTTON(widget) &&
|
||||
g_strcmp0(gtk_button_get_label(GTK_BUTTON(widget)),
|
||||
"Automatically reload changed files") == 0) {
|
||||
return widget;
|
||||
}
|
||||
if (!GTK_IS_CONTAINER(widget))
|
||||
return NULL;
|
||||
GList *children = gtk_container_get_children(GTK_CONTAINER(widget));
|
||||
GtkWidget *result = NULL;
|
||||
for (GList *item = children; item && !result; item = item->next)
|
||||
result = find_toggle(item->data);
|
||||
g_list_free(children);
|
||||
return result;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
gboolean enabled;
|
||||
gint response;
|
||||
} DialogResponse;
|
||||
|
||||
static gboolean respond_to_settings(gpointer data) {
|
||||
DialogResponse *response = data;
|
||||
GList *windows = gtk_window_list_toplevels();
|
||||
gboolean found = FALSE;
|
||||
for (GList *item = windows; item; item = item->next) {
|
||||
if (GTK_IS_DIALOG(item->data) &&
|
||||
g_strcmp0(gtk_window_get_title(item->data), "ViewMD Settings") == 0) {
|
||||
GtkWidget *toggle = find_toggle(item->data);
|
||||
g_assert_nonnull(toggle);
|
||||
gboolean previous = config->auto_reload;
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(toggle), response->enabled);
|
||||
g_assert_cmpint(config->auto_reload, ==, previous);
|
||||
gtk_dialog_response(GTK_DIALOG(item->data), response->response);
|
||||
found = TRUE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_list_free(windows);
|
||||
g_assert_true(found);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void change_setting(Fixture *fixture, gboolean enabled, gint response) {
|
||||
DialogResponse action = {enabled, response};
|
||||
g_timeout_add(50, respond_to_settings, &action);
|
||||
g_signal_emit_by_name(fixture->application->window->btn_settings, "clicked");
|
||||
}
|
||||
|
||||
static void test_settings(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
change_setting(fixture, FALSE, GTK_RESPONSE_CANCEL);
|
||||
g_assert_true(config->auto_reload);
|
||||
g_assert_nonnull(fixture->application->file_monitor);
|
||||
change_setting(fixture, FALSE, GTK_RESPONSE_APPLY);
|
||||
g_assert_false(config->auto_reload);
|
||||
g_assert_null(fixture->application->file_monitor);
|
||||
MarkydConfig *saved = config_new();
|
||||
g_assert_true(config_load(saved));
|
||||
g_assert_false(saved->auto_reload);
|
||||
config_free(saved);
|
||||
change_setting(fixture, TRUE, GTK_RESPONSE_APPLY);
|
||||
g_assert_true(config->auto_reload);
|
||||
g_assert_nonnull(fixture->application->file_monitor);
|
||||
}
|
||||
|
||||
static void test_pending_restore_cleanup(Fixture *fixture, gconstpointer unused) {
|
||||
(void)unused;
|
||||
MarkydEditor *editor = fixture->application->editor;
|
||||
gchar *changed = g_strconcat(editor->source_content, "Changed", NULL);
|
||||
markyd_editor_reload_content(editor, changed);
|
||||
g_free(changed);
|
||||
while (editor->markdown_idle_id)
|
||||
g_main_context_iteration(NULL, FALSE);
|
||||
g_assert_cmpuint(editor->scroll_restore_id, !=, 0);
|
||||
gtk_widget_destroy(fixture->application->window->window);
|
||||
markyd_app_free(fixture->application);
|
||||
fixture->application = NULL;
|
||||
pump(200);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
gchar *config_directory = g_dir_make_tmp("viewmd-watch-ui-config-XXXXXX", NULL);
|
||||
g_setenv("XDG_CONFIG_HOME", config_directory, TRUE);
|
||||
gtk_init(&argc, &argv);
|
||||
g_test_init(&argc, &argv, NULL);
|
||||
test_application = gtk_application_new("org.viewmd.watchtests", G_APPLICATION_NON_UNIQUE);
|
||||
GError *error = NULL;
|
||||
g_assert_true(g_application_register(G_APPLICATION(test_application), NULL, &error));
|
||||
g_assert_no_error(error);
|
||||
#define ADD_TEST(name, function) \
|
||||
g_test_add("/watch-ui/" name, Fixture, NULL, setup, function, teardown)
|
||||
ADD_TEST("scroll-position", test_scroll_position);
|
||||
ADD_TEST("settings-apply-cancel", test_settings);
|
||||
ADD_TEST("pending-restore-cleanup", test_pending_restore_cleanup);
|
||||
gint result = g_test_run();
|
||||
g_object_unref(test_application);
|
||||
g_remove(config_get_path());
|
||||
gchar *app_directory = g_path_get_dirname(config_get_path());
|
||||
g_rmdir(app_directory);
|
||||
g_free(app_directory);
|
||||
g_rmdir(config_directory);
|
||||
g_free(config_directory);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate src/html_entities.h using Python's HTML5 character reference data."""
|
||||
|
||||
from html.entities import html5
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
lines = [
|
||||
"/* Generated by tools/generate_html_entities.py; do not edit manually.",
|
||||
" * Source: Python html.entities.html5, based on the HTML5 entity table:",
|
||||
" * https://html.spec.whatwg.org/entities.json",
|
||||
" * Only semicolon-terminated names are valid in CommonMark.",
|
||||
" */",
|
||||
"#ifndef VIEWMD_HTML_ENTITIES_H",
|
||||
"#define VIEWMD_HTML_ENTITIES_H",
|
||||
"",
|
||||
"typedef struct {",
|
||||
" const gchar *name;",
|
||||
" const gchar *utf8;",
|
||||
"} HtmlEntity;",
|
||||
"",
|
||||
"/* Sorted by name for binary search; values may contain two code points. */",
|
||||
"static const HtmlEntity html_entities[] = {",
|
||||
]
|
||||
for name, value in sorted(html5.items()):
|
||||
if name.endswith(";"):
|
||||
utf8 = "".join(f"\\x{byte:02x}" for byte in value.encode("utf-8"))
|
||||
lines.append(f' {{"&{name}", "{utf8}"}},')
|
||||
lines.extend(["};", "", "#endif /* VIEWMD_HTML_ENTITIES_H */", ""])
|
||||
target = Path(__file__).resolve().parents[1] / "src" / "html_entities.h"
|
||||
target.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user