diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf9b66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.hedlog +obj/ +bin/ +tags +viewmd diff --git a/README.md b/README.md index 865b809..046647f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ 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 +- **Local image support** - Local images are resized to fit the document window +- **Watch mode** - `--watch` auto-reloads the file on every save +- **Editor integration** - `--socket` opens a Unix socket for live Markdown push with optional scroll-to-line +- **Stdin support** - Pipe content directly with `cat file.md | viewmd -` ## Supported Markdown @@ -64,6 +67,46 @@ Run `viewmd` to start the application. - **Reload button**: Reload the currently open document from disk - **Settings button**: Adjust theme, fonts, and markdown accent colors +### Command-line options + +``` +viewmd [OPTIONS] [FILE] +``` + +| Option | Description | +|--------|-------------| +| `--watch`, `-w` | Auto-reload the file whenever it changes on disk | +| `--socket` | Open a Unix socket for live content push from editors | +| `-` (as FILE) | Read markdown from stdin (`cat file.md \| viewmd -`) | + +### Watch mode + +```bash +viewmd --watch notes.md +``` + +The file is re-rendered within 100 ms of any write. When you open a different file via the toolbar the watch automatically follows. + +### Live push via Unix socket + +```bash +viewmd --socket file.md +``` + +On startup, ViewMD prints `VIEWMD_SOCKET=/tmp/viewmd-.sock` to stdout and listens for connections. An editor plugin can connect, push raw Markdown, and disconnect: + +```bash +echo "# Hello" | nc -U "$VIEWMD_SOCKET" +``` + +To also scroll the preview to a specific source line, prefix the payload with `CURSOR:\n` (0-based): + +```bash +{ printf 'CURSOR:42\n'; cat file.md; } | nc -U "$VIEWMD_SOCKET" +``` + +> **Note:** If ViewMD exits unexpectedly the socket file at `/tmp/viewmd-.sock` is left behind. Remove stale sockets with `rm /tmp/viewmd-*.sock`. + ### Find in Document - Press `Ctrl+F` to open search. diff --git a/src/app.c b/src/app.c index 0b864f5..d5045d3 100644 --- a/src/app.c +++ b/src/app.c @@ -2,6 +2,10 @@ #include "config.h" #include "editor.h" #include "window.h" +#include +#include +#include +#include /* Global app instance */ MarkydApp *app = NULL; @@ -11,6 +15,11 @@ 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 markyd_app_stop_watch(MarkydApp *self); +static void markyd_app_start_watch(MarkydApp *self); +static void markyd_app_parse_args(MarkydApp *self, int *argc, char **argv); +static void markyd_app_setup_socket(MarkydApp *self); +static void markyd_app_teardown_socket(MarkydApp *self); MarkydApp *markyd_app_new(void) { MarkydApp *self = g_new0(MarkydApp, 1); @@ -29,6 +38,12 @@ MarkydApp *markyd_app_new(void) { self->gtk_app = gtk_application_new("org.viewmd.app", flags); self->current_file_path = NULL; + self->watch_mode = FALSE; + self->file_monitor = NULL; + self->watch_reload_timeout_id = 0; + self->socket_mode = FALSE; + self->socket_path = NULL; + self->sock_fd = -1; g_signal_connect(self->gtk_app, "activate", G_CALLBACK(on_activate), self); g_signal_connect(self->gtk_app, "open", G_CALLBACK(on_open), self); @@ -41,11 +56,15 @@ void markyd_app_free(MarkydApp *self) { if (!self) return; + markyd_app_stop_watch(self); + markyd_app_teardown_socket(self); + if (self->window) { markyd_window_free(self->window); } g_free(self->current_file_path); + g_free(self->socket_path); g_object_unref(self->gtk_app); config_save(config); @@ -56,7 +75,30 @@ void markyd_app_free(MarkydApp *self) { app = NULL; } +static void markyd_app_parse_args(MarkydApp *self, int *argc, char **argv) { + for (gint i = 1; i < *argc; i++) { + gboolean matched = FALSE; + if (g_strcmp0(argv[i], "--watch") == 0 || g_strcmp0(argv[i], "-w") == 0) { + self->watch_mode = TRUE; + matched = TRUE; + } else if (g_strcmp0(argv[i], "--socket") == 0) { + self->socket_mode = TRUE; + matched = TRUE; + } + if (matched) { + for (gint j = i; j < *argc - 1; j++) { + argv[j] = argv[j + 1]; + } + (*argc)--; + i--; + } + } +} + int markyd_app_run(MarkydApp *self, int argc, char **argv) { + markyd_app_parse_args(self, &argc, argv); + if (self->socket_mode) + markyd_app_setup_socket(self); return g_application_run(G_APPLICATION(self->gtk_app), argc, argv); } @@ -130,27 +172,219 @@ static void markyd_app_ensure_window(MarkydApp *self) { markyd_app_update_window_title(self); } -gboolean markyd_app_open_file(MarkydApp *self, const gchar *path) { +static void markyd_app_stop_watch(MarkydApp *self) { + if (self->watch_reload_timeout_id) { + g_source_remove(self->watch_reload_timeout_id); + self->watch_reload_timeout_id = 0; + } + if (self->file_monitor) { + g_file_monitor_cancel(self->file_monitor); + g_object_unref(self->file_monitor); + self->file_monitor = NULL; + } +} + +static gboolean on_watch_reload_timeout(gpointer user_data) { + MarkydApp *self = (MarkydApp *)user_data; + self->watch_reload_timeout_id = 0; + if (self->current_file_path) { + markyd_app_open_file(self, self->current_file_path); + } + return G_SOURCE_REMOVE; +} + +static void on_file_changed(GFileMonitor *monitor, GFile *file, + GFile *other_file, GFileMonitorEvent event_type, + gpointer user_data) { + MarkydApp *self = (MarkydApp *)user_data; + (void)monitor; + (void)file; + (void)other_file; + + if (event_type != G_FILE_MONITOR_EVENT_CHANGED && + event_type != G_FILE_MONITOR_EVENT_CREATED) { + return; + } + + /* Debounce: reset timer on every event, reload after quiet period. */ + if (self->watch_reload_timeout_id) { + g_source_remove(self->watch_reload_timeout_id); + } + self->watch_reload_timeout_id = + g_timeout_add(100, on_watch_reload_timeout, self); +} + +static void markyd_app_start_watch(MarkydApp *self) { + GFile *gfile; + GError *error = NULL; + + if (!self->watch_mode || !self->current_file_path) { + return; + } + + gfile = g_file_new_for_path(self->current_file_path); + self->file_monitor = + g_file_monitor_file(gfile, G_FILE_MONITOR_NONE, NULL, &error); + g_object_unref(gfile); + + if (!self->file_monitor) { + if (error) { + g_printerr("ViewMD: could not watch file: %s\n", error->message); + g_error_free(error); + } + return; + } + + g_signal_connect(self->file_monitor, "changed", + G_CALLBACK(on_file_changed), self); +} + +static gboolean on_socket_accept(gint fd, GIOCondition cond, gpointer user_data) { + MarkydApp *self = (MarkydApp *)user_data; + int client; + GString *buf; + char chunk[4096]; + ssize_t n; + + (void)cond; + + client = accept(fd, NULL, NULL); + if (client < 0) { + return G_SOURCE_CONTINUE; + } + + buf = g_string_new(NULL); + while ((n = read(client, chunk, sizeof(chunk))) > 0) { + g_string_append_len(buf, chunk, n); + } + close(client); + + if (self->editor) { + const gchar *raw = buf->str; + gint cursor_line = -1; + /* Optional first line: "CURSOR:\n" — strip it and record the line. */ + if (g_str_has_prefix(raw, "CURSOR:")) { + const gchar *nl = strchr(raw, '\n'); + if (nl) { + cursor_line = (gint)g_ascii_strtoll(raw + 7, NULL, 10); + raw = nl + 1; + } + } + { + const gchar *dbg = g_getenv("VIEWMD_DEBUG_SCROLL"); + if (dbg && dbg[0] != '\0' && g_strcmp0(dbg, "0") != 0) + g_printerr("socket: cursor_line=%d content_len=%zu\n", + cursor_line, strlen(raw)); + } + self->editor->pending_cursor_line = cursor_line; + markyd_editor_set_content(self->editor, raw); + } + g_string_free(buf, TRUE); + return G_SOURCE_CONTINUE; +} + +static void markyd_app_setup_socket(MarkydApp *self) { + struct sockaddr_un addr; + + self->socket_path = g_strdup_printf("/tmp/viewmd-%d.sock", (int)getpid()); + self->sock_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (self->sock_fd < 0) { + g_printerr("ViewMD: failed to create socket\n"); + return; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, self->socket_path, sizeof(addr.sun_path) - 1); + + unlink(self->socket_path); + if (bind(self->sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0 || + listen(self->sock_fd, 4) < 0) { + g_printerr("ViewMD: failed to bind socket %s\n", self->socket_path); + close(self->sock_fd); + self->sock_fd = -1; + return; + } + + g_unix_fd_add(self->sock_fd, G_IO_IN, on_socket_accept, self); + g_print("VIEWMD_SOCKET=%s\n", self->socket_path); + fflush(stdout); +} + +static void markyd_app_teardown_socket(MarkydApp *self) { + if (self->sock_fd >= 0) { + close(self->sock_fd); + self->sock_fd = -1; + } + if (self->socket_path) { + unlink(self->socket_path); + } +} + +static gchar *read_stdin_content(void) { + GIOChannel *channel; gchar *content = NULL; + gsize length = 0; GError *error = NULL; - if (!self || !self->editor || !path || path[0] == '\0') { - return FALSE; + /* Refuse to block waiting on a terminal — viewmd only reads stdin from pipes. */ + if (isatty(STDIN_FILENO)) { + g_printerr("ViewMD: '-' requires piped input (stdin is a terminal)\n"); + return NULL; } - if (!g_file_get_contents(path, &content, NULL, &error)) { + channel = g_io_channel_unix_new(STDIN_FILENO); + g_io_channel_set_encoding(channel, NULL, NULL); /* binary */ + if (g_io_channel_read_to_end(channel, &content, &length, &error) != G_IO_STATUS_NORMAL) { if (error) { - g_printerr("Failed to load markdown file '%s': %s\n", path, error->message); + g_printerr("ViewMD: failed to read stdin: %s\n", error->message); g_error_free(error); } + g_free(content); + content = NULL; + } + g_io_channel_unref(channel); + return content; +} + +gboolean markyd_app_open_file(MarkydApp *self, const gchar *path) { + gchar *content = NULL; + GError *error = NULL; + gboolean from_stdin; + + if (!self || !self->editor || !path || path[0] == '\0') { return FALSE; } - markyd_editor_set_content(self->editor, content); + from_stdin = (g_strcmp0(path, "-") == 0); + + if (from_stdin) { + content = read_stdin_content(); + } else { + if (!g_file_get_contents(path, &content, NULL, &error)) { + if (error) { + g_printerr("Failed to load markdown file '%s': %s\n", path, error->message); + g_error_free(error); + } + return FALSE; + } + } + + markyd_editor_set_content(self->editor, content ? content : ""); g_free(content); - g_free(self->current_file_path); - self->current_file_path = g_strdup(path); + if (!from_stdin) { + gboolean path_changed = (g_strcmp0(self->current_file_path, path) != 0); + g_free(self->current_file_path); + self->current_file_path = g_strdup(path); + + /* Only restart monitor when the path actually changes; a toolbar + reload on the same file must keep the existing monitor alive. */ + if (path_changed) { + markyd_app_stop_watch(self); + markyd_app_start_watch(self); + } + } markyd_app_update_window_title(self); return TRUE; diff --git a/src/app.h b/src/app.h index 614ecbc..6310c8a 100644 --- a/src/app.h +++ b/src/app.h @@ -13,6 +13,16 @@ typedef struct _MarkydApp { MarkydWindow *window; MarkydEditor *editor; gchar *current_file_path; + + /* Hot-reload (--watch / -w) */ + gboolean watch_mode; + GFileMonitor *file_monitor; + guint watch_reload_timeout_id; + + /* Unix socket for live buffer push from editors (enabled by --socket). */ + gboolean socket_mode; + gchar *socket_path; + gint sock_fd; } MarkydApp; /* Global app instance */ diff --git a/src/editor.c b/src/editor.c index 4a96941..4db8746 100644 --- a/src/editor.c +++ b/src/editor.c @@ -356,10 +356,90 @@ static void render_table_widgets(MarkydEditor *self) { } } +static gboolean scroll_debug_enabled(void) { + const gchar *v = g_getenv("VIEWMD_DEBUG_SCROLL"); + return v && v[0] != '\0' && g_strcmp0(v, "0") != 0; +} + +static void scroll_to_source_line(MarkydEditor *self, gint line) { + GtkWidget *sw; + GtkAdjustment *vadj; + gdouble upper, page_size, fraction, target; + gint n_source_lines = 1; + const gchar *p; + gboolean dbg = scroll_debug_enabled(); + + if (!self || !self->text_view || line < 0) { + if (dbg) g_printerr("scroll: bail (self=%p text_view=%p line=%d)\n", + (void*)self, self ? (void*)self->text_view : NULL, line); + return; + } + + for (p = self->source_content; p && *p; p++) + if (*p == '\n') n_source_lines++; + + sw = gtk_widget_get_ancestor(self->text_view, GTK_TYPE_SCROLLED_WINDOW); + if (!sw) { + if (dbg) g_printerr("scroll: no scrolled window ancestor\n"); + return; + } + + vadj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(sw)); + if (!vadj) { + if (dbg) g_printerr("scroll: no vadjustment\n"); + return; + } + + upper = gtk_adjustment_get_upper(vadj); + page_size = gtk_adjustment_get_page_size(vadj); + + if (dbg) + g_printerr("scroll: line=%d n_lines=%d upper=%.1f page=%.1f\n", + line, n_source_lines, upper, page_size); + + if (upper <= page_size) { + if (dbg) g_printerr("scroll: content fits, no scroll needed\n"); + return; + } + + fraction = (n_source_lines > 1) + ? (gdouble)line / (gdouble)(n_source_lines - 1) + : 0.0; + target = fraction * (upper - page_size); + target = CLAMP(target, 0.0, upper - page_size); + + if (dbg) + g_printerr("scroll: fraction=%.3f target=%.1f\n", fraction, target); + + gtk_adjustment_set_value(vadj, target); + + if (dbg) + g_printerr("scroll: after set, value=%.1f\n", gtk_adjustment_get_value(vadj)); +} + +static gboolean scroll_after_apply_idle(gpointer user_data) { + MarkydEditor *self = (MarkydEditor *)user_data; + gint line = self->scroll_after_layout; + self->scroll_after_layout = -1; + if (line >= 0) + scroll_to_source_line(self, line); + return G_SOURCE_REMOVE; +} + static gboolean apply_markdown_idle(gpointer user_data) { MarkydEditor *self = (MarkydEditor *)user_data; + gint line; + self->markdown_idle_id = 0; apply_markdown(self); + + line = self->pending_cursor_line; + self->pending_cursor_line = -1; + if (line >= 0) { + self->scroll_after_layout = line; + g_timeout_add(50, scroll_after_apply_idle, self); + } + return G_SOURCE_REMOVE; } @@ -381,6 +461,8 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { self->source_content = g_strdup(""); self->updating_tags = FALSE; self->markdown_idle_id = 0; + self->pending_cursor_line = -1; + self->scroll_after_layout = -1; self->text_view = gtk_text_view_new(); gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(self->text_view), diff --git a/src/editor.h b/src/editor.h index 57343b9..f56bb21 100644 --- a/src/editor.h +++ b/src/editor.h @@ -18,6 +18,13 @@ typedef struct _MarkydEditor { /* Coalesce markdown re-rendering to idle to avoid invalidating GTK iterators. */ guint markdown_idle_id; + + /* Source line received via socket; consumed when the idle apply runs. */ + gint pending_cursor_line; + + /* Line to scroll to after the next size-allocate (post-layout). */ + gint scroll_after_layout; + } MarkydEditor; /* Lifecycle */