Merge pull request #2132 from profanity-im/feat/spellcheck

Add spellchecking
This commit is contained in:
Michael Vetter
2026-03-27 14:32:01 +01:00
committed by GitHub
17 changed files with 386 additions and 0 deletions

View File

@@ -160,6 +160,7 @@ gdk_pixbuf_dep = disabler()
omemo_dep = disabler()
gcrypt_dep = disabler()
qrencode_dep = disabler()
enchant_dep = disabler()
# Dependencies for functional tests
stabber_dep = cc.find_library('stabber', required: false)
@@ -269,6 +270,12 @@ if get_option('omemo-qrcode').enabled()
conf_data.set('HAVE_QRENCODE', 1)
endif
# Spellcheck support
if get_option('spellcheck').enabled()
enchant_dep = dependency('enchant-2', required: true)
conf_data.set('HAVE_SPELLCHECK', 1)
endif
# Set installation paths
themes_path = get_option('themes_path')
if themes_path == ''
@@ -355,6 +362,10 @@ if qrencode_dep.found()
profanity_deps += qrencode_dep
endif
if enchant_dep.found()
profanity_deps += enchant_dep
endif
# Include directories
inc = include_directories('.', 'src')
@@ -419,6 +430,7 @@ core_sources = files(
'src/tools/autocomplete.c',
'src/tools/clipboard.c',
'src/tools/editor.c',
'src/tools/spellcheck.c',
'src/config/files.c',
'src/config/conflists.c',
'src/config/accounts.c',
@@ -584,6 +596,7 @@ if get_option('tests')
'src/tools/autocomplete.c',
'src/tools/clipboard.c',
'src/tools/editor.c',
'src/tools/spellcheck.c',
'src/tools/bookmark_ignore.c',
'src/config/account.c',
'src/config/files.c',

View File

@@ -66,6 +66,12 @@ option('omemo-qrcode',
description: 'Enable ability to display OMEMO QR code'
)
option('spellcheck',
type: 'feature',
value: 'disabled',
description: 'Enable spellchecking support'
)
# Other:
option('python_framework',
type: 'string',

View File

@@ -43,6 +43,7 @@
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _spellcheck_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _account_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -138,6 +139,7 @@ static Autocomplete autoconnect_ac;
static Autocomplete wintitle_ac;
static Autocomplete theme_ac;
static Autocomplete theme_load_ac;
static Autocomplete spellcheck_ac;
static Autocomplete account_ac;
static Autocomplete account_set_ac;
static Autocomplete account_clear_ac;
@@ -298,6 +300,7 @@ static Autocomplete* all_acs[] = {
&autoconnect_ac,
&wintitle_ac,
&theme_ac,
&spellcheck_ac,
&account_ac,
&account_set_ac,
&account_clear_ac,
@@ -563,6 +566,11 @@ cmd_ac_init(void)
autocomplete_add(theme_ac, "colours");
autocomplete_add(theme_ac, "properties");
autocomplete_add(spellcheck_ac, "on");
autocomplete_add(spellcheck_ac, "off");
autocomplete_add(spellcheck_ac, "list");
autocomplete_add(spellcheck_ac, "lang");
autocomplete_add(disco_ac, "info");
autocomplete_add(disco_ac, "items");
@@ -1748,6 +1756,16 @@ cmd_ac_complete_filepath(const char* const input, char* const startstr, gboolean
return autocomplete_param_with_ac(input, startstr, filepath_ac, TRUE, previous);
}
static char*
_spellcheck_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
result = autocomplete_param_with_ac(input, "/spellcheck", spellcheck_ac, TRUE, previous);
return result;
}
static char*
_cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previous)
{
@@ -1767,6 +1785,11 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
}
}
result = _spellcheck_autocomplete(window, input, previous);
if (result) {
return result;
}
// autocomplete nickname in chat rooms
if (window->type == WIN_MUC) {
ProfMucWin* mucwin = (ProfMucWin*)window;

View File

@@ -1451,6 +1451,23 @@ static const struct cmd_t command_defs[] = {
{ "on|off", "Enable or disable splash logo." })
},
{ CMD_PREAMBLE("/spellcheck",
parse_args, 1, 2, &cons_spellcheck_setting)
CMD_MAINFUNC(cmd_spellcheck)
CMD_TAGS(
CMD_TAG_UI)
CMD_SYN(
"/spellcheck on|off",
"/spellcheck list",
"/spellcheck lang <locale>")
CMD_DESC(
"Enable or disable spellchecking, or set the language.")
CMD_ARGS(
{ "on|off", "Enable or disable spellchecking." },
{ "list", "List available dictionaries recognized by Enchant." },
{ "lang <locale>", "Set the spellcheck language (en_US)." })
},
{ CMD_PREAMBLE("/autoconnect",
parse_args, 1, 2, &cons_autoconnect_setting)
CMD_MAINFUNC(cmd_autoconnect)

View File

@@ -54,6 +54,7 @@
#include "tools/plugin_download.h"
#include "tools/bookmark_ignore.h"
#include "tools/editor.h"
#include "tools/spellcheck.h"
#include "plugins/plugins.h"
#include "ui/inputwin.h"
#include "ui/ui.h"
@@ -6560,6 +6561,55 @@ cmd_splash(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
gboolean
cmd_spellcheck(ProfWin* window, const char* const command, gchar** args)
{
#ifdef HAVE_SPELLCHECK
if (g_strcmp0(args[0], "on") == 0) {
prefs_set_boolean(PREF_SPELLCHECK_ENABLE, TRUE);
cons_show("Spellcheck enabled.");
} else if (g_strcmp0(args[0], "off") == 0) {
prefs_set_boolean(PREF_SPELLCHECK_ENABLE, FALSE);
cons_show("Spellcheck disabled.");
} else if (g_strcmp0(args[0], "list") == 0) {
GList* langs = spellcheck_get_available_langs();
if (langs == NULL) {
cons_show("No dictionaries found. Install Enchant-compatible dictionaries (hunspell/aspell).");
} else {
GString* lang_str = g_string_new("");
GList* curr = langs;
while (curr) {
g_string_append(lang_str, (char*)curr->data);
if (curr->next) {
g_string_append(lang_str, ", ");
}
curr = g_list_next(curr);
}
cons_show("Available spellcheck dictionaries: %s", lang_str->str);
g_string_free(lang_str, TRUE);
g_list_free_full(langs, g_free);
}
} else if (g_strcmp0(args[0], "lang") == 0) {
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
} else {
if (spellcheck_set_lang(args[1])) {
prefs_set_string(PREF_SPELLCHECK_LANG, args[1]);
cons_show("Spellcheck language set to: %s", args[1]);
} else {
cons_show("Failed to set spellcheck language to: %s. Is the dictionary installed?", args[1]);
}
}
} else {
cons_bad_cmd_usage(command);
}
#else
cons_show("Profanity was built without spellcheck support.");
#endif
return TRUE;
}
gboolean
cmd_autoconnect(ProfWin* window, const char* const command, gchar** args)
{

View File

@@ -103,6 +103,7 @@ gboolean cmd_bookmark_ignore(ProfWin* window, const char* const command, gchar**
gboolean cmd_roster(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_software(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_splash(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_spellcheck(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_states(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_status_get(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_status_set(ProfWin* window, const char* const command, gchar** args);

View File

@@ -41,6 +41,7 @@
#define PREF_GROUP_MUC "muc"
#define PREF_GROUP_PLUGINS "plugins"
#define PREF_GROUP_EXECUTABLES "executables"
#define PREF_GROUP_SPELLCHECK "spellcheck"
#define INPBLOCK_DEFAULT 1000
@@ -1909,6 +1910,9 @@ _get_group(preference_t pref)
return PREF_GROUP_OMEMO;
case PREF_OX_LOG:
return PREF_GROUP_OX;
case PREF_SPELLCHECK_ENABLE:
case PREF_SPELLCHECK_LANG:
return PREF_GROUP_SPELLCHECK;
default:
return NULL;
}
@@ -2196,6 +2200,10 @@ _get_key(preference_t pref)
return "strophe.sm.enabled";
case PREF_STROPHE_SM_RESEND:
return "strophe.sm.resend";
case PREF_SPELLCHECK_ENABLE:
return "enabled";
case PREF_SPELLCHECK_LANG:
return "lang";
default:
return NULL;
}
@@ -2249,6 +2257,7 @@ _get_default_boolean(preference_t pref)
case PREF_STROPHE_SM_ENABLED:
case PREF_STROPHE_SM_RESEND:
return TRUE;
case PREF_SPELLCHECK_ENABLE:
case PREF_PGP_PUBKEY_AUTOIMPORT:
default:
return FALSE;
@@ -2356,6 +2365,8 @@ _get_default_string(preference_t pref)
return "0";
case PREF_DBLOG:
return "on";
case PREF_SPELLCHECK_LANG:
return "en_US";
default:
return NULL;
}

View File

@@ -161,6 +161,8 @@ typedef enum {
PREF_STROPHE_SM_RESEND,
PREF_VCARD_PHOTO_CMD,
PREF_STATUSBAR_TABMODE,
PREF_SPELLCHECK_ENABLE,
PREF_SPELLCHECK_LANG,
} preference_t;
typedef struct prof_alias_t

View File

@@ -90,6 +90,7 @@ theme_init(const char* const theme_name)
g_hash_table_insert(defaults, strdup("mention"), strdup("yellow"));
g_hash_table_insert(defaults, strdup("trigger"), strdup("yellow"));
g_hash_table_insert(defaults, strdup("input.text"), strdup("default"));
g_hash_table_insert(defaults, strdup("input.misspelled"), strdup("red"));
g_hash_table_insert(defaults, strdup("main.time"), strdup("default"));
g_hash_table_insert(defaults, strdup("titlebar.text"), strdup("white"));
g_hash_table_insert(defaults, strdup("titlebar.brackets"), strdup("cyan"));
@@ -713,6 +714,9 @@ theme_attrs(theme_item_t attrs)
case THEME_INPUT_TEXT:
_theme_prep_fgnd("input.text", lookup_str, &bold);
break;
case THEME_INPUT_MISSPELLED:
_theme_prep_fgnd("input.misspelled", lookup_str, &bold);
break;
case THEME_TIME:
_theme_prep_fgnd("main.time", lookup_str, &bold);
break;

View File

@@ -27,6 +27,7 @@ typedef enum {
THEME_MENTION,
THEME_TRIGGER,
THEME_INPUT_TEXT,
THEME_INPUT_MISSPELLED,
THEME_TIME,
THEME_TITLE_TEXT,
THEME_TITLE_BRACKET,

View File

@@ -30,6 +30,7 @@
#include "config/accounts.h"
#include "config/preferences.h"
#include "config/theme.h"
#include "tools/spellcheck.h"
#include "config/tlscerts.h"
#include "config/scripts.h"
#include "command/cmd_defs.h"
@@ -234,6 +235,7 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name)
#ifdef HAVE_OMEMO
omemo_init();
#endif
spellcheck_init();
atexit(_shutdown);
plugins_init();
#ifdef HAVE_GTK

132
src/tools/spellcheck.c Normal file
View File

@@ -0,0 +1,132 @@
/*
* spellcheck.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2026 Michael Vetter <jubalh@iodoru.org>
*
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
*/
#include "config.h"
#ifdef HAVE_SPELLCHECK
#include <enchant.h>
#include <string.h>
#include <glib.h>
#include "tools/spellcheck.h"
#include "config/preferences.h"
#include "log.h"
#include "common.h"
static EnchantBroker* broker = NULL;
static EnchantDict* dict = NULL;
static char* current_lang = NULL;
void
spellcheck_init(void)
{
if (broker) {
return;
}
broker = enchant_broker_init();
if (!broker) {
log_error("Failed to initialize Enchant broker");
return;
}
prof_add_shutdown_routine(spellcheck_deinit);
char* lang = prefs_get_string(PREF_SPELLCHECK_LANG);
spellcheck_set_lang(lang);
g_free(lang);
}
void
spellcheck_deinit(void)
{
if (dict) {
enchant_broker_free_dict(broker, dict);
dict = NULL;
}
if (broker) {
enchant_broker_free(broker);
broker = NULL;
}
g_free(current_lang);
current_lang = NULL;
}
gboolean
spellcheck_set_lang(const char* lang)
{
if (!broker || !lang) {
return FALSE;
}
if (current_lang && strcmp(current_lang, lang) == 0 && dict) {
return TRUE;
}
EnchantDict* new_dict = enchant_broker_request_dict(broker, lang);
if (!new_dict) {
log_error("Failed to request Enchant dictionary for language: %s", lang);
return FALSE;
}
if (dict) {
enchant_broker_free_dict(broker, dict);
}
dict = new_dict;
g_free(current_lang);
current_lang = g_strdup(lang);
return TRUE;
}
const char*
spellcheck_get_lang(void)
{
return current_lang;
}
static void
_list_dicts_cb(const char* const lang_tag,
const char* const provider_name,
const char* const provider_desc,
const char* const provider_file,
void* user_data)
{
GList** list = (GList**)user_data;
if (g_list_find_custom(*list, lang_tag, (GCompareFunc)g_strcmp0) == NULL) {
*list = g_list_append(*list, g_strdup(lang_tag));
}
}
GList*
spellcheck_get_available_langs(void)
{
if (!broker) {
return NULL;
}
GList* list = NULL;
enchant_broker_list_dicts(broker, _list_dicts_cb, &list);
return g_list_sort(list, (GCompareFunc)g_strcmp0);
}
gboolean
spellcheck_is_misspelled(const char* word)
{
if (!dict || !word || word[0] == '\0') {
return FALSE;
}
int result = enchant_dict_check(dict, word, strlen(word));
return (result != 0);
}
#endif

58
src/tools/spellcheck.h Normal file
View File

@@ -0,0 +1,58 @@
/*
* spellcheck.h
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2026 Michael Vetter <jubalh@iodoru.org>
*
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
*/
#ifndef TOOLS_SPELLCHECK_H
#define TOOLS_SPELLCHECK_H
#include "config.h"
#include <glib.h>
#ifdef HAVE_SPELLCHECK
void spellcheck_init(void);
void spellcheck_deinit(void);
gboolean spellcheck_is_misspelled(const char* word);
gboolean spellcheck_set_lang(const char* lang);
const char* spellcheck_get_lang(void);
GList* spellcheck_get_available_langs(void);
#else
static inline void
spellcheck_init(void)
{
}
static inline void
spellcheck_deinit(void)
{
}
static inline gboolean
spellcheck_is_misspelled(const char* word)
{
return FALSE;
}
static inline gboolean
spellcheck_set_lang(const char* lang)
{
return FALSE;
}
static inline const char*
spellcheck_get_lang(void)
{
return NULL;
}
static inline GList*
spellcheck_get_available_langs(void)
{
return NULL;
}
#endif
#endif

View File

@@ -1270,6 +1270,21 @@ cons_splash_setting(void)
cons_show("Splash screen (/splash) : OFF");
}
void
cons_spellcheck_setting(void)
{
#ifdef HAVE_SPELLCHECK
if (prefs_get_boolean(PREF_SPELLCHECK_ENABLE)) {
auto_gchar gchar* lang = prefs_get_string(PREF_SPELLCHECK_LANG);
cons_show("Spellcheck (/spellcheck) : ON (%s)", lang);
} else {
cons_show("Spellcheck (/spellcheck) : OFF");
}
#else
cons_show("Spellcheck (/spellcheck) : built without spellcheck support");
#endif
}
void
cons_occupants_setting(void)
{
@@ -1589,6 +1604,7 @@ cons_show_ui_prefs(void)
cons_beep_setting();
cons_flash_setting();
cons_splash_setting();
cons_spellcheck_setting();
cons_winpos_setting();
cons_wrap_setting();
cons_time_setting();
@@ -2572,6 +2588,7 @@ cons_theme_properties(void)
_cons_theme_prop(console, THEME_RECEIPT_SENT, "receipt.sent");
_cons_theme_prop(console, THEME_INPUT_TEXT, "input.text");
_cons_theme_prop(console, THEME_INPUT_MISSPELLED, "input.misspelled");
cons_show("");
}

View File

@@ -50,6 +50,7 @@
#include "xmpp/roster_list.h"
#include "xmpp/chat_state.h"
#include "tools/editor.h"
#include "tools/spellcheck.h"
static WINDOW* inp_win;
static int pad_start = 0;
@@ -327,6 +328,8 @@ _inp_write(char* line, int offset)
getyx(inp_win, y, x);
col += x;
gboolean do_spell = prefs_get_boolean(PREF_SPELLCHECK_ENABLE) && (line[0] != '/');
for (size_t i = 0; line[i] != '\0'; i++) {
char* c = &line[i];
char retc[PROF_MB_CUR_MAX] = { 0 };
@@ -348,6 +351,47 @@ _inp_write(char* line, int offset)
i += ch_len - 1;
}
if (do_spell) {
gunichar uc = g_utf8_get_char(c);
if (g_unichar_isalnum(uc) || uc == '\'') {
// start of a word
size_t start = i - (ch_len - 1);
size_t end = start + ch_len;
while (line[end] != '\0') {
size_t next_ch_len = mbrlen(&line[end], MB_CUR_MAX, NULL);
if (next_ch_len == (size_t)-2 || next_ch_len == (size_t)-1)
break;
gunichar next_uc = g_utf8_get_char(&line[end]);
if (!g_unichar_isalnum(next_uc) && next_uc != '\'')
break;
end += next_ch_len;
}
char* word = g_strndup(&line[start], end - start);
gboolean misspelled = spellcheck_is_misspelled(word);
g_free(word);
if (misspelled) {
wattron(inp_win, theme_attrs(THEME_INPUT_MISSPELLED));
}
// add the word
size_t word_pos = start;
while (word_pos < end) {
size_t cur_ch_len = mbrlen(&line[word_pos], MB_CUR_MAX, NULL);
waddnstr(inp_win, &line[word_pos], cur_ch_len);
word_pos += cur_ch_len;
}
if (misspelled) {
wattroff(inp_win, theme_attrs(THEME_INPUT_MISSPELLED));
}
i = end - 1;
continue;
}
}
waddnstr(inp_win, c, ch_len);
}

View File

@@ -284,6 +284,7 @@ void cons_console_setting(void);
void cons_flash_setting(void);
void cons_tray_setting(void);
void cons_splash_setting(void);
void cons_spellcheck_setting(void);
void cons_titlebar_setting(void);
void cons_vercheck_setting(void);
void cons_occupants_setting(void);

View File

@@ -1008,6 +1008,10 @@ cons_splash_setting(void)
{
}
void
cons_spellcheck_setting(void)
{
}
void
cons_vercheck_setting(void)
{
}