23 Commits
Author SHA1 Message Date
Robert Fulton fef00eebf5 remove extraneous file 2026-10-30 22:58:28 +00:00
Robert Fulton 14596044e0 add file watching functionality to update view on external edits 2026-10-30 22:56:04 +00:00
Robert Fulton 2fe8624b64 add file watching functionality to update view on external edits 2026-10-30 22:54:54 +00:00
rabfulton 02da8ed087 Merge pull request #20 from mxmlnkn/shortcuts
Add some common shortcuts
2026-10-30 22:28:19 +00:00
mxmlnkn aa31530f22 feature: Open files dropped onto the window
Dropping a markdown file onto the window opens it in the viewer; dropping
the current document reloads it.

GtkTextView registers its own text/plain drop target, which steals file
drops in the document area from the window. Unset it (and the scrolled
window's, if any) so the window handles the drop, and mark the text view as
a destination again on destroy so its dispose does not warn about setting a
target list on a non-destination.
2026-10-30 16:30:38 +01:00
mxmlnkn 2a5a438c32 feature: Add a toolbar button to toggle the search bar
The header bar gets a find button (Ctrl+F tooltip) that shows or hides the
existing search widget.
2026-10-30 16:30:38 +01:00
mxmlnkn 12c53c15a1 feature: Ctrl+mouse-wheel zooms the document font size
Ctrl+wheel over the text view steps the font size between 8 and 48 and
re-applies the theme CSS so the change takes effect immediately.
2026-10-30 16:30:38 +01:00
mxmlnkn d0dc69fda0 feature: Add Ctrl+O,Q,W and F5 shortcuts
- Ctrl+O opens the file chooser and Ctrl+Q/W closes the window.
   In the future, Ctrl+W should close a tab and Ctrl+Q the window.
 - F5 reloads the current document.
2026-10-30 16:25:02 +01:00
mxmlnkn 81e9cee18e build: Add gitignore 2026-10-30 16:25:02 +01:00
mxmlnkn 81dac54a2a fix: Avoid touching destroyed widgets during window teardown
GTK destroys the application window and its children before the app's
window and editor state is freed, so cached widget pointers can dangle and
the pending render idle can run after the text view is gone. Clear the
window pointer on destroy, track the text view with a weak pointer, cancel
its pending markdown idle, and skip a render once the text view or buffer is
gone.

This removes the gtk_widget_destroy critical on quit:

> (viewmd:3577241): Gtk-CRITICAL **: 10:16:57.172:
> gtk_widget_destroy: assertion 'GTK_IS_WIDGET (widget)' failed
2026-10-30 16:25:02 +01:00
mxmlnkn 126616f585 fix: Sanitize non-UTF-8 file content to avoid Pango crashes
Load markdown through g_utf8_make_valid. A latin-1 or otherwise
non-UTF-8 file previously flowed into the buffer and could crash
Pango during layout.
2026-10-30 10:33:44 +01:00
Robert Fulton 703399976f Add support for html literals 2026-10-29 21:41:53 +00:00
Robert Fulton c6f5b8f369 Add support for tasklists and fix link tags leaking on reload 2026-10-29 21:36:07 +00:00
rabfulton d38bace1d5 Merge pull request #4 from NotAlexNoyle/proposal
Add MIT License to project root
2026-10-29 14:59:54 +00:00
NotAlexNoyleandClaude Opus 4.8 e3b229c464 Add MIT License
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:33 -04:00
rabfulton effa685762 Merge pull request #1 from FenrirTheGray/fix/reload-file-crash
Fix error when reloading file more than once
2026-04-10 11:28:26 +01:00
Aleksandar Colovic a30ce043f4 Fix crash when reloading file more than once
markyd_app_open_file() invalidates the current path string, so calling
it with the pointer from markyd_app_get_current_path() works the first
time but causes a use-after-free on subsequent reloads. Copy the path
with g_strdup() before passing it to open_file, and free it afterwards.
2026-04-08 18:46:24 +02:00
Robert Fulton 3d36df9847 Update README.md 2026-02-26 02:09:07 +00:00
Robert Fulton 0cce704eec Support hanging indents for list items 2026-02-26 02:06:58 +00:00
Robert Fulton 55eff8c494 Fix for search within tables 2026-02-26 01:50:11 +00:00
Robert Fulton c40a410d7d Add support for local images 2026-02-26 01:18:04 +00:00
Robert Fulton ab0b9b37d7 Add search on document 2026-02-23 17:52:51 +00:00
Robert Fulton 9bfb95e5a7 Update README.md 2026-02-19 11:30:50 +00:00
23 changed files with 4756 additions and 65 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
*.o
/viewmd
+21
View File
@@ -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.
+19 -2
View File
@@ -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
+19 -1
View File
@@ -8,9 +8,12 @@ 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
- **Document search** - `Ctrl+F` with next/previous match navigation
- **Local image support** - Local images are resized to fit the the document window
## Supported Markdown
@@ -27,7 +30,7 @@ A lightweight GTK 3 markdown viewer for desktop Linux. It is ideal as your defau
| `> quote` | Block quote |
| `[text](url)` | Link |
| `~~strike~~` | Strikethrough |
| `| table | row |` | Markdown tables |
| `\| table \| row \|` | Markdown tables |
| `---` | Horizontal rule |
Code blocks currently support a beta version of keyword highlighting for fenced languages `c`, `java`, and `python`.
@@ -59,8 +62,23 @@ sudo dnf install ./viewmd-*.rpm
Run `viewmd` to start the application.
- **Open button**: Open a markdown document
- **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.
- Type to highlight matches as you search.
- Press `Enter` for next match and `Shift+Enter` for previous match.
- Press `Esc` to close search.
### Set as Default `.md` Viewer
After installing, associate markdown MIME types with `viewmd.desktop`:
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+119 -2
View File
@@ -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;
}
+7
View File
@@ -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);
+9
View File
@@ -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) {
+1
View File
@@ -26,6 +26,7 @@ typedef struct _MarkydConfig {
/* Editor */
gboolean line_numbers;
gboolean word_wrap;
gboolean auto_reload;
} MarkydConfig;
/* Global config instance */
+292 -6
View File
@@ -1,4 +1,5 @@
#include "editor.h"
#include "app.h"
#include "markdown.h"
#include <string.h>
@@ -8,11 +9,16 @@ 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);
static void render_table_widgets(MarkydEditor *self);
static const gchar *TABLE_WIDGET_DATA_KEY = "viewmd-table-widget";
static void refresh_image_widget_scales(MarkydEditor *self);
static gboolean resolve_image_source_path(MarkydEditor *self, const gchar *src,
gchar **out_path);
static void on_text_view_size_allocate(GtkWidget *widget, GtkAllocation *allocation,
gpointer user_data);
static gchar *get_url_from_iter_tags(GtkTextIter *iter) {
GSList *tags;
@@ -128,17 +134,200 @@ 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;
}
self->updating_tags = TRUE;
markdown_apply_tags(self->buffer,
self->source_content ? self->source_content : "");
render_image_widgets(self);
render_table_widgets(self);
refresh_image_widget_scales(self);
self->updating_tags = FALSE;
}
static gboolean resolve_image_source_path(MarkydEditor *self, const gchar *src,
gchar **out_path) {
gchar *path = NULL;
if (out_path) {
*out_path = NULL;
}
if (!self || !src || src[0] == '\0' || !out_path) {
return FALSE;
}
if (g_uri_parse_scheme(src) != NULL) {
if (g_str_has_prefix(src, "file://")) {
path = g_filename_from_uri(src, NULL, NULL);
} else {
return FALSE;
}
} else if (g_path_is_absolute(src)) {
path = g_strdup(src);
} else {
const gchar *current_path = markyd_app_get_current_path(self->app);
if (current_path && current_path[0] != '\0') {
gchar *dir = g_path_get_dirname(current_path);
path = g_build_filename(dir, src, NULL);
g_free(dir);
} else {
path = g_strdup(src);
}
}
if (!path) {
return FALSE;
}
if (!g_file_test(path, G_FILE_TEST_EXISTS)) {
g_free(path);
return FALSE;
}
*out_path = path;
return TRUE;
}
static gint get_image_max_width(MarkydEditor *self) {
GtkAllocation alloc;
gint width;
if (!self || !self->text_view) {
return 0;
}
gtk_widget_get_allocation(self->text_view, &alloc);
width = alloc.width - gtk_text_view_get_left_margin(GTK_TEXT_VIEW(self->text_view)) -
gtk_text_view_get_right_margin(GTK_TEXT_VIEW(self->text_view)) - 24;
return MAX(width, 64);
}
static void scale_image_widget(GtkWidget *widget, gint max_width) {
GtkWidget *image;
GdkPixbuf *orig;
gint ow;
gint oh;
if (!widget || max_width <= 0) {
return;
}
image = g_object_get_data(G_OBJECT(widget), "viewmd-image-widget-child");
orig = g_object_get_data(G_OBJECT(widget), "viewmd-image-orig-pixbuf");
if (!GTK_IS_IMAGE(image) || !GDK_IS_PIXBUF(orig)) {
return;
}
ow = gdk_pixbuf_get_width(orig);
oh = gdk_pixbuf_get_height(orig);
if (ow <= 0 || oh <= 0) {
return;
}
if (ow > max_width) {
gint nh = (gint)(((gdouble)oh * (gdouble)max_width) / (gdouble)ow);
GdkPixbuf *scaled =
gdk_pixbuf_scale_simple(orig, max_width, MAX(nh, 1), GDK_INTERP_BILINEAR);
gtk_image_set_from_pixbuf(GTK_IMAGE(image), scaled);
if (scaled) {
g_object_unref(scaled);
}
} else {
gtk_image_set_from_pixbuf(GTK_IMAGE(image), orig);
}
}
static void refresh_image_widget_scales(MarkydEditor *self) {
GtkTextIter iter;
GtkTextIter end;
gint max_width;
if (!self || !self->buffer) {
return;
}
max_width = get_image_max_width(self);
gtk_text_buffer_get_bounds(self->buffer, &iter, &end);
while (!gtk_text_iter_equal(&iter, &end)) {
GtkTextChildAnchor *anchor = gtk_text_iter_get_child_anchor(&iter);
if (anchor &&
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_ANCHOR_DATA) != NULL) {
GtkWidget *image_widget =
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_WIDGET_DATA);
if (image_widget) {
scale_image_widget(image_widget, max_width);
}
}
gtk_text_iter_forward_char(&iter);
}
}
static void render_image_widgets(MarkydEditor *self) {
GtkTextIter iter;
GtkTextIter end;
gint max_width;
if (!self || !self->buffer || !self->text_view) {
return;
}
max_width = get_image_max_width(self);
gtk_text_buffer_get_bounds(self->buffer, &iter, &end);
while (!gtk_text_iter_equal(&iter, &end)) {
GtkTextChildAnchor *anchor = gtk_text_iter_get_child_anchor(&iter);
if (anchor &&
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_ANCHOR_DATA) != NULL) {
GtkWidget *image_widget =
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_WIDGET_DATA);
if (!image_widget) {
const gchar *src =
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_SRC_DATA);
const gchar *alt =
g_object_get_data(G_OBJECT(anchor), VIEWMD_IMAGE_ALT_DATA);
gchar *path = NULL;
if (resolve_image_source_path(self, src, &path)) {
GError *err = NULL;
GdkPixbuf *orig = gdk_pixbuf_new_from_file(path, &err);
if (orig) {
GtkWidget *event_box = gtk_event_box_new();
GtkWidget *image = gtk_image_new();
gtk_event_box_set_visible_window(GTK_EVENT_BOX(event_box), FALSE);
gtk_widget_set_halign(event_box, GTK_ALIGN_START);
gtk_container_add(GTK_CONTAINER(event_box), image);
g_object_set_data(G_OBJECT(event_box), "viewmd-image-widget-child",
image);
g_object_set_data_full(G_OBJECT(event_box), "viewmd-image-orig-pixbuf",
orig, g_object_unref);
scale_image_widget(event_box, max_width);
image_widget = event_box;
} else if (err) {
g_error_free(err);
}
}
if (!image_widget) {
GtkWidget *fallback = gtk_label_new(alt && alt[0] != '\0' ? alt : src);
gtk_widget_set_halign(fallback, GTK_ALIGN_START);
gtk_style_context_add_class(gtk_widget_get_style_context(fallback),
"dim-label");
image_widget = fallback;
}
gtk_text_view_add_child_at_anchor(GTK_TEXT_VIEW(self->text_view),
image_widget, anchor);
gtk_widget_show_all(image_widget);
g_object_set_data(G_OBJECT(anchor), VIEWMD_IMAGE_WIDGET_DATA, image_widget);
g_free(path);
} else {
scale_image_widget(image_widget, max_width);
}
}
gtk_text_iter_forward_char(&iter);
}
}
static void render_table_widgets(MarkydEditor *self) {
GtkTextIter iter;
GtkTextIter end;
@@ -152,14 +341,15 @@ static void render_table_widgets(MarkydEditor *self) {
GtkTextChildAnchor *anchor = gtk_text_iter_get_child_anchor(&iter);
if (anchor &&
g_object_get_data(G_OBJECT(anchor), VIEWMD_TABLE_ANCHOR_DATA) != NULL) {
GtkWidget *table = g_object_get_data(G_OBJECT(anchor), TABLE_WIDGET_DATA_KEY);
GtkWidget *table =
g_object_get_data(G_OBJECT(anchor), VIEWMD_TABLE_WIDGET_DATA);
if (!table) {
table = markdown_create_table_widget(anchor);
if (table) {
gtk_text_view_add_child_at_anchor(GTK_TEXT_VIEW(self->text_view), table,
anchor);
gtk_widget_show_all(table);
g_object_set_data(G_OBJECT(anchor), TABLE_WIDGET_DATA_KEY, table);
g_object_set_data(G_OBJECT(anchor), VIEWMD_TABLE_WIDGET_DATA, table);
}
}
}
@@ -167,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;
}
@@ -194,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);
@@ -215,14 +429,60 @@ 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);
return self;
}
static void on_text_view_size_allocate(GtkWidget *widget, GtkAllocation *allocation,
gpointer user_data) {
MarkydEditor *self = (MarkydEditor *)user_data;
(void)widget;
(void)allocation;
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;
@@ -236,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("");
+6
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+379 -41
View File
@@ -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,12 +25,23 @@
#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;
guint next_index;
} ListState;
typedef struct {
gint left_margin;
gint indent;
} ListLayoutSpec;
typedef struct {
MD_BLOCKTYPE type;
guint pushed_tags;
@@ -58,6 +71,7 @@ typedef struct {
typedef struct {
GtkTextBuffer *buffer;
RenderState *render_state;
GtkTextIter iter;
GPtrArray *active_tags; /* GtkTextTag* */
GArray *block_stack; /* BlockState */
@@ -74,6 +88,9 @@ typedef struct {
ViewmdTableRow *table_current_row;
GString *table_cell_text;
guint table_current_col;
gboolean in_image;
gchar *image_src;
GString *image_alt;
GArray *code_blocks; /* CodeBlockRange */
gint current_code_start_offset;
const MarkydLanguageHighlight *current_code_language;
@@ -81,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) {
@@ -274,6 +327,14 @@ static void update_newline_state(RenderCtx *ctx, const gchar *text, gsize len) {
}
}
static void note_non_newline_output(RenderCtx *ctx) {
if (!ctx) {
return;
}
ctx->has_output = TRUE;
ctx->trailing_newlines = 0;
}
static void apply_tag_by_offsets(GtkTextBuffer *buffer, GtkTextTag *tag,
gint start_offset, gint end_offset) {
GtkTextIter start;
@@ -337,6 +398,61 @@ static void ensure_newlines(RenderCtx *ctx, guint min_newlines) {
}
}
static guint decimal_digits(guint value) {
guint digits = 1;
while (value >= 10) {
value /= 10;
digits++;
}
return digits;
}
static guint list_marker_columns(RenderCtx *ctx, gboolean is_task) {
ListState *list;
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) {
return decimal_digits(MAX(1, list->next_index)) + 2; /* "12. " */
}
return 2; /* "• " */
}
static ListLayoutSpec compute_list_layout(guint depth, guint quote_depth,
guint marker_cols) {
ListLayoutSpec spec = {0, 0};
guint depth_index = (depth > 0) ? (depth - 1) : 0;
gint marker_px = (gint)CLAMP((gint)marker_cols * 9, 16, 64);
gint base_indent = 20 + ((gint)depth_index * 22) + ((gint)quote_depth * 24);
spec.left_margin = base_indent + marker_px;
spec.indent = -marker_px;
return spec;
}
static GtkTextTag *ensure_list_layout_tag(RenderCtx *ctx, guint depth,
guint quote_depth,
guint marker_cols) {
gchar *name;
GtkTextTag *tag;
ListLayoutSpec spec;
if (!ctx || !ctx->buffer) {
return NULL;
}
name = g_strdup_printf("list_layout_%u_%u_%u", depth, quote_depth, marker_cols);
tag = lookup_tag(ctx->buffer, name);
if (!tag) {
spec = compute_list_layout(depth, quote_depth, marker_cols);
tag = gtk_text_buffer_create_tag(ctx->buffer, name, "left-margin",
spec.left_margin, "indent", spec.indent, NULL);
}
g_free(name);
return tag;
}
static void push_active_tag(RenderCtx *ctx, GtkTextTag *tag, guint *counter) {
if (!ctx || !tag) {
return;
@@ -362,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) {
@@ -478,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;
@@ -573,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);
@@ -589,6 +754,109 @@ static void table_capture_append(RenderCtx *ctx, const gchar *text) {
g_free(escaped);
}
static gchar *table_cell_markup_to_plain(const gchar *markup) {
gchar *plain = NULL;
GError *error = NULL;
if (!markup || markup[0] == '\0') {
return g_strdup("");
}
if (pango_parse_markup(markup, -1, 0, NULL, &plain, NULL, &error)) {
return plain;
}
if (error) {
g_error_free(error);
}
return g_strdup(markup);
}
static void table_search_index_free(gpointer data) {
ViewmdTableSearchIndex *index = (ViewmdTableSearchIndex *)data;
if (!index) {
return;
}
if (index->cells) {
g_array_free(index->cells, TRUE);
}
g_free(index);
}
static void table_emit_hidden_search_text(RenderCtx *ctx, ViewmdTable *table,
GtkTextChildAnchor *anchor) {
ViewmdTableSearchIndex *index;
gboolean saved_has_output;
guint saved_trailing_newlines;
const gchar *cell_sep = " ";
if (!ctx || !ctx->buffer || !table || !anchor || !table->rows ||
table->rows->len == 0 || table->col_count == 0) {
return;
}
/* Hidden search text must not affect visible block spacing state. */
saved_has_output = ctx->has_output;
saved_trailing_newlines = ctx->trailing_newlines;
index = g_new0(ViewmdTableSearchIndex, 1);
index->cells = g_array_new(FALSE, FALSE, sizeof(ViewmdTableSearchCellRange));
index->start_offset = gtk_text_iter_get_offset(&ctx->iter);
for (guint r = 0; r < table->rows->len; r++) {
ViewmdTableRow *row = g_ptr_array_index(table->rows, r);
if (!row) {
continue;
}
for (guint c = 0; c < table->col_count; c++) {
const gchar *cell_markup = "";
gchar *plain;
gint cell_start;
gint cell_end;
if (c < row->cells->len) {
cell_markup = g_ptr_array_index(row->cells, c);
if (!cell_markup) {
cell_markup = "";
}
}
plain = table_cell_markup_to_plain(cell_markup);
cell_start = gtk_text_iter_get_offset(&ctx->iter);
if (plain && plain[0] != '\0') {
gtk_text_buffer_insert(ctx->buffer, &ctx->iter, plain, -1);
}
cell_end = gtk_text_iter_get_offset(&ctx->iter);
if (cell_end > cell_start) {
ViewmdTableSearchCellRange cell_range = {(gint)r, (gint)c, cell_start,
cell_end};
g_array_append_val(index->cells, cell_range);
}
g_free(plain);
if (!(r + 1 == table->rows->len && c + 1 == table->col_count)) {
gtk_text_buffer_insert(ctx->buffer, &ctx->iter, cell_sep, -1);
}
}
}
index->end_offset = gtk_text_iter_get_offset(&ctx->iter);
if (index->end_offset > index->start_offset) {
apply_tag_by_name_offsets(ctx->buffer, TAG_INVISIBLE, index->start_offset,
index->end_offset);
g_object_set_data_full(G_OBJECT(anchor), VIEWMD_TABLE_SEARCH_INDEX_DATA, index,
table_search_index_free);
} else {
table_search_index_free(index);
}
ctx->has_output = saved_has_output;
ctx->trailing_newlines = saved_trailing_newlines;
}
static void table_capture_span_enter(RenderCtx *ctx, MD_SPANTYPE type) {
if (!ctx || !ctx->table_cell_text) {
return;
@@ -707,39 +975,61 @@ static void table_emit_anchor(RenderCtx *ctx) {
}
anchor = gtk_text_buffer_create_child_anchor(ctx->buffer, &ctx->iter);
note_non_newline_output(ctx);
g_object_set_data(G_OBJECT(anchor), VIEWMD_TABLE_ANCHOR_DATA, GINT_TO_POINTER(1));
g_object_set_data_full(G_OBJECT(anchor), TABLE_MODEL_DATA_KEY, ctx->table_model,
viewmd_table_free);
/* Force at least one hard line break after the embedded table widget so
* following markdown never shares the same visual line. */
insert_cstr(ctx, "\n");
/* Keep table text searchable via Ctrl+F without showing duplicate content. */
table_emit_hidden_search_text(ctx, ctx->table_model, anchor);
ctx->table_model = NULL;
}
static void insert_list_marker(RenderCtx *ctx) {
static void image_emit_anchor(RenderCtx *ctx) {
GtkTextChildAnchor *anchor;
gchar *alt = NULL;
if (!ctx || !ctx->buffer || !ctx->image_src || ctx->image_src[0] == '\0') {
return;
}
anchor = gtk_text_buffer_create_child_anchor(ctx->buffer, &ctx->iter);
note_non_newline_output(ctx);
g_object_set_data(G_OBJECT(anchor), VIEWMD_IMAGE_ANCHOR_DATA, GINT_TO_POINTER(1));
g_object_set_data_full(G_OBJECT(anchor), VIEWMD_IMAGE_SRC_DATA,
g_strdup(ctx->image_src), g_free);
if (ctx->image_alt && ctx->image_alt->len > 0) {
alt = g_strdup(ctx->image_alt->str);
} else {
alt = g_strdup("");
}
g_object_set_data_full(G_OBJECT(anchor), VIEWMD_IMAGE_ALT_DATA, alt, g_free);
/* Keep following markdown on a new visual line after embedded images. */
insert_cstr(ctx, "\n");
}
static void insert_list_marker(RenderCtx *ctx, const MD_BLOCK_LI_DETAIL *detail) {
ListState *list;
gchar *ordered = NULL;
gint marker_start_offset;
gint marker_end_offset;
guint depth_indent;
if (!ctx || ctx->list_stack->len == 0) {
return;
}
list = &g_array_index(ctx->list_stack, ListState, ctx->list_stack->len - 1);
depth_indent = ((ctx->list_stack->len > 0) ? (ctx->list_stack->len - 1) * 2 : 0) +
(ctx->quote_depth * 2);
for (guint i = 0; i < depth_indent; i++) {
insert_cstr(ctx, " ");
}
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");
@@ -749,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);
}
@@ -825,13 +1118,20 @@ static int on_enter_block(MD_BLOCKTYPE type, void *detail, void *userdata) {
}
case MD_BLOCK_LI:
ensure_newlines(ctx, 1);
push_active_tag_by_name(ctx, TAG_LIST,
&g_array_index(ctx->block_stack, BlockState,
ctx->block_stack->len - 1)
.pushed_tags);
insert_list_marker(ctx);
ctx->list_item_prefix_pending = TRUE;
{
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);
ensure_newlines(ctx, 1);
push_active_tag(ctx, layout_tag,
&g_array_index(ctx->block_stack, BlockState,
ctx->block_stack->len - 1)
.pushed_tags);
insert_list_marker(ctx, li);
ctx->list_item_prefix_pending = TRUE;
}
break;
case MD_BLOCK_HR: {
@@ -993,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,
@@ -1005,6 +1306,19 @@ static int on_enter_span(MD_SPANTYPE type, void *detail, void *userdata) {
break;
}
case MD_SPAN_IMG: {
MD_SPAN_IMG_DETAIL *img = (MD_SPAN_IMG_DETAIL *)detail;
g_free(ctx->image_src);
ctx->image_src = attr_to_string(img ? &img->src : NULL);
if (!ctx->image_alt) {
ctx->image_alt = g_string_new(NULL);
} else {
g_string_set_size(ctx->image_alt, 0);
}
ctx->in_image = TRUE;
break;
}
default:
break;
}
@@ -1019,6 +1333,15 @@ static int on_leave_span(MD_SPANTYPE type, void *detail, void *userdata) {
if (ctx->span_stack->len > 0) {
SpanState state =
g_array_index(ctx->span_stack, SpanState, ctx->span_stack->len - 1);
if (state.type == MD_SPAN_IMG && ctx->in_image) {
image_emit_anchor(ctx);
ctx->in_image = FALSE;
g_free(ctx->image_src);
ctx->image_src = NULL;
if (ctx->image_alt) {
g_string_set_size(ctx->image_alt, 0);
}
}
if (ctx->table_cell_text) {
table_capture_span_leave(ctx, state.type);
}
@@ -1045,7 +1368,12 @@ static int on_text(MD_TEXTTYPE type, const MD_CHAR *text, MD_SIZE size,
if (ctx->list_item_prefix_pending && rendered[0] != '\0') {
ctx->list_item_prefix_pending = FALSE;
}
if (ctx->table_cell_text) {
if (ctx->in_image) {
if (!ctx->image_alt) {
ctx->image_alt = g_string_new(NULL);
}
g_string_append(ctx->image_alt, rendered);
} else if (ctx->table_cell_text) {
table_capture_append(ctx, rendered);
} else {
insert_cstr(ctx, rendered);
@@ -1241,6 +1569,10 @@ GtkWidget *markdown_create_table_widget(GtkTextChildAnchor *anchor) {
gtk_style_context_add_class(gtk_widget_get_style_context(cell),
"viewmd-table-header-cell");
}
g_object_set_data(G_OBJECT(cell), VIEWMD_TABLE_CELL_ROW_DATA,
GINT_TO_POINTER((gint)r));
g_object_set_data(G_OBJECT(cell), VIEWMD_TABLE_CELL_COL_DATA,
GINT_TO_POINTER((gint)c));
gtk_style_context_add_class(gtk_widget_get_style_context(label),
"viewmd-table-label");
gtk_widget_set_hexpand(cell, FALSE);
@@ -1282,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));
@@ -1314,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);
@@ -1322,6 +1656,10 @@ void markdown_apply_tags(GtkTextBuffer *buffer, const gchar *source) {
if (ctx.table_cell_text) {
g_string_free(ctx.table_cell_text, TRUE);
}
g_free(ctx.image_src);
if (ctx.image_alt) {
g_string_free(ctx.image_alt, TRUE);
}
if (ctx.table_model) {
viewmd_table_free(ctx.table_model);
}
+29
View File
@@ -20,6 +20,35 @@ void markdown_update_accent_tags(GtkTextBuffer *buffer);
/* GObject data key used to mark table child anchors with parsed table data. */
#define VIEWMD_TABLE_ANCHOR_DATA "viewmd-table-anchor"
/* GObject data key set on table anchors for hidden searchable index metadata. */
#define VIEWMD_TABLE_SEARCH_INDEX_DATA "viewmd-table-search-index"
/* GObject data key set on table anchors for attached table widget instance. */
#define VIEWMD_TABLE_WIDGET_DATA "viewmd-table-widget"
/* GObject data keys set on each table cell widget. */
#define VIEWMD_TABLE_CELL_ROW_DATA "viewmd-table-cell-row"
#define VIEWMD_TABLE_CELL_COL_DATA "viewmd-table-cell-col"
/* CSS classes for table search highlight states. */
#define VIEWMD_TABLE_CELL_MATCH_CLASS "viewmd-table-cell-match"
#define VIEWMD_TABLE_CELL_CURRENT_CLASS "viewmd-table-cell-current"
/* GObject data keys for image anchors and metadata. */
#define VIEWMD_IMAGE_ANCHOR_DATA "viewmd-image-anchor"
#define VIEWMD_IMAGE_SRC_DATA "viewmd-image-src"
#define VIEWMD_IMAGE_ALT_DATA "viewmd-image-alt"
#define VIEWMD_IMAGE_WIDGET_DATA "viewmd-image-widget"
typedef struct {
gint row;
gint col;
gint start_offset;
gint end_offset;
} ViewmdTableSearchCellRange;
typedef struct {
gint start_offset;
gint end_offset;
GArray *cells; /* ViewmdTableSearchCellRange */
} ViewmdTableSearchIndex;
/* Normalize heading/link text into anchor slug form. Caller owns result. */
gchar *markdown_normalize_anchor_slug(const gchar *text);
+22
View File
@@ -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.
+919 -13
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -11,11 +11,20 @@ typedef struct _MarkydWindow {
GtkWidget *header_bar;
GtkWidget *btn_open;
GtkWidget *btn_refresh;
GtkWidget *btn_search;
GtkWidget *btn_settings;
GtkWidget *search_revealer;
GtkWidget *search_entry;
GtkWidget *btn_search_prev;
GtkWidget *btn_search_next;
GtkWidget *lbl_search_status;
GtkWidget *lbl_title;
GtkWidget *scroll;
MarkydEditor *editor;
MarkydApp *app;
GArray *search_matches;
gint search_current_index;
gdouble zoom_accumulator;
} MarkydWindow;
/* Lifecycle */
+275
View File
@@ -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;
}
+269
View File
@@ -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,
"&copy; &mdash; caf&eacute; A&nbsp;B &amp; &lt; &gt; &quot; &apos; "
"&NotEqualTilde; &Afr; &CounterClockwiseContourIntegral;");
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, "&#169; &#x2014; &#X1F600; &#0; &#xD800; "
"&#xDFFF; &#1114112; &#xFFFFFF;");
assert_text(buffer, "© — 😀 � � � � �");
g_object_unref(buffer);
}
static void test_entity_literals(void) {
GtkTextBuffer *buffer = new_buffer();
const gchar *text = "&MadeUpEntity; &COPYwrong; &copy &#; &#x; &#xGG; "
"&#12345678; &#x1234567;";
markdown_apply_tags(buffer, text);
assert_text(buffer, text);
markdown_apply_tags(buffer, "&amp;copy; &#38;copy; \\&copy; &#42;literal&#42;");
assert_text(buffer, "&copy; &copy; &copy; *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, "`&copy; &#169;`\n\n```\n&amp; &#x2014;\n```\n");
assert_text(buffer, "&copy; &#169;\n\n&amp; &#x2014;\n");
markdown_apply_tags(buffer, " &copy; &#169;\n");
assert_text(buffer, "&copy; &#169;\n");
g_object_unref(buffer);
}
static void test_entity_attributes(void) {
GtkTextBuffer *buffer = new_buffer();
markdown_apply_tags(buffer, "[A &amp; B](https://example.com/?a=1&amp;b=2&#35;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, "![A &amp; B](images/a&amp;b.png)");
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, "![A](images/a\\&amp;b.png)");
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&amp;b.png");
g_object_unref(buffer);
}
static void test_entities_in_headings_and_tables(void) {
GtkTextBuffer *buffer = new_buffer();
markdown_apply_tags(buffer, "# Tom &amp; Jerry\n\n| Header |\n| --- |\n"
"| &copy; &amp; &lt;b&gt; &NotEqualTilde; |\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;
}
+180
View File
@@ -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;
}
+36
View File
@@ -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()