From 5b941830ab7c61eacea5fec4bb5b2051cadea8e4 Mon Sep 17 00:00:00 2001
From: Alcor <alcor@tilde.club>
Date: Mon, 16 Mar 2026 00:04:23 +0100
Subject: [PATCH 12/19] Add /macros command & switch macro character to .

---
 catgirl.1  |  6 +++++-
 chat.h     | 12 +++++++++++-
 command.c  | 17 +++++++++++++++++
 complete.c | 14 ++++++++------
 input.c    | 34 ++++++++++++++++++++++++++--------
 xdg.c      |  9 +++++++++
 6 files changed, 76 insertions(+), 16 deletions(-)

diff --git a/catgirl.1 b/catgirl.1
index df48237..09590e8 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -763,6 +763,10 @@ To permanently add a pattern,
 use the
 .Cm ignore
 option.
+.It Ic /macros
+List known macros.
+.It Ic /macros Ar filename
+Load macros from the given filename.
 .It Ic /move Oo Ar name Oc Ar num
 Move the named or current window to number.
 .It Ic /o ...
@@ -860,7 +864,7 @@ Delete to beginning of line.
 Delete previous word.
 .It Ic C-x
 Expand a text macro beginning with
-.Ql \e .
+.Ql . .
 .It Ic C-y
 Paste previously deleted text.
 .It Ic M-Enter
