4 Commits
Author SHA1 Message Date
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
5 changed files with 413 additions and 28 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ A lightweight GTK 3 markdown viewer for desktop Linux. It is ideal as your defau
- **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
+200
View File
@@ -1,4 +1,5 @@
#include "editor.h"
#include "app.h"
#include "markdown.h"
#include <string.h>
@@ -10,7 +11,13 @@ static gboolean on_leave_notify(GtkWidget *widget, GdkEventCrossing *event,
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 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;
@@ -133,10 +140,193 @@ static void apply_markdown(MarkydEditor *self) {
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;
@@ -214,10 +404,20 @@ 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, "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);
}
void markyd_editor_free(MarkydEditor *self) {
if (!self) {
return;
+154 -25
View File
@@ -29,6 +29,11 @@ typedef struct {
guint next_index;
} ListState;
typedef struct {
gint left_margin;
gint indent;
} ListLayoutSpec;
typedef struct {
MD_BLOCKTYPE type;
guint pushed_tags;
@@ -74,6 +79,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;
@@ -274,6 +282,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 +353,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) {
ListState *list;
if (!ctx || !ctx->list_stack || ctx->list_stack->len == 0) {
return 2; /* "• " */
}
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;
@@ -621,12 +692,19 @@ static void table_search_index_free(gpointer data) {
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);
@@ -665,14 +743,10 @@ static void table_emit_hidden_search_text(RenderCtx *ctx, ViewmdTable *table,
g_free(plain);
if (c + 1 < table->col_count) {
gtk_text_buffer_insert(ctx->buffer, &ctx->iter, "\t", 1);
if (!(r + 1 == table->rows->len && c + 1 == table->col_count)) {
gtk_text_buffer_insert(ctx->buffer, &ctx->iter, cell_sep, -1);
}
}
if (r + 1 < table->rows->len) {
gtk_text_buffer_insert(ctx->buffer, &ctx->iter, "\n", 1);
}
}
index->end_offset = gtk_text_iter_get_offset(&ctx->iter);
@@ -684,6 +758,9 @@ static void table_emit_hidden_search_text(RenderCtx *ctx, ViewmdTable *table,
} 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) {
@@ -804,6 +881,7 @@ 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);
@@ -811,31 +889,45 @@ static void table_emit_anchor(RenderCtx *ctx) {
/* Keep table text searchable via Ctrl+F without showing duplicate content. */
table_emit_hidden_search_text(ctx, ctx->table_model, anchor);
/* 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");
ctx->table_model = NULL;
}
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) {
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) {
@@ -925,13 +1017,19 @@ 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;
{
guint marker_cols = list_marker_columns(ctx);
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);
ctx->list_item_prefix_pending = TRUE;
}
break;
case MD_BLOCK_HR: {
@@ -1105,6 +1203,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;
}
@@ -1119,6 +1230,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);
}
@@ -1145,7 +1265,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);
@@ -1426,6 +1551,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);
}
+6
View File
@@ -31,6 +31,12 @@ void markdown_update_accent_tags(GtkTextBuffer *buffer);
#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;
+52 -3
View File
@@ -176,6 +176,8 @@ static void set_table_cell_highlight(GtkWidget *cell, gboolean match,
static gboolean scroll_to_table_cell(MarkydWindow *self, GtkWidget *cell) {
gint x = 0;
gint y = 0;
gdouble doc_x;
gdouble doc_y;
GtkAllocation alloc;
GtkAdjustment *hadj;
GtkAdjustment *vadj;
@@ -192,12 +194,21 @@ static gboolean scroll_to_table_cell(MarkydWindow *self, GtkWidget *cell) {
gtk_widget_get_allocation(cell, &alloc);
hadj = gtk_scrolled_window_get_hadjustment(GTK_SCROLLED_WINDOW(self->scroll));
vadj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(self->scroll));
doc_x = x + (hadj ? gtk_adjustment_get_value(hadj) : 0.0);
doc_y = y + (vadj ? gtk_adjustment_get_value(vadj) : 0.0);
if (hadj) {
gtk_adjustment_clamp_page(hadj, MAX(0, x - 12), MAX(0, x + alloc.width + 12));
gdouble target = MAX(0.0, doc_x - 16.0);
gdouble max_val =
gtk_adjustment_get_upper(hadj) - gtk_adjustment_get_page_size(hadj);
gtk_adjustment_set_value(hadj, CLAMP(target, 0.0, MAX(0.0, max_val)));
}
if (vadj) {
gtk_adjustment_clamp_page(vadj, MAX(0, y - 12), MAX(0, y + alloc.height + 12));
gdouble page = gtk_adjustment_get_page_size(vadj);
gdouble target = MAX(0.0, doc_y - (page * 0.25));
gdouble max_val =
gtk_adjustment_get_upper(vadj) - gtk_adjustment_get_page_size(vadj);
gtk_adjustment_set_value(vadj, CLAMP(target, 0.0, MAX(0.0, max_val)));
}
return TRUE;
@@ -318,10 +329,11 @@ static gboolean resolve_table_match_location(MarkydWindow *self, gint start_offs
}
if (index->cells) {
ViewmdTableSearchCellRange *overlap_cell = NULL;
for (guint i = 0; i < index->cells->len; i++) {
ViewmdTableSearchCellRange *cell =
&g_array_index(index->cells, ViewmdTableSearchCellRange, i);
if (start_offset < cell->end_offset && end_offset > cell->start_offset) {
if (start_offset >= cell->start_offset && start_offset < cell->end_offset) {
if (out_row) {
*out_row = cell->row;
}
@@ -330,6 +342,18 @@ static gboolean resolve_table_match_location(MarkydWindow *self, gint start_offs
}
return TRUE;
}
if (start_offset < cell->end_offset && end_offset > cell->start_offset) {
overlap_cell = cell;
}
}
if (overlap_cell) {
if (out_row) {
*out_row = overlap_cell->row;
}
if (out_col) {
*out_col = overlap_cell->col;
}
return TRUE;
}
}
@@ -1008,6 +1032,31 @@ static gboolean on_key_press_event(GtkWidget *widget, GdkEventKey *event,
return TRUE;
}
if (event->keyval == GDK_KEY_Home || event->keyval == GDK_KEY_KP_Home) {
if (self->editor && self->editor->buffer && self->editor->text_view) {
GtkTextIter start;
GtkAdjustment *hadj = NULL;
GtkAdjustment *vadj = NULL;
gtk_text_buffer_get_start_iter(self->editor->buffer, &start);
gtk_text_buffer_place_cursor(self->editor->buffer, &start);
gtk_text_view_scroll_to_iter(GTK_TEXT_VIEW(self->editor->text_view), &start,
0.0, FALSE, 0.0, 0.0);
if (self->scroll) {
hadj = gtk_scrolled_window_get_hadjustment(GTK_SCROLLED_WINDOW(self->scroll));
vadj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(self->scroll));
}
if (hadj) {
gtk_adjustment_set_value(hadj, gtk_adjustment_get_lower(hadj));
}
if (vadj) {
gtk_adjustment_set_value(vadj, gtk_adjustment_get_lower(vadj));
}
}
return TRUE;
}
return FALSE;
}