diff --git a/meson.build b/meson.build index e556b3c6..7ebd1ed4 100644 --- a/meson.build +++ b/meson.build @@ -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', diff --git a/meson_options.txt b/meson_options.txt index 363c6360..03018334 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -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', diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index 9799134d..34e92446 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -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,10 @@ 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, "lang"); + autocomplete_add(disco_ac, "info"); autocomplete_add(disco_ac, "items"); @@ -1748,6 +1755,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 +1784,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; diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 28b473fd..59f910f5 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -1451,6 +1451,22 @@ 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 lang ") + 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 ", "Set the spellcheck language (en_US)." }) + }, + { CMD_PREAMBLE("/autoconnect", parse_args, 1, 2, &cons_autoconnect_setting) CMD_MAINFUNC(cmd_autoconnect) diff --git a/src/command/cmd_funcs.c b/src/command/cmd_funcs.c index 68d6188a..133f54c9 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -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,37 @@ 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], "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) { diff --git a/src/command/cmd_funcs.h b/src/command/cmd_funcs.h index 72739187..10455dc0 100644 --- a/src/command/cmd_funcs.h +++ b/src/command/cmd_funcs.h @@ -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); diff --git a/src/config/preferences.c b/src/config/preferences.c index 6cc58244..aa59fa59 100644 --- a/src/config/preferences.c +++ b/src/config/preferences.c @@ -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; } diff --git a/src/config/preferences.h b/src/config/preferences.h index 1568a3d3..1708e442 100644 --- a/src/config/preferences.h +++ b/src/config/preferences.h @@ -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 diff --git a/src/config/theme.c b/src/config/theme.c index c1fb6273..92a2ef27 100644 --- a/src/config/theme.c +++ b/src/config/theme.c @@ -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; diff --git a/src/config/theme.h b/src/config/theme.h index 09923049..1d459ed7 100644 --- a/src/config/theme.h +++ b/src/config/theme.h @@ -27,6 +27,7 @@ typedef enum { THEME_MENTION, THEME_TRIGGER, THEME_INPUT_TEXT, + THEME_INPUT_MISSPELLED, THEME_TIME, THEME_TITLE_TEXT, THEME_TITLE_BRACKET, diff --git a/src/profanity.c b/src/profanity.c index 1c4e0220..ec6d3067 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -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 diff --git a/src/tools/spellcheck.c b/src/tools/spellcheck.c new file mode 100644 index 00000000..c23fa4e2 --- /dev/null +++ b/src/tools/spellcheck.c @@ -0,0 +1,107 @@ +/* + * spellcheck.c + * vim: expandtab:ts=4:sts=4:sw=4 + * + * Copyright (C) 2026 Michael Vetter + * + * SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception + */ + +#include "config.h" + +#ifdef HAVE_SPELLCHECK + +#include +#include +#include + +#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; +} + +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 diff --git a/src/tools/spellcheck.h b/src/tools/spellcheck.h new file mode 100644 index 00000000..d983106e --- /dev/null +++ b/src/tools/spellcheck.h @@ -0,0 +1,52 @@ +/* + * spellcheck.h + * vim: expandtab:ts=4:sts=4:sw=4 + * + * Copyright (C) 2026 Michael Vetter + * + * SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception + */ + +#ifndef TOOLS_SPELLCHECK_H +#define TOOLS_SPELLCHECK_H + +#include "config.h" +#include + +#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); + +#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; +} + +#endif + +#endif diff --git a/src/ui/console.c b/src/ui/console.c index 3ff31e77..57ca3fda 100644 --- a/src/ui/console.c +++ b/src/ui/console.c @@ -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(""); } diff --git a/src/ui/inputwin.c b/src/ui/inputwin.c index e96fa719..60c58894 100644 --- a/src/ui/inputwin.c +++ b/src/ui/inputwin.c @@ -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); } diff --git a/src/ui/ui.h b/src/ui/ui.h index ea0777db..4ab2ac53 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -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); diff --git a/tests/unittests/ui/stub_ui.c b/tests/unittests/ui/stub_ui.c index 2d0a200a..80328825 100644 --- a/tests/unittests/ui/stub_ui.c +++ b/tests/unittests/ui/stub_ui.c @@ -1008,6 +1008,10 @@ cons_splash_setting(void) { } void +cons_spellcheck_setting(void) +{ +} +void cons_vercheck_setting(void) { }