diff --git a/chat.h b/chat.h
index a20b2f9..092f503 100644
--- a/chat.h
+++ b/chat.h
@@ -342,6 +342,13 @@ void uiFormat(
 void uiLoad(const char *name);
 int uiSave(void);
 
+#define MACRO_CHAR L'.'
+enum { NMACROS = 256 };
+struct Macro { const wchar_t *name, *string; struct Entry *ent; };
+extern struct Macro Macros[NMACROS];
+extern size_t MacrosTop;
+extern int MacroWidth;
+
 void inputInit(void);
 void inputWait(void);
 void inputUpdate(void);
@@ -409,13 +416,15 @@ int bufferReflow(
 	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
 );
 
+struct Entry;
 struct treap;
 struct Cursor {
 	uint gen;
 	struct treap *node;
 	uint n;
 };
-void completePush(uint id, const char *str, enum Color color);
+void completePop(uint id, struct Entry *e);
+struct Entry *completePush(uint id, const char *str, enum Color color);
 void completePull(uint id, const char *str, enum Color color);
 void completeReplace(const char *old, const char *new);
 void completeRemove(uint id, const char *str);
@@ -458,6 +467,7 @@ void logClose(void);
 char *configPath(char *buf, size_t cap, const char *path, int i);
 char *dataPath(char *buf, size_t cap, const char *path, int i);
 FILE *configOpen(const char *path, const char *mode);
+FILE *configMaybeOpen(const char *path, const char *mode);
 FILE *dataOpen(const char *path, const char *mode);
 
 int getopt_config(
diff --git a/command.c b/command.c
index f187719..95c61c7 100644
--- a/command.c
+++ b/command.c
@@ -393,6 +393,22 @@ static void commandList(uint id, char *params) {
 	replies[ReplyList]++;
 }
 
+static void commandMacros(uint id, char *params) {
+	if (!params) {
+		for (size_t i = 0; i < MacrosTop; ++i) {
+			uiFormat(id, Warm, NULL, "\3%d%3ld.\3\t%-*ls\t%ls", Gray, i + 1, MacroWidth, Macros[i].name, Macros[i].string);
+		}
+		return;
+	}
+	FILE *file = configMaybeOpen(params, "r");
+	if (!file) {
+		uiFormat(id, Warm, NULL, "couldn't load macros from: %s", params);
+		return;
+	}
+	inputCompletion(file);
+	uiFormat(id, Warm, NULL, "%ld macro(s) loaded from: %s", MacrosTop, params);
+}
+
 static void commandWhois(uint id, char *params) {
 	(void)id;
 	if (!params) params = self.nick;
@@ -608,6 +624,7 @@ static const struct Handler {
 	{ "/join", commandJoin, 0, 0 },
 	{ "/kick", commandKick, 0, 0 },
 	{ "/list", commandList, 0, 0 },
+	{ "/macros", commandMacros, 0, 0, },
 	{ "/me", commandMe, Multiline, 0 },
 	{ "/mode", commandMode, 0, 0 },
 	{ "/motd", commandMOTD, 0, 0 },
diff --git a/complete.c b/complete.c
index e3a838d..1e73ecd 100644
--- a/complete.c
+++ b/complete.c
@@ -66,7 +66,7 @@ static bool entryContains(const struct Entry *e, uint id) {
 	return (e->on[id / CHAR_BIT] & ((unsigned char)(1 << (id % CHAR_BIT)))) != 0;
 }
 
-static void deleteEntry(struct Entry *e, uint id) {
+void completePop(uint id, struct Entry *e) {
 	assert(id < IDCap);
 	if (id) {
 		e->on[id / CHAR_BIT] &= ~((unsigned char)(1 << (id % CHAR_BIT)));
@@ -78,7 +78,7 @@ invalidate:
 	++gen;
 }
 
-void completePush(uint id, const char *str, enum Color color) {
+struct Entry *completePush(uint id, const char *str, enum Color color) {
 	struct Entry entry = { .str = (char *)str };
 	const struct treap *temp = root;
 	const struct treap *node = treapget(&temp, entryCmp, &entry);
@@ -91,13 +91,15 @@ void completePush(uint id, const char *str, enum Color color) {
 			data->str = strdup(str);
 			if (!data->str) err(1, "strdup");
 		}
+		return data;
 	} else {
 		struct Entry data = { .str = strdup(str), .color = color };
 		if (!data.str) err(1, "strdup");
 		entrySetID(&data, id);
-		int prio = (*str == '/' || *str == '\\') ? rand() : -rand();
+		int prio = (*str == '/' || *str == MACRO_CHAR) ? rand() : -rand();
 		treap *t = treapush(&root, entryCmp, prio, sizeof(data), &data);
 		if (!t) err(1, "treapush");
+		return (struct Entry *)treapdata(t);
 	}
 }
 
@@ -113,7 +115,7 @@ void completePull(uint id, const char *str, enum Color color) {
 		struct Entry data = { .str = strdup(str), .color = color };
 		if (!data.str) err(1, "strdup");
 		entrySetID(&data, id);
-		int prio = (*str == '/' || *str == '\\') ? rand() : -rand();
+		int prio = (*str == '/' || *str == MACRO_CHAR) ? rand() : -rand();
 		treap *t = treapushat(&root, (treap *)temp, entryCmp, prio, sizeof(data), &data);
 		if (!t) err(1, "treapushat");
 	}
@@ -136,13 +138,13 @@ void completeRemove(uint id, const char *str) {
 		const treap *temp = root;
 		const treap *node = treapget(&temp, entryCmp, &entry);
 		if (!node) return;
-		deleteEntry((struct Entry *)treapdata(node), id);
+		completePop(id, (struct Entry *)treapdata(node));
 	} else {
 		treap *t = (treap *)treapiter(root);
 		while (t) {
 			struct Entry *data = (struct Entry *)treapdata(t);
 			t = (treap *)treapnext(t);
-			deleteEntry(data, id);
+			completePop(id, data);
 		}
 	}
 }
diff --git a/input.c b/input.c
index 93f3c50..d4d9a4f 100644
--- a/input.c
+++ b/input.c
@@ -246,10 +246,10 @@ bool inputPending(uint id) {
 	return edits[id].len;
 }
 
-enum { NMACROS = 256 };
-static struct Macro { const wchar_t *name, *string; } Macros[NMACROS];
+struct Macro Macros[NMACROS];
 struct MacroQuery { const wchar_t *str; size_t n; };
-static size_t MacrosTop;
+size_t MacrosTop;
+int MacroWidth;
 
 static int MacroCompare(const void *p, const void *q) {
 	const struct Macro *ma = p, *mb = q;
@@ -281,18 +281,36 @@ static void wrtrim(wchar_t *s) {
 void inputCompletion(FILE *file) {
 	if (!file) return;
 	wchar_t line[512];
+	for (size_t i = 0; i < MacrosTop; ++i) {
+		if (Macros[i].ent) {
+			completePop(None, Macros[i].ent);
+			Macros[i].ent = NULL;
+		}
+		if (Macros[i].string) {
+			free((void *)Macros[i].string);
+			Macros[i].string = NULL;
+		}
+		if (Macros[i].name) {
+			free((void *)Macros[i].name);
+			Macros[i].name = NULL;
+		}
+	}
+	MacrosTop = 0;
+	MacroWidth = 0;
 	while (MacrosTop < NMACROS && fgetws(line, ARRAY_LEN(line), file)) {
 		if (*line == L'\n' || *line == L'\r') continue;
-		wchar_t wcvalue[256] = { L'\0' }, wckey[257] = { L'\\' };
+		wchar_t wcvalue[256] = { L'\0' }, wckey[257] = { MACRO_CHAR };
 		if (swscanf(line, L" %255l[a-zA-Z0-9_] %255l[^\n]", &wckey[1], wcvalue) == 2) {
 			char key[257];
 			size_t n = wcstombs(key, wckey, sizeof(key));
 			if (n == (size_t)-1 || n >= sizeof(key)) continue;
-			completePush(None, key, Default);
+			Macros[MacrosTop].ent = completePush(None, key, Default);
 			Macros[MacrosTop].name = wcsdup(wckey);
 			if (!Macros[MacrosTop].name) err(1, "wcsdup");
 			Macros[MacrosTop].string = (wrtrim(wcvalue), wcsdup(wcvalue));
 			if (!Macros[MacrosTop].string) err(1, "wcsdup");
+			int w = wcswidth(wckey, 16);
+			if (w > MacroWidth) MacroWidth = w;
 			++MacrosTop;
 		}
 	}
@@ -304,8 +322,8 @@ static int macroExpand(struct Edit *e) {
 	size_t macro = e->pos;
 	if (!macro) return 0;
 	--macro;
-	while (macro && e->buf[macro] != L'\\') macro--;
-	if (e->buf[macro] != L'\\') return 0;
+	while (macro && e->buf[macro] != MACRO_CHAR) macro--;
+	if (e->buf[macro] != MACRO_CHAR) return 0;
 	const struct MacroQuery query = { &e->buf[macro], e->pos - macro };
 	const struct Macro *match = bsearch(&query, Macros, MacrosTop, sizeof(struct Macro), MacroQueryExactCmp);
 	if (!match) match = bsearch(&query, Macros, MacrosTop, sizeof(struct Macro), MacroQueryCmp);
@@ -387,7 +405,7 @@ static int tabComplete(struct Edit *e, uint id) {
 	if (error) goto fail;
 
 	tab.len = n;
-	if (wcs[0] == L'\\' || wcschr(wcs, L' ')) {
+	if (wcs[0] == MACRO_CHAR || wcschr(wcs, L' ')) {
 		error = editReserve(e, tab.pos, tab.len);
 		if (error) goto fail;
 	} else if (wcs[0] != L'/' && tab.suffix && (!tab.pos || colon)) {
diff --git a/xdg.c b/xdg.c
index 6f61cf9..9ffe506 100644
--- a/xdg.c
+++ b/xdg.c
@@ -113,6 +113,15 @@ FILE *configOpen(const char *path, const char *mode) {
 	return NULL;
 }
 
+FILE *configMaybeOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; configPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+	}
+	return NULL;
+}
+
 FILE *dataOpen(const char *path, const char *mode) {
 	char buf[PATH_MAX];
 	for (int i = 0; dataPath(buf, sizeof(buf), path, i); ++i) {
-- 
2.47.3

