Added support to change the spell-checking language.
Bug 720335 * src/client/composer/spell-check-popover.vala Implemented a GtkPopover allowing the user to select a subset of the currently installed dictionaries for the spell checking in the composer widget. * src/client/util/util-international-vala Added detection of installed dictionaries and proper translation of the available languages. This requires Enchant as an additional dependency. * src/client/application/geary-config.vala Added keys spell-check-visible-languages and spell-check-languages in GSettings.
This commit is contained in:
parent
32a7f76360
commit
cae4b443c6
10 changed files with 651 additions and 16 deletions
|
|
@ -23,6 +23,32 @@ set(VERSION "0.11.0")
|
||||||
set(VERSION_INFO "Release")
|
set(VERSION_INFO "Release")
|
||||||
set(LANGUAGE_SUPPORT_DIRECTORY ${CMAKE_INSTALL_PREFIX}/share/locale)
|
set(LANGUAGE_SUPPORT_DIRECTORY ${CMAKE_INSTALL_PREFIX}/share/locale)
|
||||||
|
|
||||||
|
if (NOT ISO_CODE_639_XML)
|
||||||
|
find_path(ISOCODES_DIRECTORY NAMES iso_639.xml PATHS ${CMAKE_INSTALL_PREFIX} /usr/share/xml/iso-codes)
|
||||||
|
if (ISOCODES_DIRECTORY)
|
||||||
|
set(ISO_CODE_639_XML ${ISOCODES_DIRECTORY}/iso_639.xml)
|
||||||
|
else ()
|
||||||
|
message(WARNING "File iso_639.xml not found. Please specify it manually using cmake -DISO_CODE_639_XML=/path/to/iso_639.xml")
|
||||||
|
endif ()
|
||||||
|
else ()
|
||||||
|
if (NOT EXISTS ${ISO_CODE_639_XML})
|
||||||
|
message(WARNING "The path to iso_639.xml specified in ISO_CODE_639_XML is not valid.")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (NOT ISO_CODE_3166_XML)
|
||||||
|
find_path(ISOCODES_DIRECTORY NAMES iso_3166.xml PATHS ${CMAKE_INSTALL_PREFIX} /usr/share/xml/iso-codes)
|
||||||
|
if (ISOCODES_DIRECTORY)
|
||||||
|
set(ISO_CODE_3166_XML ${ISOCODES_DIRECTORY}/iso_3166.xml)
|
||||||
|
else ()
|
||||||
|
message(WARNING "File iso_3166.xml not found. Please specify it manually using cmake -DISO_CODE_3166_XML=/path/to/iso_3166.xml")
|
||||||
|
endif ()
|
||||||
|
else ()
|
||||||
|
if (NOT EXISTS ${ISO_CODE_3166_XML})
|
||||||
|
message(WARNING "The path to iso_3166.xml specified in ISO_CODE_3166_XML is not valid.")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
# Packaging filenamesnames.
|
# Packaging filenamesnames.
|
||||||
set(ARCHIVE_BASE_NAME ${CMAKE_PROJECT_NAME}-${VERSION})
|
set(ARCHIVE_BASE_NAME ${CMAKE_PROJECT_NAME}-${VERSION})
|
||||||
set(ARCHIVE_FULL_NAME ${ARCHIVE_BASE_NAME}.tar.xz)
|
set(ARCHIVE_FULL_NAME ${ARCHIVE_BASE_NAME}.tar.xz)
|
||||||
|
|
@ -58,6 +84,8 @@ find_package(PkgConfig)
|
||||||
pkg_check_modules(LIBUNITY QUIET unity>=5.12.0)
|
pkg_check_modules(LIBUNITY QUIET unity>=5.12.0)
|
||||||
pkg_check_modules(LIBMESSAGINGMENU QUIET messaging-menu>=12.10.2)
|
pkg_check_modules(LIBMESSAGINGMENU QUIET messaging-menu>=12.10.2)
|
||||||
|
|
||||||
|
pkg_check_modules(ENCHANT QUIET enchant)
|
||||||
|
|
||||||
pkg_check_modules(SQLITE311 QUIET sqlite3>=3.11.0)
|
pkg_check_modules(SQLITE311 QUIET sqlite3>=3.11.0)
|
||||||
pkg_check_modules(SQLITE312 QUIET sqlite3>=3.12.0)
|
pkg_check_modules(SQLITE312 QUIET sqlite3>=3.12.0)
|
||||||
if (SQLITE311_FOUND AND NOT SQLITE312_FOUND)
|
if (SQLITE311_FOUND AND NOT SQLITE312_FOUND)
|
||||||
|
|
|
||||||
34
bindings/vapi/enchant.vapi
Normal file
34
bindings/vapi/enchant.vapi
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[CCode (cheader_filename = "enchant.h")]
|
||||||
|
namespace Enchant {
|
||||||
|
public delegate void BrokerDescribeFn (string provider_name, string provider_desc, string provider_dll_file);
|
||||||
|
public delegate void DictDescribeFn (string lang_tag, string provider_name, string provider_desc, string provider_file);
|
||||||
|
|
||||||
|
[Compact]
|
||||||
|
[CCode (free_function = "enchant_broker_free")]
|
||||||
|
public class Broker {
|
||||||
|
[CCode (cname = "enchant_broker_init")]
|
||||||
|
public Broker ();
|
||||||
|
|
||||||
|
public unowned Dict request_dict (string tag);
|
||||||
|
public unowned Dict request_pwl_dict (string pwl);
|
||||||
|
public void free_dict (Dict dict);
|
||||||
|
public int dict_exists (string tag);
|
||||||
|
public void set_ordering (string tag, string ordering);
|
||||||
|
public void describe (BrokerDescribeFn fn);
|
||||||
|
public void list_dicts (DictDescribeFn fn);
|
||||||
|
public unowned string get_error ();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Compact]
|
||||||
|
public class Dict {
|
||||||
|
public int check (string word, long len = -1);
|
||||||
|
public unowned string[] suggest (string word, long len = -1);
|
||||||
|
public void free_string_list ([CCode (array_length = false)] string[] string_list);
|
||||||
|
public void add_to_session (string word, long len = -1);
|
||||||
|
public int is_in_session (string word, long len = -1);
|
||||||
|
public void store_replacement ( string mis, long mis_len, string cor, long cor_len);
|
||||||
|
public void add_to_pwl ( string word, long len = -1);
|
||||||
|
public void describe (DictDescribeFn fn);
|
||||||
|
public unowned string get_error ();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,18 @@
|
||||||
<summary>enable inline spell checking</summary>
|
<summary>enable inline spell checking</summary>
|
||||||
<description>True to spell check while typing.</description>
|
<description>True to spell check while typing.</description>
|
||||||
</key>
|
</key>
|
||||||
|
|
||||||
|
<key name="spell-check-languages" type="as">
|
||||||
|
<default>[]</default>
|
||||||
|
<summary>Languages that shall be used in the spell checker</summary>
|
||||||
|
<description>List of the languages to use in the spell checker</description>
|
||||||
|
</key>
|
||||||
|
|
||||||
|
<key name="spell-check-visible-languages" type="as">
|
||||||
|
<default>[]</default>
|
||||||
|
<summary>Languages that are displayed in the spell checker popover.</summary>
|
||||||
|
<description>List of languages that are always displayed in the popover of the spell checker.</description>
|
||||||
|
</key>
|
||||||
<key name="play-sounds" type="b">
|
<key name="play-sounds" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>enable notification sounds</summary>
|
<summary>enable notification sounds</summary>
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,7 @@ client/composer/contact-entry-completion.vala
|
||||||
client/composer/contact-list-store.vala
|
client/composer/contact-list-store.vala
|
||||||
client/composer/email-entry.vala
|
client/composer/email-entry.vala
|
||||||
client/composer/scrollable-overlay.vala
|
client/composer/scrollable-overlay.vala
|
||||||
|
client/composer/spell-check-popover.vala
|
||||||
client/composer/webview-edit-fixer.vala
|
client/composer/webview-edit-fixer.vala
|
||||||
|
|
||||||
client/conversation-list/conversation-list-cell-renderer.vala
|
client/conversation-list/conversation-list-cell-renderer.vala
|
||||||
|
|
@ -495,6 +496,7 @@ pkg_check_modules(DEPS REQUIRED
|
||||||
gcr-3>=3.10.1
|
gcr-3>=3.10.1
|
||||||
gobject-introspection-1.0
|
gobject-introspection-1.0
|
||||||
webkitgtk-3.0>=2.4.0
|
webkitgtk-3.0>=2.4.0
|
||||||
|
enchant>=1.6
|
||||||
${EXTRA_CLIENT_PKG_CONFIG}
|
${EXTRA_CLIENT_PKG_CONFIG}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -505,7 +507,7 @@ set(ENGINE_PACKAGES
|
||||||
# webkitgtk-3.0 is listed as a custom VAPI (below) to ensure it's treated as a dependency and
|
# webkitgtk-3.0 is listed as a custom VAPI (below) to ensure it's treated as a dependency and
|
||||||
# built before compilation
|
# built before compilation
|
||||||
set(CLIENT_PACKAGES
|
set(CLIENT_PACKAGES
|
||||||
gtk+-3.0 libsecret-1 libsoup-2.4 libnotify libcanberra gcr-3 ${EXTRA_CLIENT_PACKAGES}
|
gtk+-3.0 libsecret-1 libsoup-2.4 libnotify libcanberra gcr-3 enchant ${EXTRA_CLIENT_PACKAGES}
|
||||||
)
|
)
|
||||||
|
|
||||||
set(CONSOLE_PACKAGES
|
set(CONSOLE_PACKAGES
|
||||||
|
|
@ -523,6 +525,8 @@ set(CFLAGS
|
||||||
-D_GSETTINGS_DIR=\"${CMAKE_BINARY_DIR}/gsettings\"
|
-D_GSETTINGS_DIR=\"${CMAKE_BINARY_DIR}/gsettings\"
|
||||||
-DGETTEXT_PACKAGE=\"${GETTEXT_PACKAGE}\"
|
-DGETTEXT_PACKAGE=\"${GETTEXT_PACKAGE}\"
|
||||||
-DLANGUAGE_SUPPORT_DIRECTORY=\"${LANGUAGE_SUPPORT_DIRECTORY}\"
|
-DLANGUAGE_SUPPORT_DIRECTORY=\"${LANGUAGE_SUPPORT_DIRECTORY}\"
|
||||||
|
-DISO_CODE_639_XML=\"${ISO_CODE_639_XML}\"
|
||||||
|
-DISO_CODE_3166_XML=\"${ISO_CODE_3166_XML}\"
|
||||||
-DGCR_API_SUBJECT_TO_CHANGE
|
-DGCR_API_SUBJECT_TO_CHANGE
|
||||||
-g
|
-g
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ public class Configuration {
|
||||||
public const string STARTUP_NOTIFICATIONS_KEY = "startup-notifications";
|
public const string STARTUP_NOTIFICATIONS_KEY = "startup-notifications";
|
||||||
public const string ASK_OPEN_ATTACHMENT_KEY = "ask-open-attachment";
|
public const string ASK_OPEN_ATTACHMENT_KEY = "ask-open-attachment";
|
||||||
public const string COMPOSE_AS_HTML_KEY = "compose-as-html";
|
public const string COMPOSE_AS_HTML_KEY = "compose-as-html";
|
||||||
|
public const string SPELL_CHECK_VISIBLE_LANGUAGES = "spell-check-visible-languages";
|
||||||
|
public const string SPELL_CHECK_LANGUAGES = "spell-check-languages";
|
||||||
|
|
||||||
public Settings settings { get; private set; }
|
public Settings settings { get; private set; }
|
||||||
public Settings gnome_interface;
|
public Settings gnome_interface;
|
||||||
|
|
@ -77,6 +79,24 @@ public class Configuration {
|
||||||
get { return settings.get_boolean(SPELL_CHECK_KEY); }
|
get { return settings.get_boolean(SPELL_CHECK_KEY); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string[] spell_check_languages {
|
||||||
|
owned get {
|
||||||
|
return settings.get_strv(SPELL_CHECK_LANGUAGES);
|
||||||
|
}
|
||||||
|
set { settings.set_strv(SPELL_CHECK_LANGUAGES, value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] spell_check_visible_languages {
|
||||||
|
owned get {
|
||||||
|
string[] langs = settings.get_strv(SPELL_CHECK_VISIBLE_LANGUAGES);
|
||||||
|
if (langs.length == 0) {
|
||||||
|
langs = International.get_user_preferred_languages();
|
||||||
|
}
|
||||||
|
return langs;
|
||||||
|
}
|
||||||
|
set { settings.set_strv(SPELL_CHECK_VISIBLE_LANGUAGES, value); }
|
||||||
|
}
|
||||||
|
|
||||||
public bool play_sounds {
|
public bool play_sounds {
|
||||||
get { return settings.get_boolean(PLAY_SOUNDS_KEY); }
|
get { return settings.get_boolean(PLAY_SOUNDS_KEY); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
public class ComposerToolbar : PillToolbar {
|
public class ComposerToolbar : PillToolbar {
|
||||||
|
|
||||||
public string label_text { get; set; }
|
public string label_text { get; set; }
|
||||||
|
|
||||||
|
public Gtk.Button select_dictionary_button;
|
||||||
|
|
||||||
public ComposerToolbar(Gtk.ActionGroup toolbar_action_group, Gtk.Menu menu) {
|
public ComposerToolbar(Gtk.ActionGroup toolbar_action_group, Gtk.Menu menu) {
|
||||||
base(toolbar_action_group);
|
base(toolbar_action_group);
|
||||||
|
|
@ -36,6 +38,12 @@ public class ComposerToolbar : PillToolbar {
|
||||||
insert.add(create_toolbar_button(null, ComposerWidget.ACTION_REMOVE_FORMAT));
|
insert.add(create_toolbar_button(null, ComposerWidget.ACTION_REMOVE_FORMAT));
|
||||||
add_start(create_pill_buttons(insert));
|
add_start(create_pill_buttons(insert));
|
||||||
|
|
||||||
|
// Select dictionary
|
||||||
|
insert.clear();
|
||||||
|
select_dictionary_button = create_toolbar_button(null, ComposerWidget.ACTION_SELECT_DICTIONARY);
|
||||||
|
insert.add(select_dictionary_button);
|
||||||
|
add_start(create_pill_buttons(insert));
|
||||||
|
|
||||||
// Menu.
|
// Menu.
|
||||||
insert.clear();
|
insert.clear();
|
||||||
insert.add(create_menu_button(null, menu, ComposerWidget.ACTION_MENU));
|
insert.add(create_menu_button(null, menu, ComposerWidget.ACTION_MENU));
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
public const string ACTION_SEND = "send";
|
public const string ACTION_SEND = "send";
|
||||||
public const string ACTION_ADD_ATTACHMENT = "add attachment";
|
public const string ACTION_ADD_ATTACHMENT = "add attachment";
|
||||||
public const string ACTION_ADD_ORIGINAL_ATTACHMENTS = "add original attachments";
|
public const string ACTION_ADD_ORIGINAL_ATTACHMENTS = "add original attachments";
|
||||||
|
public const string ACTION_SELECT_DICTIONARY = "select dictionary";
|
||||||
|
|
||||||
private const string DRAFT_SAVED_TEXT = _("Saved");
|
private const string DRAFT_SAVED_TEXT = _("Saved");
|
||||||
private const string DRAFT_SAVING_TEXT = _("Saving");
|
private const string DRAFT_SAVING_TEXT = _("Saving");
|
||||||
|
|
@ -250,7 +251,9 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
private Gtk.MenuItem html_item2;
|
private Gtk.MenuItem html_item2;
|
||||||
private Gtk.MenuItem extended_item;
|
private Gtk.MenuItem extended_item;
|
||||||
|
|
||||||
|
private ComposerToolbar composer_toolbar;
|
||||||
private Gtk.ActionGroup actions;
|
private Gtk.ActionGroup actions;
|
||||||
|
private SpellCheckPopover? spell_check_popover = null;
|
||||||
private string? hover_url = null;
|
private string? hover_url = null;
|
||||||
private bool action_flag = false;
|
private bool action_flag = false;
|
||||||
private bool is_attachment_overlay_visible = false;
|
private bool is_attachment_overlay_visible = false;
|
||||||
|
|
@ -405,7 +408,7 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
actions.get_action(ACTION_OUTDENT).icon_name = "format-indent-less-symbolic";
|
actions.get_action(ACTION_OUTDENT).icon_name = "format-indent-less-symbolic";
|
||||||
}
|
}
|
||||||
|
|
||||||
ComposerToolbar composer_toolbar = new ComposerToolbar(actions, menu);
|
composer_toolbar = new ComposerToolbar(actions, menu);
|
||||||
Gtk.Alignment toolbar_area = (Gtk.Alignment) builder.get_object("toolbar area");
|
Gtk.Alignment toolbar_area = (Gtk.Alignment) builder.get_object("toolbar area");
|
||||||
toolbar_area.add(composer_toolbar);
|
toolbar_area.add(composer_toolbar);
|
||||||
bind_property("toolbar-text", composer_toolbar, "label-text", BindingFlags.SYNC_CREATE);
|
bind_property("toolbar-text", composer_toolbar, "label-text", BindingFlags.SYNC_CREATE);
|
||||||
|
|
@ -447,6 +450,7 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
actions.get_action(ACTION_SEND).activate.connect(on_send);
|
actions.get_action(ACTION_SEND).activate.connect(on_send);
|
||||||
actions.get_action(ACTION_ADD_ATTACHMENT).activate.connect(on_add_attachment_button_clicked);
|
actions.get_action(ACTION_ADD_ATTACHMENT).activate.connect(on_add_attachment_button_clicked);
|
||||||
actions.get_action(ACTION_ADD_ORIGINAL_ATTACHMENTS).activate.connect(on_pending_attachments_button_clicked);
|
actions.get_action(ACTION_ADD_ORIGINAL_ATTACHMENTS).activate.connect(on_pending_attachments_button_clicked);
|
||||||
|
actions.get_action(ACTION_SELECT_DICTIONARY).activate.connect(on_select_dictionary_clicked);
|
||||||
|
|
||||||
ui = new Gtk.UIManager();
|
ui = new Gtk.UIManager();
|
||||||
ui.insert_action_group(actions, 0);
|
ui.insert_action_group(actions, 0);
|
||||||
|
|
@ -578,6 +582,8 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
|
|
||||||
WebKit.WebSettings s = editor.settings;
|
WebKit.WebSettings s = editor.settings;
|
||||||
s.enable_spell_checking = GearyApplication.instance.config.spell_check;
|
s.enable_spell_checking = GearyApplication.instance.config.spell_check;
|
||||||
|
s.spell_checking_languages = string.joinv(",",
|
||||||
|
GearyApplication.instance.config.spell_check_languages);
|
||||||
s.auto_load_images = false;
|
s.auto_load_images = false;
|
||||||
s.enable_scripts = false;
|
s.enable_scripts = false;
|
||||||
s.enable_java_applet = false;
|
s.enable_java_applet = false;
|
||||||
|
|
@ -800,8 +806,10 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
|
|
||||||
set_focus(); // Focus in the GTK widget hierarchy
|
set_focus(); // Focus in the GTK widget hierarchy
|
||||||
|
|
||||||
// Ensure the editor is in correct mode re HTML
|
// Ensure the editor is in correct mode re HTML and that the spell checker
|
||||||
|
// is visible only when needed
|
||||||
on_compose_as_html();
|
on_compose_as_html();
|
||||||
|
on_spell_check_changed();
|
||||||
|
|
||||||
Util.DOM.bind_event(editor,"a", "click", (Callback) on_link_clicked, this);
|
Util.DOM.bind_event(editor,"a", "click", (Callback) on_link_clicked, this);
|
||||||
update_actions();
|
update_actions();
|
||||||
|
|
@ -2056,6 +2064,7 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
|
|
||||||
private void on_spell_check_changed() {
|
private void on_spell_check_changed() {
|
||||||
editor.settings.enable_spell_checking = GearyApplication.instance.config.spell_check;
|
editor.settings.enable_spell_checking = GearyApplication.instance.config.spell_check;
|
||||||
|
actions.get_action(ACTION_SELECT_DICTIONARY).visible = editor.settings.enable_spell_checking;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This overrides the keypress handling for the *widget*; the WebView editor's keypress overrides
|
// This overrides the keypress handling for the *widget*; the WebView editor's keypress overrides
|
||||||
|
|
@ -2165,7 +2174,18 @@ public class ComposerWidget : Gtk.EventBox {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void on_select_dictionary_clicked() {
|
||||||
|
if (spell_check_popover == null) {
|
||||||
|
spell_check_popover = new SpellCheckPopover(composer_toolbar.select_dictionary_button);
|
||||||
|
spell_check_popover.selection_changed.connect((active_langs) => {
|
||||||
|
editor.settings.spell_checking_languages = string.joinv(",", active_langs);
|
||||||
|
GearyApplication.instance.config.spell_check_languages = active_langs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
spell_check_popover.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
private bool on_editor_key_press(Gdk.EventKey event) {
|
private bool on_editor_key_press(Gdk.EventKey event) {
|
||||||
// widget's keypress override doesn't receive non-modifier keys when the editor processes
|
// widget's keypress override doesn't receive non-modifier keys when the editor processes
|
||||||
// them, regardless if true or false is called; this deals with that issue (specifically
|
// them, regardless if true or false is called; this deals with that issue (specifically
|
||||||
|
|
|
||||||
309
src/client/composer/spell-check-popover.vala
Normal file
309
src/client/composer/spell-check-popover.vala
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
/* Copyright 2016 Software Freedom Conservancy Inc.
|
||||||
|
*
|
||||||
|
* This software is licensed under the GNU Lesser General Public License
|
||||||
|
* (version 2.1 or later). See the COPYING file in this distribution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
public class SpellCheckPopover {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This signal is emitted then the selection of rows changes.
|
||||||
|
*
|
||||||
|
* @param active_langs The new set of active dictionaries after the
|
||||||
|
* selection has changed.
|
||||||
|
*/
|
||||||
|
public signal void selection_changed(string[] active_langs);
|
||||||
|
|
||||||
|
private Gtk.Popover? popover = null;
|
||||||
|
private GLib.GenericSet<string> selected_rows;
|
||||||
|
private bool is_expanded = false;
|
||||||
|
private Gtk.ListBox langs_list;
|
||||||
|
private Gtk.SearchEntry search_box;
|
||||||
|
private Gtk.ScrolledWindow view;
|
||||||
|
private Gtk.Box content;
|
||||||
|
|
||||||
|
private enum SpellCheckStatus {
|
||||||
|
INACTIVE,
|
||||||
|
ACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SpellCheckLangRow : Gtk.ListBoxRow {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This signal is emitted then the user activates the row.
|
||||||
|
*
|
||||||
|
* @param lang_code The language code associated to this row (such as en_US).
|
||||||
|
* @param status true if the associated dictionary should be enabled, false if it should be
|
||||||
|
* disabled.
|
||||||
|
*/
|
||||||
|
public signal void toggled (string lang_code, bool status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Signal when the visibility has changed.
|
||||||
|
*/
|
||||||
|
public signal void visibility_changed ();
|
||||||
|
|
||||||
|
private string lang_code;
|
||||||
|
private string lang_name;
|
||||||
|
private string country_name;
|
||||||
|
private bool is_lang_visible;
|
||||||
|
private Gtk.Image active_image;
|
||||||
|
private Gtk.Button remove_button;
|
||||||
|
private SpellCheckStatus lang_active = SpellCheckStatus.INACTIVE;
|
||||||
|
|
||||||
|
public SpellCheckLangRow (string lang_code) {
|
||||||
|
this.lang_code = lang_code;
|
||||||
|
Gtk.Box box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
|
||||||
|
|
||||||
|
lang_name = International.language_name_from_locale(lang_code);
|
||||||
|
country_name = International.country_name_from_locale(lang_code);
|
||||||
|
|
||||||
|
string label_text = lang_name;
|
||||||
|
if (country_name != null)
|
||||||
|
label_text += " (" + country_name + ")";
|
||||||
|
Gtk.Label label = new Gtk.Label(label_text);
|
||||||
|
label.set_xalign(0.0f);
|
||||||
|
label.set_size_request(-1, 24);
|
||||||
|
|
||||||
|
box.pack_start(label, false, false);
|
||||||
|
|
||||||
|
Gtk.IconSize sz = Gtk.IconSize.SMALL_TOOLBAR;
|
||||||
|
active_image = new Gtk.Image.from_icon_name("object-select-symbolic", sz);
|
||||||
|
remove_button = new Gtk.Button();
|
||||||
|
remove_button.set_relief(Gtk.ReliefStyle.NONE);
|
||||||
|
box.pack_start(active_image, false, false, 6);
|
||||||
|
box.pack_start(remove_button, true, true);
|
||||||
|
remove_button.halign = Gtk.Align.END; // Make the button stay at the right end of the screen
|
||||||
|
|
||||||
|
remove_button.clicked.connect(on_remove_clicked);
|
||||||
|
|
||||||
|
is_lang_visible = false;
|
||||||
|
foreach (string visible_lang in GearyApplication.instance.config.spell_check_visible_languages) {
|
||||||
|
if (visible_lang == lang_code)
|
||||||
|
is_lang_visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string active_lang in GearyApplication.instance.config.spell_check_languages) {
|
||||||
|
if (active_lang == lang_code)
|
||||||
|
lang_active = SpellCheckStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_images();
|
||||||
|
add(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_lang_active() {
|
||||||
|
return lang_active == SpellCheckStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update_images() {
|
||||||
|
Gtk.IconSize sz = Gtk.IconSize.SMALL_TOOLBAR;
|
||||||
|
|
||||||
|
switch (lang_active) {
|
||||||
|
case SpellCheckStatus.ACTIVE:
|
||||||
|
active_image.set_from_icon_name("object-select-symbolic", sz);
|
||||||
|
break;
|
||||||
|
case SpellCheckStatus.INACTIVE:
|
||||||
|
active_image.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_lang_visible) {
|
||||||
|
remove_button.set_image(new Gtk.Image.from_icon_name("list-remove-symbolic", sz));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
remove_button.set_image(new Gtk.Image.from_icon_name("list-add-symbolic", sz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_remove_clicked() {
|
||||||
|
is_lang_visible = ! is_lang_visible;
|
||||||
|
|
||||||
|
update_images();
|
||||||
|
|
||||||
|
if (!is_lang_visible && lang_active == SpellCheckStatus.ACTIVE)
|
||||||
|
set_lang_active(SpellCheckStatus.INACTIVE);
|
||||||
|
|
||||||
|
if (is_lang_visible) {
|
||||||
|
string[] visible_langs = GearyApplication.instance.config.spell_check_visible_languages;
|
||||||
|
visible_langs += lang_code;
|
||||||
|
GearyApplication.instance.config.spell_check_visible_languages = visible_langs;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
string[] visible_langs = {};
|
||||||
|
foreach (string lang in GearyApplication.instance.config.spell_check_visible_languages) {
|
||||||
|
if (lang != lang_code)
|
||||||
|
visible_langs += lang;
|
||||||
|
}
|
||||||
|
GearyApplication.instance.config.spell_check_visible_languages = visible_langs;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibility_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool match_filter(string filter) {
|
||||||
|
string filter_down = filter.down();
|
||||||
|
return ((lang_name != null ? filter_down in lang_name.down() : false) ||
|
||||||
|
(country_name != null ? filter_down in country_name.down() : false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void set_lang_active(SpellCheckStatus active) {
|
||||||
|
lang_active = active;
|
||||||
|
|
||||||
|
switch (active) {
|
||||||
|
case SpellCheckStatus.ACTIVE:
|
||||||
|
// If the lang is not visible make it visible now
|
||||||
|
if (!is_lang_visible) {
|
||||||
|
string[] visible_langs = GearyApplication.instance.config.spell_check_visible_languages;
|
||||||
|
visible_langs += lang_code;
|
||||||
|
GearyApplication.instance.config.spell_check_visible_languages = visible_langs;
|
||||||
|
is_lang_visible = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SpellCheckStatus.INACTIVE:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_images();
|
||||||
|
this.toggled(lang_code, active == SpellCheckStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handle_activation(SpellCheckPopover spell_check_popover) {
|
||||||
|
// Make sure that we do not enable the language when the user is just
|
||||||
|
// trying to remove it from the list.
|
||||||
|
if (!visible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (lang_active) {
|
||||||
|
case SpellCheckStatus.ACTIVE:
|
||||||
|
set_lang_active(SpellCheckStatus.INACTIVE);
|
||||||
|
break;
|
||||||
|
case SpellCheckStatus.INACTIVE:
|
||||||
|
set_lang_active(SpellCheckStatus.ACTIVE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_row_visible(bool is_expanded) {
|
||||||
|
return is_lang_visible || is_expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpellCheckPopover(Gtk.Widget button) {
|
||||||
|
popover = new Gtk.Popover(button);
|
||||||
|
selected_rows = new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
setup_popover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool filter_function (Gtk.ListBoxRow row) {
|
||||||
|
string text = search_box.get_text();
|
||||||
|
SpellCheckLangRow r = row as SpellCheckLangRow;
|
||||||
|
return (r.is_row_visible(is_expanded) && r.match_filter(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setup_popover() {
|
||||||
|
// We populate the popover with the list of languages that the user wants to see
|
||||||
|
string[] languages = International.get_available_dictionaries();
|
||||||
|
|
||||||
|
content = new Gtk.Box(Gtk.Orientation.VERTICAL, 6);
|
||||||
|
search_box = new Gtk.SearchEntry();
|
||||||
|
search_box.set_placeholder_text(_("Search for more languages"));
|
||||||
|
search_box.changed.connect(on_search_box_changed);
|
||||||
|
search_box.grab_focus.connect(on_search_box_grab_focus);
|
||||||
|
content.pack_start(search_box, false, true);
|
||||||
|
|
||||||
|
view = new Gtk.ScrolledWindow(null, null);
|
||||||
|
view.set_shadow_type(Gtk.ShadowType.IN);
|
||||||
|
view.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
|
||||||
|
|
||||||
|
langs_list = new Gtk.ListBox();
|
||||||
|
langs_list.set_selection_mode(Gtk.SelectionMode.NONE);
|
||||||
|
foreach (string lang in languages) {
|
||||||
|
SpellCheckLangRow row = new SpellCheckLangRow(lang);
|
||||||
|
langs_list.add(row);
|
||||||
|
|
||||||
|
if (row.is_lang_active())
|
||||||
|
selected_rows.add(lang);
|
||||||
|
|
||||||
|
row.toggled.connect(this.on_row_toggled);
|
||||||
|
row.visibility_changed.connect(this.on_visibility_changed);
|
||||||
|
}
|
||||||
|
langs_list.row_activated.connect(on_row_activated);
|
||||||
|
view.add(langs_list);
|
||||||
|
|
||||||
|
content.pack_start(view, true, true);
|
||||||
|
|
||||||
|
langs_list.set_filter_func(this.filter_function);
|
||||||
|
|
||||||
|
view.set_size_request(350, 300);
|
||||||
|
popover.add(content);
|
||||||
|
|
||||||
|
// Make sure that the search box does not get the focus first. We want it to have it only
|
||||||
|
// if the user wants to perform an extended search.
|
||||||
|
content.set_focus_child(view);
|
||||||
|
content.set_margin_start(6);
|
||||||
|
content.set_margin_end(6);
|
||||||
|
content.set_margin_top(6);
|
||||||
|
content.set_margin_bottom(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_row_activated(Gtk.ListBoxRow row) {
|
||||||
|
SpellCheckLangRow r = row as SpellCheckLangRow;
|
||||||
|
r.handle_activation(this);
|
||||||
|
// Make sure that we update the visible languages based on the
|
||||||
|
// possibly updated is_lang_visible_properties.
|
||||||
|
langs_list.invalidate_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_search_box_changed() {
|
||||||
|
langs_list.invalidate_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_search_box_grab_focus() {
|
||||||
|
set_expanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void set_expanded(bool expanded) {
|
||||||
|
is_expanded = expanded;
|
||||||
|
langs_list.invalidate_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Toggle the visibility of the popover, and return the final status.
|
||||||
|
*
|
||||||
|
* @return true if the Popover is visible after the call, false otherwise.
|
||||||
|
*/
|
||||||
|
public bool toggle() {
|
||||||
|
if (popover.get_visible()) {
|
||||||
|
popover.hide();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Make sure that when the box is shown the list is not expanded anymore.
|
||||||
|
search_box.set_text("");
|
||||||
|
content.set_focus_child(view);
|
||||||
|
is_expanded = false;
|
||||||
|
langs_list.invalidate_filter();
|
||||||
|
|
||||||
|
popover.show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return popover.get_visible();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_row_toggled(string lang_code, bool active) {
|
||||||
|
if (active)
|
||||||
|
selected_rows.add(lang_code);
|
||||||
|
else
|
||||||
|
selected_rows.remove(lang_code);
|
||||||
|
|
||||||
|
// Signal that the selection has changed
|
||||||
|
string[] active_langs = {};
|
||||||
|
selected_rows.foreach((lang) => active_langs += lang);
|
||||||
|
this.selection_changed(active_langs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void on_visibility_changed() {
|
||||||
|
langs_list.invalidate_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -5,23 +5,216 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
extern const string LANGUAGE_SUPPORT_DIRECTORY;
|
extern const string LANGUAGE_SUPPORT_DIRECTORY;
|
||||||
|
extern const string ISO_CODE_639_XML;
|
||||||
|
extern const string ISO_CODE_3166_XML;
|
||||||
public const string TRANSLATABLE = "translatable";
|
public const string TRANSLATABLE = "translatable";
|
||||||
|
|
||||||
namespace International {
|
namespace International {
|
||||||
|
|
||||||
public const string SYSTEM_LOCALE = "";
|
private GLib.HashTable<string, string> language_names = null;
|
||||||
|
private GLib.HashTable<string, string> country_names = null;
|
||||||
|
|
||||||
void init(string package_name, string program_path, string locale = SYSTEM_LOCALE) {
|
public const string SYSTEM_LOCALE = "";
|
||||||
Intl.setlocale(LocaleCategory.ALL, locale);
|
|
||||||
Intl.bindtextdomain(package_name, get_langpack_dir_path(program_path));
|
|
||||||
Intl.bind_textdomain_codeset(package_name, "UTF-8");
|
|
||||||
Intl.textdomain(package_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Geary should be able to use langpacks from the build directory
|
void init(string package_name, string program_path, string locale = SYSTEM_LOCALE) {
|
||||||
private string get_langpack_dir_path(string program_path) {
|
Intl.setlocale(LocaleCategory.ALL, locale);
|
||||||
return LANGUAGE_SUPPORT_DIRECTORY;
|
Intl.bindtextdomain(package_name, get_langpack_dir_path(program_path));
|
||||||
}
|
Intl.bind_textdomain_codeset(package_name, "UTF-8");
|
||||||
|
Intl.textdomain(package_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Geary should be able to use langpacks from the build directory
|
||||||
|
private string get_langpack_dir_path(string program_path) {
|
||||||
|
return LANGUAGE_SUPPORT_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] get_available_dictionaries() {
|
||||||
|
string[] dictionaries = {};
|
||||||
|
|
||||||
|
Enchant.Broker broker = new Enchant.Broker();
|
||||||
|
broker.list_dicts((lang_tag, provider_name, provider_desc, provider_file) => {
|
||||||
|
dictionaries += lang_tag;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whenever regional variants of the dictionaries are available use them
|
||||||
|
// in place of the generic ones, e.g., discard en if en_US, en_GB, ...
|
||||||
|
// are installed on the system.
|
||||||
|
GLib.GenericSet<string> regional_dictionaries =
|
||||||
|
new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
foreach (string dic in dictionaries) {
|
||||||
|
if ("_" in dic) {
|
||||||
|
int underscore = dic.index_of_char('_');
|
||||||
|
regional_dictionaries.add(dic.substring(0, underscore));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GLib.List<string> filtered_dictionaries = new GLib.List<string>();
|
||||||
|
foreach (string dic in dictionaries) {
|
||||||
|
if ("_" in dic || ! regional_dictionaries.contains(dic))
|
||||||
|
filtered_dictionaries.append(dic);
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered_dictionaries.sort((dic_a, dic_b) => (dic_a < dic_b) ? -1 : 1);
|
||||||
|
|
||||||
|
dictionaries = {};
|
||||||
|
foreach (string dic in filtered_dictionaries) {
|
||||||
|
dictionaries += dic;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] get_available_locales() {
|
||||||
|
string[] locales = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
string? output = null;
|
||||||
|
GLib.Subprocess p = new GLib.Subprocess.newv({ "locale", "-a" },
|
||||||
|
GLib.SubprocessFlags.STDOUT_PIPE);
|
||||||
|
p.communicate_utf8(null, null, out output, null);
|
||||||
|
|
||||||
|
foreach (string l in output.split("\n")) {
|
||||||
|
locales += l;
|
||||||
|
}
|
||||||
|
} catch (GLib.Error e) {
|
||||||
|
return locales;
|
||||||
|
}
|
||||||
|
|
||||||
|
return locales;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Strip the information about the encoding from the locale.
|
||||||
|
*
|
||||||
|
* That is, en_US.UTF-8 is mapped to en_US, while en_GB remains
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
public string strip_encoding(string locale) {
|
||||||
|
int dot = locale.index_of_char('.');
|
||||||
|
return locale.substring(0, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] get_user_preferred_languages() {
|
||||||
|
GLib.GenericSet<string> dicts = new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
foreach (string dict in get_available_dictionaries()) {
|
||||||
|
dicts.add(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
GLib.GenericSet<string> locales = new GLib.GenericSet<string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
foreach (string locale in get_available_locales()) {
|
||||||
|
locales.add(strip_encoding(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] output = {};
|
||||||
|
unowned string[] language_names = GLib.Intl.get_language_names();
|
||||||
|
foreach (string lang in language_names) {
|
||||||
|
// Check if we have the associated locale and the dictionary installed before actually
|
||||||
|
// considering this language.
|
||||||
|
if (lang != "C" && dicts.contains(lang) && locales.contains(lang)) {
|
||||||
|
output += lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? language_name_from_locale (string locale) {
|
||||||
|
if (language_names == null) {
|
||||||
|
language_names = new HashTable<string, string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
|
||||||
|
unowned Xml.Doc doc = Xml.Parser.parse_file(ISO_CODE_639_XML);
|
||||||
|
if (doc == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
unowned Xml.Node root = doc.get_root_element();
|
||||||
|
for (unowned Xml.Node entry = root.children; entry != null; entry = entry.next) {
|
||||||
|
if (entry.type == Xml.ElementType.ELEMENT_NODE) {
|
||||||
|
string? iso_639_1 = null;
|
||||||
|
string? language_name = null;
|
||||||
|
|
||||||
|
for (unowned Xml.Attr a = entry.properties; a != null; a = a.next) {
|
||||||
|
switch (a.name) {
|
||||||
|
case "iso_639_1_code":
|
||||||
|
iso_639_1 = a.children->content;
|
||||||
|
break;
|
||||||
|
case "name":
|
||||||
|
language_name = a.children->content;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language_name != null) {
|
||||||
|
if (iso_639_1 != null) {
|
||||||
|
language_names.insert(iso_639_1, language_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the name of language matching only the part before the _
|
||||||
|
int pos = -1;
|
||||||
|
if ("_" in locale) {
|
||||||
|
pos = locale.index_of_char('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a translated version of the language.
|
||||||
|
string language_name = GLib.dgettext("iso_639", language_names.get(locale.substring(0, pos)));
|
||||||
|
|
||||||
|
return language_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? country_name_from_locale(string locale) {
|
||||||
|
if (country_names == null) {
|
||||||
|
country_names = new HashTable<string, string>(GLib.str_hash, GLib.str_equal);
|
||||||
|
|
||||||
|
unowned Xml.Doc doc = Xml.Parser.parse_file(ISO_CODE_3166_XML);
|
||||||
|
|
||||||
|
if (doc == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
unowned Xml.Node root = doc.get_root_element();
|
||||||
|
for (unowned Xml.Node entry = root.children; entry != null; entry = entry.next) {
|
||||||
|
if (entry.type == Xml.ElementType.ELEMENT_NODE) {
|
||||||
|
string? iso_3166 = null;
|
||||||
|
string? country_name = null;
|
||||||
|
|
||||||
|
for (unowned Xml.Attr a = entry.properties; a != null; a = a.next) {
|
||||||
|
switch (a.name) {
|
||||||
|
case "alpha_2_code":
|
||||||
|
iso_3166 = a.children->content;
|
||||||
|
break;
|
||||||
|
case "name":
|
||||||
|
country_name = a.children->content;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country_name != null) {
|
||||||
|
if (iso_3166 != null) {
|
||||||
|
country_names.insert(iso_3166, country_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the name of language matching only the part before the _
|
||||||
|
int pos = -1;
|
||||||
|
if ("_" in locale) {
|
||||||
|
pos = locale.index_of_char('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
string country_name = GLib.dgettext("iso_3166", country_names.get(locale.substring(pos+1)));
|
||||||
|
|
||||||
|
return country_name;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,13 @@
|
||||||
<property name="icon_name">edit-copy-symbolic</property>
|
<property name="icon_name">edit-copy-symbolic</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkAction" id="select dictionary">
|
||||||
|
<property name="label" translatable="yes">Select spell checking language</property>
|
||||||
|
<property name="short_label" translatable="yes">Spelling language</property>
|
||||||
|
<property name="icon_name">accessories-dictionary-symbolic</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
<object class="GtkBox" id="composer">
|
<object class="GtkBox" id="composer">
|
||||||
<property name="visible">True</property>
|
<property name="visible">True</property>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue