changeset 83287ed2d759 in tryton:default
details: https://hg.tryton.org/tryton?cmd=changeset;node=83287ed2d759
description:
        Send client email via server

        issue9449
        review292001002
diffstat:

 CHANGELOG                                                |    2 +
 doc/usage.rst                                            |   26 +-
 tryton/action/main.py                                    |   43 +-
 tryton/client.py                                         |    2 +-
 tryton/common/popup_menu.py                              |   24 +-
 tryton/common/richtext.py                                |  346 +++++++++++++
 tryton/data/pixmaps/tryton/tryton-send.svg               |    1 +
 tryton/gui/main.py                                       |   24 +-
 tryton/gui/window/email_.py                              |  371 +++++++++++---
 tryton/gui/window/form.py                                |   38 +-
 tryton/gui/window/tabcontent.py                          |   11 +-
 tryton/gui/window/view_form/view/form_gtk/richtextbox.py |  314 +------------
 12 files changed, 726 insertions(+), 476 deletions(-)

diffs (1549 lines):

diff -r 5ce1d6984748 -r 83287ed2d759 CHANGELOG
--- a/CHANGELOG Sat Sep 05 22:59:38 2020 +0200
+++ b/CHANGELOG Thu Sep 17 17:18:02 2020 +0200
@@ -1,3 +1,5 @@
+* Support e-mail template
+* Send e-mail via the server
 * Support PYSON comparison of date and datetime
 * Position copied records based on order
 * Allow configuration of default colors for graph and calendar
diff -r 5ce1d6984748 -r 83287ed2d759 doc/usage.rst
--- a/doc/usage.rst     Sat Sep 05 22:59:38 2020 +0200
+++ b/doc/usage.rst     Thu Sep 17 17:18:02 2020 +0200
@@ -197,30 +197,6 @@
 Search Limit:
   Open a dialog to set up the maximum number of records displayed on a list.
 
-.. _Menu-Options-Email:
-
-Email:
-  Open a dialog to set up an email reader.
-
-  * Command Line: The command line calling the email reader.
-  * Placeholders:
-
-    - ``${to}``: the destination email address
-    - ``${cc}``: the carbon copy email address
-    - ``${subject}``: the subject of the email
-    - ``${body}``: the body of the email
-    - ``${attachment}``: the attachment of the email
-
-  * Examples:
-
-    - Thunderbird 2 on Linux:
-      ``thunderbird -compose 
"to='${to}',cc='${cc}',subject='${subject}',body='${body}',attachment='file://${attachment}'"``
-
-    - Thunderbird 2 on Windows XP SP3:
-      ``"C:\\Program Files\\Mozilla Thunderbird\\thunderbird.exe" -compose 
to="${to}",cc="${cc}",subject="${subject}",body="${body}",attachment="${attachment}"``
-
-.. note:: The path of *Program Files* may vary dependent on the localization 
of your Windows version.
-
 .. _Menu-Options-Check_Version:
 
 Check Version:
@@ -343,7 +319,7 @@
 .. _Toolbar-Email:
 
 E-Mail...:
-  Shows all email reports for the actual view, model and record.
+  Open an editor to send an email related to the actual record.
 
 .. _Toolbar-Print:
 
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/action/main.py
--- a/tryton/action/main.py     Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/action/main.py     Thu Sep 17 17:18:02 2020 +0200
@@ -5,7 +5,7 @@
 
 import tryton.rpc as rpc
 from tryton.common import RPCProgress, RPCExecute, RPCException
-from tryton.common import message, selection, file_write, file_open, mailto
+from tryton.common import message, selection, file_write, file_open
 from tryton.config import CONFIG
 from tryton.pyson import PYSONDecoder
 
@@ -15,18 +15,13 @@
 class Action(object):
 
     @staticmethod
-    def exec_report(name, data, direct_print=False, email_print=False,
-            email=None, context=None):
+    def exec_report(name, data, direct_print=False, context=None):
         if context is None:
             context = {}
-        if email is None:
-            email = {}
         data = data.copy()
         ctx = rpc.CONTEXT.copy()
         ctx.update(context)
         ctx['direct_print'] = direct_print
-        ctx['email_print'] = email_print
-        ctx['email'] = email
         args = ('report', name, 'execute', data.get('ids', []), data, ctx)
         try:
             res = RPCProgress('execute', args).run()
@@ -39,12 +34,7 @@
             print_p = True
 
         fp_name = file_write((name, type), data)
-        if email_print:
-            mailto(to=email.get('to'), cc=email.get('cc'),
-                subject=email.get('subject'), body=email.get('body'),
-                attachment=fp_name)
-        else:
-            file_open(fp_name, type, print_p=print_p)
+        file_open(fp_name, type, print_p=print_p)
         return True
 
     @staticmethod
@@ -159,16 +149,15 @@
                 name = add_name_suffix(name, context)
             Window.create_wizard(action['wiz_name'], data,
                 direct_print=action.get('direct_print', False),
-                email_print=action.get('email_print', False),
-                email=action.get('email'), name=name,
+                name=name,
                 context=context, icon=(action.get('icon.rec_name') or ''),
                 window=action.get('window', False))
 
         elif action['type'] == 'ir.action.report':
-            Action.exec_report(action['report_name'], data,
-                    direct_print=action.get('direct_print', False),
-                    email_print=action.get('email_print', False),
-                    email=action.get('email'), context=context)
+            Action.exec_report(
+                action['report_name'], data,
+                direct_print=action.get('direct_print', False),
+                context=context)
 
         elif action['type'] == 'ir.action.url':
             if action['url']:
@@ -197,19 +186,3 @@
         elif not len(keyact) and warning:
             message(_('No action defined.'))
         return False
-
-    @staticmethod
-    def evaluate(action, atype, record):
-        '''
-        Evaluate the action with the record.
-        '''
-        action = action.copy()
-        email = {}
-        if 'pyson_email' in action:
-            email = record.expr_eval(action['pyson_email'])
-            if not email:
-                email = {}
-        if 'subject' not in email:
-            email['subject'] = action['name'].replace('_', '')
-        action['email'] = email
-        return action
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/client.py
--- a/tryton/client.py  Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/client.py  Thu Sep 17 17:18:02 2020 +0200
@@ -20,7 +20,7 @@
     .readonly entry, .readonly text {
         background-color: @insensitive_bg_color;
     }
-    .required entry, .required text {
+    .required entry, entry.required, .required text, text.required {
         border-color: darker(@unfocused_borders);
     }
     .invalid entry, entry.invalid, .invalid text, text.invalid {
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/common/popup_menu.py
--- a/tryton/common/popup_menu.py       Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/common/popup_menu.py       Thu Sep 17 17:18:02 2020 +0200
@@ -5,10 +5,12 @@
 from gi.repository import Gtk, Gdk
 
 from tryton.common import RPCExecute, RPCException
+from tryton.common.common import selection
 from tryton.gui.window.view_form.screen import Screen
 from tryton.action import Action
 from tryton.gui.window import Window
 from tryton.gui.window.attachment import Attachment
+from tryton.gui.window.email_ import Email
 from tryton.gui.window.note import Note
 
 _ = gettext.gettext
@@ -41,7 +43,6 @@
 
     def activate(menuitem, action, atype):
         rec = load(record)
-        action = Action.evaluate(action, atype, rec)
         data = {
             'model': model,
             'id': rec.id,
@@ -61,6 +62,20 @@
     def note(menuitem):
         Note(load(record), None)
 
+    def is_report(action):
+        return action['type'] == 'ir.action.report'
+
+    def email(menuitem, toolbar):
+        rec = load(record)
+        prints = filter(is_report, toolbar['print'])
+        emails = {e['name']: e['id'] for e in toolbar['emails']}
+        template = selection(_("Template"), emails, alwaysask=True)
+        if template:
+            template = template[1]
+        Email(
+            '%s: %s' % (title, rec.rec_name()), rec, prints,
+            template=template)
+
     def edit(menuitem):
         with Window(hide_current=True, allow_similar=True):
             Window.create(model,
@@ -103,7 +118,6 @@
                 ('action', 'tryton-launch', _('Actions...'), None),
                 ('relate', 'tryton-link', _('Relate...'), None),
                 ('print', 'tryton-open', _('Report...'), 'open'),
-                ('print', 'tryton-email', _('E-Mail...'), 'email'),
                 ('print', 'tryton-print', _('Print...'), 'print'),
                 ):
             if len(action_menu):
@@ -121,8 +135,10 @@
                 submenu.append(item)
                 if flavor == 'print':
                     action['direct_print'] = True
-                elif flavor == 'email':
-                    action['email_print'] = True
                 item.connect('activate', activate, action, atype)
             menu.show_all()
+
+        email_item = Gtk.MenuItem(label=_('E-Mail...'))
+        action_menu.append(email_item)
+        email_item.connect('activate', email, toolbar)
     menu.show_all()
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/common/richtext.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/tryton/common/richtext.py Thu Sep 17 17:18:02 2020 +0200
@@ -0,0 +1,346 @@
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+import gettext
+from contextlib import contextmanager
+
+from gi.repository import Gtk, Gdk
+
+from tryton.common import IconFactory
+from tryton.common.htmltextbuffer import (
+    serialize, deserialize,
+    setup_tags, register_foreground, remove_tags,
+    FAMILIES, SIZE2SCALE, MIME, use_serialize_func)
+
+_ = gettext.gettext
+SIZES = sorted(SIZE2SCALE.keys())
+
+
+def register_format(textview):
+    buffer_ = textview.get_buffer()
+    setup_tags(buffer_)
+    buffer_.register_serialize_format(str(MIME), serialize, None)
+    buffer_.register_deserialize_format(str(MIME), deserialize, None)
+
+
+def set_content(textview, content):
+    with disable_text_style(textview):
+        buffer_ = textview.get_buffer()
+        start = buffer_.get_start_iter()
+        end = buffer_.get_end_iter()
+        buffer_.delete(start, end)
+        if use_serialize_func:
+            buffer_.deserialize(buffer_, MIME, start, content)
+        else:
+            deserialize(
+                buffer_, buffer_, start, content,
+                buffer_.deserialize_get_can_create_tags(MIME), None)
+
+
+def get_content(textview):
+    buffer_ = textview.get_buffer()
+    start = buffer_.get_start_iter()
+    end = buffer_.get_end_iter()
+    if use_serialize_func:
+        return buffer_.serialize(buffer_, MIME, start, end)
+    else:
+        return serialize(buffer_, buffer_, start, end, None)
+
+
+def add_toolbar(textview):
+    toolbar = Gtk.Toolbar()
+
+    tag_widgets = {}
+    colors = {}
+
+    for icon, label in [
+            ('bold', _("Bold")),
+            ('italic', _("Italic")),
+            ('underline', _("Underline")),
+            ]:
+        button = Gtk.ToggleToolButton()
+        button.set_icon_widget(IconFactory.get_image(
+                'tryton-format-%s' % icon,
+                Gtk.IconSize.SMALL_TOOLBAR))
+        button.set_label(label)
+        button.connect('toggled', _toggle_props, icon, textview)
+        toolbar.insert(button, -1)
+        tag_widgets[icon] = button
+
+    toolbar.insert(Gtk.SeparatorToolItem(), -1)
+
+    for name, options, active in [
+            ('family', FAMILIES, FAMILIES.index('normal')),
+            ('size', SIZES, SIZES.index('4')),
+            ]:
+        combobox = Gtk.ComboBoxText()
+        for option in options:
+            combobox.append_text(option)
+        combobox.set_active(active)
+        combobox.set_focus_on_click(False)
+        combobox.connect('changed', _change_props, name, textview)
+        tool = Gtk.ToolItem()
+        tool.add(combobox)
+        toolbar.insert(tool, -1)
+        tag_widgets[name] = combobox
+
+    toolbar.insert(Gtk.SeparatorToolItem(), -1)
+
+    button = None
+    for name, label in [
+            ('left', _("Align Left")),
+            ('center', _("Align Center")),
+            ('right', _("Align Right")),
+            ('justify', _("Justify")),
+            ]:
+        icon = 'tryton-format-align-%s' % name
+        button = Gtk.RadioToolButton.new_from_widget(button)
+        button.set_icon_widget(IconFactory.get_image(
+                icon, Gtk.IconSize.SMALL_TOOLBAR))
+        button.set_active(icon == 'left')
+        button.set_label(label)
+        button.connect(
+            'toggled', _toggle_justification, name, textview)
+        toolbar.insert(button, -1)
+        tag_widgets[name] = button
+
+    toolbar.insert(Gtk.SeparatorToolItem(), -1)
+
+    for icon, label in [
+            ('foreground', _("Foreground Color")),
+            # TODO ('background', _('Background')),
+            ]:
+        button = Gtk.ToolButton()
+        if icon == 'foreground':
+            button.set_icon_widget(IconFactory.get_image(
+                    'tryton-format-color-text',
+                    Gtk.IconSize.SMALL_TOOLBAR))
+        button.set_label(label)
+        button.connect('clicked', _toggle_color, icon, textview, colors)
+        toolbar.insert(button, -1)
+        tag_widgets[icon] = button
+
+    buffer_ = textview.get_buffer()
+    buffer_.connect_after(
+        'insert-text', _insert_text_style, tag_widgets)
+    textview.connect_after(
+        'move-cursor',
+        lambda *a: _detect_style(textview, tag_widgets, colors))
+    textview.connect_after(
+        'button-release-event',
+        lambda *a: _detect_style(textview, tag_widgets, colors))
+
+    return toolbar
+
+
+@contextmanager
+def disable_text_style(textview):
+    buffer_ = textview.get_buffer()
+    try:
+        buffer_.handler_block_by_func(_insert_text_style)
+    except TypeError:
+        pass
+    yield
+    try:
+        buffer_.handler_unblock_by_func(_insert_text_style)
+    except TypeError:
+        pass
+
+
+def _toggle_props(toggle, name, textview):
+    buffer_ = textview.get_buffer()
+    try:
+        start, end = buffer_.get_selection_bounds()
+    except ValueError:
+        return
+    _apply_tool(buffer_, name, toggle, start, end)
+
+
+def _change_props(combobox, name, textview):
+    buffer_ = textview.get_buffer()
+    try:
+        start, end = buffer_.get_selection_bounds()
+    except ValueError:
+        return
+    _apply_tool(buffer_, name, combobox, start, end)
+
+
+def _toggle_justification(button, name, textview):
+    buffer_ = textview.get_buffer()
+    try:
+        start, end = buffer_.get_selection_bounds()
+    except ValueError:
+        insert = buffer_.get_insert()
+        start = buffer_.get_iter_at_mark(insert)
+        end = start.copy()
+    start.set_line_offset(0)
+    if not end.ends_line():
+        end.forward_to_line_end()
+    _apply_tool(buffer_, name, button, start, end)
+
+
+def _toggle_color(button, name, textview, colors):
+    buffer_ = textview.get_buffer()
+    insert = buffer_.get_insert()
+    try:
+        start, end = buffer_.get_selection_bounds()
+    except ValueError:
+        start = end = None
+    else:
+        # Use offset position to preserve across buffer_ modification
+        start = start.get_offset()
+        end = end.get_offset()
+
+    dialog = Gtk.ColorChooserDialog(
+        title=_('Select a color'),
+        transient_for=textview.get_toplevel(),
+        use_alpha=False)
+    color = Gdk.RGBA()
+    if name in colors:
+        color.parse(colors[name])
+        dialog.set_rgba(color)
+    if dialog.run() == Gtk.ResponseType.OK:
+        color = dialog.get_rgba()
+        if start is not None and end is not None:
+            start = buffer_.get_iter_at_offset(start)
+            end = buffer_.get_iter_at_offset(end)
+            tag = register_foreground(buffer_, color)
+            remove_tags(buffer_, start, end, name)
+            buffer_.apply_tag(tag, start, end)
+    dialog.destroy()
+    buffer_.place_cursor(buffer_.get_iter_at_mark(insert))
+
+
+def _apply_tool(buffer_, name, tool, start, end):
+    # First test RadioToolButton as they inherit from ToggleToolButton
+    if isinstance(tool, Gtk.RadioToolButton):
+        name = 'justification %s' % name
+        if not tool.get_active():
+            remove_tags(buffer_, start, end, name)
+        else:
+            remove_tags(buffer_, start, end, 'justification')
+            buffer_.apply_tag_by_name(name, start, end)
+    elif isinstance(tool, Gtk.ToggleToolButton):
+        if tool.get_active():
+            buffer_.apply_tag_by_name(name, start, end)
+        else:
+            buffer_.remove_tag_by_name(name, start, end)
+    elif isinstance(tool, Gtk.ComboBoxText):
+        value = tool.get_active_text()
+        remove_tags(buffer_, start, end, name)
+        name = '%s %s' % (name, value)
+        buffer_.apply_tag_by_name(name, start, end)
+
+
+def _insert_text_style(buffer_, iter_, text, length, tag_widgets):
+    # Text is already inserted so iter_ points to the end
+    start = iter_.copy()
+    start.backward_chars(length)
+    end = iter_.copy()
+    # Apply tags activated from the toolbar
+    for name, widget in tag_widgets.items():
+        _apply_tool(buffer_, name, widget, start, end)
+
+
+def _detect_style(textview, tag_widgets, colors):
+    buffer_ = textview.get_buffer()
+    try:
+        start, end = buffer_.get_selection_bounds()
+    except ValueError:
+        start = end = buffer_.get_iter_at_mark(
+            buffer_.get_insert())
+
+    def toggle_button(name, values):
+        try:
+            value, = values
+        except ValueError:
+            value = False
+        button = tag_widgets[name]
+        button.handler_block_by_func(_toggle_props)
+        button.set_active(value)
+        button.handler_unblock_by_func(_toggle_props)
+
+    def set_combobox(name, indexes):
+        try:
+            index, = indexes
+        except ValueError:
+            index = -1
+        combobox = tag_widgets[name]
+        combobox.handler_block_by_func(_change_props)
+        combobox.set_active(index)
+        combobox.handler_unblock_by_func(_change_props)
+
+    def toggle_justification(names, value):
+        if len(names) != 1:
+            value = False
+        for name in names:
+            button = tag_widgets[name]
+            button.handler_block_by_func(_toggle_justification)
+            button.set_active(value)
+            button.handler_unblock_by_func(_toggle_justification)
+
+    bolds, italics, underlines = set(), set(), set()
+    families, sizes, justifications = set(), set(), set()
+    colors['foreground'] = 'black'
+
+    iter_ = start.copy()
+    while True:
+        bold, italic, underline = False, False, False
+        family = FAMILIES.index('normal')
+        size = SIZES.index('4')
+        justification = 'left'
+
+        for tag in iter_.get_tags():
+            if not tag.props.name:
+                continue
+            elif tag.props.name == 'bold':
+                bold = True
+            elif tag.props.name == 'italic':
+                italic = True
+            elif tag.props.name == 'underline':
+                underline = True
+            elif tag.props.name.startswith('family'):
+                _, family = tag.props.name.split()
+                family = FAMILIES.index(family)
+            elif tag.props.name.startswith('size'):
+                _, size = tag.props.name.split()
+                size = SIZES.index(size)
+            elif tag.props.name.startswith('justification'):
+                _, justification = tag.props.name.split()
+            elif tag.props.name.startswith('foreground'):
+                _, colors['foreground'] = tag.props.name.split()
+        bolds.add(bold)
+        italics.add(italic)
+        underlines.add(underline)
+        families.add(family)
+        sizes.add(size)
+        justifications.add(justification)
+
+        iter_.forward_char()
+        if iter_.compare(end) > 0:
+            iter_ = end
+        if iter_.compare(end) == 0:
+            break
+
+    for name, values in [
+            ('bold', bolds),
+            ('italic', italics),
+            ('underline', underlines)]:
+        toggle_button(name, values)
+    set_combobox('family', families)
+    set_combobox('size', sizes)
+    toggle_justification(justifications, True)
+
+
+if __name__ == '__main__':
+    win = Gtk.Window()
+    box = Gtk.VBox()
+    win.add(box)
+
+    textview = Gtk.TextView()
+    register_format(textview)
+    toolbar = add_toolbar(textview)
+    box.pack_start(toolbar, expand=False, fill=True, padding=0)
+    box.pack_start(textview, expand=True, fill=True, padding=0)
+
+    win.show_all()
+    Gtk.main()
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/data/pixmaps/tryton/tryton-send.svg
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/tryton/data/pixmaps/tryton/tryton-send.svg        Thu Sep 17 17:18:02 
2020 +0200
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg"; height="24" viewBox="0 0 24 24" 
width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M2.01 21L23 12 2.01 3 
2 10l15 2-15 2z"/></svg>
\ No newline at end of file
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/gui/main.py
--- a/tryton/gui/main.py        Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/gui/main.py        Thu Sep 17 17:18:02 2020 +0200
@@ -100,10 +100,6 @@
         action.connect('activate', lambda *a: self.edit_limit())
         self.add_action(action)
 
-        action = Gio.SimpleAction.new('email', None)
-        action.connect('activate', lambda *a: self.edit_email())
-        self.add_action(action)
-
         self._shortcuts = None
         action = Gio.SimpleAction.new('shortcuts', None)
         action.connect('activate', lambda *a: self.shortcuts())
@@ -198,6 +194,9 @@
         Gtk.AccelMap.add_entry(
             '<tryton>/Form/Report', Gdk.KEY_P, Gdk.ModifierType.CONTROL_MASK)
         Gtk.AccelMap.add_entry(
+            '<tryton>/Form/Email', Gdk.KEY_E,
+            Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)
+        Gtk.AccelMap.add_entry(
             '<tryton>/Form/Search', Gdk.KEY_F, Gdk.ModifierType.CONTROL_MASK)
 
         Gtk.AccelMap.load(os.path.join(get_config_dir(), 'accel.map'))
@@ -296,7 +295,6 @@
 
         section.append(_("PDA Mode"), 'app.mode-pda')
         section.append(_("Search Limit..."), 'app.search-limit')
-        section.append(_("Email..."), 'app.email')
         section.append(_("Check Version"), 'app.check-version')
 
         menu.append_section(_("Options"), section)
@@ -491,10 +489,6 @@
         from tryton.gui.window.limit import Limit
         Limit().run()
 
-    def edit_email(self):
-        from tryton.gui.window.email_ import Email
-        Email().run()
-
     def win_next(self):
         page = self.notebook.get_current_page()
         if page == len(self.pages) - 1:
@@ -1010,8 +1004,6 @@
                 data = json.loads(params.get('data', '{}'),
                     object_hook=object_hook)
                 direct_print = json.loads(params.get('direct_print', 'false'))
-                email_print = json.loads(params.get('email_print', 'false'))
-                email = json.loads(params.get('email', 'null'))
                 name = json.loads(params.get('name', '""'))
                 window = json.loads(params.get('window', 'false'))
                 context = json.loads(params.get('context', '{}'),
@@ -1019,8 +1011,8 @@
             except ValueError:
                 return
             try:
-                Window.create_wizard(wizard, data, direct_print=direct_print,
-                    email_print=email_print, email=email, name=name,
+                Window.create_wizard(
+                    wizard, data, direct_print=direct_print, name=name,
                     context=context, window=window)
             except Exception:
                 # Prevent crashing the client
@@ -1032,15 +1024,13 @@
             try:
                 data = json.loads(params.get('data'), object_hook=object_hook)
                 direct_print = json.loads(params.get('direct_print', 'false'))
-                email_print = json.loads(params.get('email_print', 'false'))
-                email = json.loads(params.get('email', 'null'))
                 context = json.loads(params.get('context', '{}'),
                     object_hook=object_hook)
             except ValueError:
                 return
             try:
-                Action.exec_report(report, data, direct_print=direct_print,
-                    email_print=email_print, email=email, context=context)
+                Action.exec_report(
+                    report, data, direct_print=direct_print, context=context)
             except Exception:
                 # Prevent crashing the client
                 return
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/gui/window/email_.py
--- a/tryton/gui/window/email_.py       Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/gui/window/email_.py       Thu Sep 17 17:18:02 2020 +0200
@@ -1,97 +1,310 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
+import gettext
+import logging
+import os
 
-import gettext
-
-from gi.repository import Gtk
+from gi.repository import Gtk, GLib
+try:
+    from gi.repository import GtkSpell
+except ImportError:
+    GtkSpell = None
 
-from tryton.common import get_toplevel_window, IconFactory
+from tryton.common import IconFactory, Tooltips, RPCExecute, RPCException
+from tryton.common.richtext import (
+    register_format, add_toolbar, get_content, set_content)
 from tryton.common.underline import set_underline
+from tryton.common.widget_style import widget_class
 from tryton.config import TRYTON_ICON, CONFIG
+from tryton.exceptions import TrytonServerError, TrytonError
 from tryton.gui import Main
+from tryton.gui.window.nomodal import NoModal
 
 _ = gettext.gettext
+logger = logging.getLogger(__name__)
+
+
+class EmailEntry(Gtk.Entry):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._completion = Gtk.EntryCompletion()
+        self._completion.set_model(Gtk.ListStore(str, str))
+        self._completion.set_text_column(0)
+        self._completion.set_match_func(lambda *a: True)
+        self._completion.connect('match-selected', self._match_selected)
+        self.set_completion(self._completion)
+        self.connect('changed', EmailEntry._changed)
+
+    def _match_selected(self, completion, model, iter):
+        self.set_text(model.get_value(iter, 1))
+        self.set_position(-1)
+        return True
+
+    def _update_completion(self, text):
+        if not self.props.window:
+            return False
+        if text != self.get_text():
+            return False
+        model = self._completion.get_model()
+        if not text:
+            model.clear()
+            model.text = text
+            return False
+        if getattr(model, 'text', None) == text:
+            return False
+
+        def callback(results):
+            try:
+                results = results()
+            except (TrytonError, TrytonServerError):
+                logger.warning(
+                    _("Unable to complete email entry"), exc_info=True)
+                results = []
+            if text != self.get_text():
+                return False
+            model.clear()
+            for ratio, address, addresses in results:
+                model.append([address, addresses])
+            model.text = text
+            # Force display of popup
+            self.emit('changed')
+
+        try:
+            RPCExecute(
+                'model', 'ir.email', 'complete', text, CONFIG['client.limit'],
+                process_exception=False, callback=callback)
+        except Exception:
+            logger.warning(
+                _("Unable to complete email entry"), exc_info=True)
+        return False
+
+    def _changed(self):
+        def keypress():
+            if not self.props.window:
+                return
+            self._update_completion()
+        text = self.get_text()
+        if self.get_position() >= len(text) - 1:
+            GLib.timeout_add(300, self._update_completion, text)
+        else:
+            model = self._completion.get_model()
+            model.clear()
+            model.text = None
 
 
-class Email(object):
+class Email(NoModal):
+
+    def __init__(self, name, record, prints, template=None):
+        super().__init__()
+        self.record = record
+        self.dialog = Gtk.Dialog(
+            transient_for=self.parent, destroy_with_parent=True)
+        Main().add_window(self.dialog)
+        self.dialog.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
+        self.dialog.set_icon(TRYTON_ICON)
+        self.dialog.set_default_size(*self.default_size())
+        self.dialog.connect('response', self.response)
+
+        self.dialog.set_title(_('E-mail %s') % name)
+
+        grid = Gtk.Grid(
+            column_spacing=3, row_spacing=3,
+            border_width=3)
+        self.dialog.vbox.pack_start(grid, expand=True, fill=True, padding=0)
 
-    def __init__(self):
-        self.parent = get_toplevel_window()
-        self.win = Gtk.Dialog(
-            title=_('Email'), transient_for=self.parent, modal=True,
-            destroy_with_parent=True)
-        Main().add_window(self.win)
-        cancel_button = self.win.add_button(
-            set_underline(_("Cancel")), Gtk.ResponseType.CANCEL)
-        cancel_button.set_image(
-            IconFactory.get_image('tryton-cancel', Gtk.IconSize.BUTTON))
-        cancel_button.set_always_show_image(True)
-        ok_button = self.win.add_button(
-            set_underline(_("OK")), Gtk.ResponseType.OK)
-        ok_button.set_image(
-            IconFactory.get_image('tryton-ok', Gtk.IconSize.BUTTON))
-        ok_button.set_always_show_image(True)
-        self.win.set_default_response(Gtk.ResponseType.OK)
-        self.win.set_icon(TRYTON_ICON)
-        self.win.vbox.set_spacing(3)
-        self.win.vbox.pack_start(Gtk.Label(
-            _('Email Program Settings')), expand=False, fill=True, padding=0)
-        self.win.vbox.pack_start(
-            Gtk.HSeparator(), expand=True, fill=True, padding=0)
-        hbox = Gtk.HBox(spacing=3)
-        label = Gtk.Label(label=_('Command Line:'))
-        hbox.pack_start(label, expand=True, fill=True, padding=0)
-        self.entry = Gtk.Entry()
-        self.entry.set_property('activates_default', True)
-        self.entry.set_width_chars(50)
-        self.entry.set_text(CONFIG['client.email'])
-        label.set_mnemonic_widget(label)
-        hbox.pack_start(self.entry, expand=True, fill=True, padding=0)
-        self.win.vbox.pack_start(hbox, expand=True, fill=True, padding=0)
+        label = Gtk.Label(
+            set_underline(_("To:")), use_underline=True, halign=Gtk.Align.END)
+        grid.attach(label, 0, 0, 1, 1)
+        self.to = EmailEntry(hexpand=True, activates_default=True)
+        widget_class(self.to, 'required', True)
+        label.set_mnemonic_widget(self.to)
+        grid.attach(self.to, 1, 0, 1, 1)
+
+        label = Gtk.Label(
+            set_underline(_("Cc:")), use_underline=True, halign=Gtk.Align.END)
+        grid.attach(label, 0, 1, 1, 1)
+        self.cc = EmailEntry(hexpand=True, activates_default=True)
+        label.set_mnemonic_widget(self.cc)
+        grid.attach(self.cc, 1, 1, 1, 1)
+
+        label = Gtk.Label(
+            set_underline(_("Bcc:")), use_underline=True, halign=Gtk.Align.END)
+        grid.attach(label, 0, 2, 1, 1)
+        self.bcc = EmailEntry(hexpand=True, activates_default=True)
+        label.set_mnemonic_widget(self.bcc)
+        grid.attach(self.bcc, 1, 2, 1, 1)
 
         label = Gtk.Label(
-            label=_('Legend of Available Placeholders:'),
-            halign=Gtk.Align.START, margin_top=10, margin_bottom=5)
-        self.win.vbox.pack_start(label, expand=False, fill=True, padding=0)
+            set_underline(_("Subject:")),
+            use_underline=True, halign=Gtk.Align.END)
+        grid.attach(label, 0, 3, 1, 1)
+        self.subject = Gtk.Entry(hexpand=True, activates_default=True)
+        label.set_mnemonic_widget(self.subject)
+        grid.attach(self.subject, 1, 3, 1, 1)
+
+        self.body = Gtk.TextView()
+        body_frame = Gtk.Frame()
+        label = Gtk.Label(
+            set_underline(_("Body")), use_underline=True, halign=Gtk.Align.END)
+        label.set_mnemonic_widget(self.body)
+        body_frame.set_label_widget(label)
+        grid.attach(body_frame, 0, 4, 2, 1)
+        body_box = Gtk.VBox(hexpand=True, vexpand=True)
+        body_frame.add(body_box)
+        register_format(self.body)
+        body_toolbar = add_toolbar(self.body)
+        body_box.pack_start(body_toolbar, expand=False, fill=True, padding=0)
+        body_box.pack_start(self.body, expand=True, fill=True, padding=0)
+
+        if GtkSpell and CONFIG['client.spellcheck']:
+            checker = GtkSpell.Checker()
+            checker.attach(self.body)
+            language = os.environ.get('LANGUAGE', 'en')
+            try:
+                checker.set_language(language)
+            except Exception:
+                logger.error(
+                    'Could not set spell checker for "%s"', language)
+                checker.detach()
+
+        attachments = Gtk.HBox()
+        grid.attach(attachments, 0, 5, 2, 1)
 
-        hbox = Gtk.HBox(spacing=3)
-        vboxl = Gtk.VBox(homogeneous=True, spacing=3)
-        label = Gtk.Label(label=_('To:'), halign=Gtk.Align.START)
-        vboxl.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=_('CC:'), halign=Gtk.Align.START)
-        vboxl.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=_('Subject:'), halign=Gtk.Align.START)
-        vboxl.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=_('Body:'), halign=Gtk.Align.START)
-        vboxl.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=_('Attachment:'), halign=Gtk.Align.START)
-        vboxl.pack_start(label, expand=False, fill=False, padding=0)
+        print_frame = Gtk.Frame(shadow_type=Gtk.ShadowType.NONE)
+        print_frame.set_label(_("Reports"))
+        attachments.pack_start(print_frame, expand=True, fill=True, padding=0)
+        print_box = Gtk.VBox()
+        print_frame.add(print_box)
+        print_flowbox = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE)
+        print_box.pack_start(
+            print_flowbox, expand=False, fill=False, padding=0)
+        self.print_actions = {}
+        for print_ in prints:
+            print_check = Gtk.CheckButton.new_with_mnemonic(
+                set_underline(print_['name']))
+            self.print_actions[print_['id']] = print_check
+            print_flowbox.add(print_check)
+
+        file_frame = Gtk.Frame(shadow_type=Gtk.ShadowType.NONE)
+        file_frame.set_label(_("Attachments"))
+        attachments.pack_start(file_frame, expand=True, fill=True, padding=0)
+        self.files = Gtk.VBox(spacing=6)
+        file_frame.add(self.files)
+        self._add_file_button()
+
+        button_cancel = self.dialog.add_button(
+            set_underline(_("Cancel")), Gtk.ResponseType.CANCEL)
+        button_cancel.set_image(IconFactory.get_image(
+                'tryton-cancel', Gtk.IconSize.BUTTON))
+
+        button_send = self.dialog.add_button(
+            set_underline(_("Send")), Gtk.ResponseType.OK)
+        button_send.set_image(IconFactory.get_image(
+                'tryton-send', Gtk.IconSize.BUTTON))
+        self.dialog.set_default_response(Gtk.ResponseType.OK)
+
+        self._fill_with(template)
+
+        self.dialog.show_all()
+        self.register()
+
+    def _add_file_button(self):
+        tooltips = Tooltips()
+        box = Gtk.HBox(spacing=3)
+        self.files.pack_start(box, expand=False, fill=True, padding=0)
+        file_ = Gtk.FileChooserButton(title=_("Select Attachment"))
+        box.pack_start(file_, expand=True, fill=True, padding=0)
+        button = Gtk.Button()
+        button.set_image(IconFactory.get_image(
+                'tryton-remove', Gtk.IconSize.BUTTON))
+        tooltips.set_tip(button, _("Remove attachment"))
+        button.set_sensitive(False)
+        box.pack_start(button, expand=False, fill=True, padding=0)
 
-        vboxr = Gtk.VBox(homogeneous=True, spacing=3)
-        label = Gtk.Label(label=' ${to}', halign=Gtk.Align.START)
-        vboxr.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=' ${cc}', halign=Gtk.Align.START)
-        vboxr.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=' ${subject}', halign=Gtk.Align.START)
-        vboxr.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=' ${body}', halign=Gtk.Align.START)
-        vboxr.pack_start(label, expand=False, fill=False, padding=0)
-        label = Gtk.Label(label=' ${attachment}', halign=Gtk.Align.START)
-        vboxr.pack_start(label, expand=False, fill=False, padding=0)
+        box.show_all()
+
+        file_.connect('file-set', self._file_set, button)
+        button.connect('clicked', self._file_remove)
+
+    def _file_set(self, file_, button):
+        button.set_sensitive(True)
+        self._add_file_button()
+
+    def _file_remove(self, button):
+        self.files.remove(button.get_parent())
+
+    def get_files(self):
+        for box in self.files:
+            file_ = list(box)[0]
+            filename = file_.get_filename()
+            if not filename:
+                continue
+            with open(filename, 'rb') as fp:
+                data = fp.read()
+            name = os.path.basename(filename)
+            yield (name, data)
+
+    def _fill_with(self, template=None):
+        try:
+            if template:
+                values = RPCExecute(
+                    'model', 'ir.email.template', 'get',
+                    template, self.record.id)
+            else:
+                values = RPCExecute(
+                    'model', 'ir.email.template', 'get_default',
+                    self.record.model_name, self.record.id)
+        except RPCException:
+            return
+        self.to.set_text(', '.join(values.get('to', [])))
+        self.cc.set_text(', '.join(values.get('cc', [])))
+        self.bcc.set_text(', '.join(values.get('bcc', [])))
+        self.subject.set_text(values.get('subject', ''))
+        set_content(self.body, values.get('body', ''))
+        print_ids = values.get('reports', [])
+        for print_id, print_check in self.print_actions.items():
+            print_check.set_active(print_id in print_ids)
 
-        hbox.pack_start(vboxl, expand=False, fill=False, padding=0)
-        hbox.pack_start(vboxr, expand=False, fill=False, padding=0)
-
-        self.win.vbox.pack_start(hbox, expand=True, fill=True, padding=0)
-
-        self.win.show_all()
+    def validate(self):
+        valid = True
+        if not self.subject.get_text():
+            valid = False
+            widget_class(self.subject, 'invalid', True)
+            self.subject.grab_focus()
+        else:
+            widget_class(self.subject, 'invalid', False)
+        if not self.to.get_text():
+            valid = False
+            widget_class(self.to, 'invalid', True)
+            self.to.grab_focus()
+        else:
+            widget_class(self.to, 'invalid', False)
+        return valid
 
-    def run(self):
-        "Run the window"
-        res = self.win.run()
-        if res == Gtk.ResponseType.OK:
-            CONFIG['client.email'] = self.entry.get_text()
-            CONFIG.save()
-        self.parent.present()
-        self.win.destroy()
-        return res
+    def response(self, dialog, response):
+        if response == Gtk.ResponseType.OK:
+            if not self.validate():
+                return
+            to = self.to.get_text()
+            cc = self.cc.get_text()
+            bcc = self.bcc.get_text()
+            subject = self.subject.get_text()
+            body = get_content(self.body)
+            attachments = list(self.get_files())
+            reports = [
+                id_ for id_, check in self.print_actions.items()
+                if check.get_active()]
+            try:
+                RPCExecute(
+                    'model', 'ir.email', 'send',
+                    to, cc, bcc, subject, body,
+                    attachments,
+                    [self.record.model_name, self.record.id],
+                    reports)
+            except RPCException:
+                return
+        self.destroy()
+
+    def destroy(self):
+        super().destroy()
+        self.dialog.destroy()
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/gui/window/form.py
--- a/tryton/gui/window/form.py Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/gui/window/form.py Thu Sep 17 17:18:02 2020 +0200
@@ -14,6 +14,7 @@
 from tryton.action import Action
 from tryton.gui import Main
 from tryton.gui.window import Window
+from tryton.gui.window.email_ import Email
 from tryton.gui.window.win_export import WinExport
 from tryton.gui.window.win_import import WinImport
 from tryton.gui.window.attachment import Attachment
@@ -23,6 +24,7 @@
 from tryton.common import message, sur, sur_3b, timezoned_date
 import tryton.common as common
 from tryton.common import RPCExecute, RPCException
+from tryton.common.common import selection as selection_
 from tryton.common.underline import set_underline
 from tryton import plugins
 
@@ -437,9 +439,24 @@
         if self.buttons['open'].props.sensitive:
             self.buttons['open'].props.active = True
 
-    def sig_print_email(self, widget):
+    def sig_email(self, widget):
+        def is_report(action):
+            return action['type'] == 'ir.action.report'
+
         if self.buttons['email'].props.sensitive:
-            self.buttons['email'].props.active = True
+            record = self.screen.current_record
+            if not record or record.id < 0:
+                return
+            toolbars = self.get_toolbars()
+            title = self.title.get_text()
+            prints = filter(is_report, toolbars['print'])
+            emails = {e['name']: e['id'] for e in toolbars['emails']}
+            template = selection_(_("Template"), emails, alwaysask=True)
+            if template:
+                template = template[1]
+            Email(
+                '%s: %s' % (title, record.rec_name()), record, prints,
+                template=template)
 
     def sig_relate(self, widget):
         if self.buttons['relate'].props.sensitive:
@@ -487,7 +504,7 @@
             can_be_sensitive = getattr(button, '_can_be_sensitive', True)
             if button_id in {'print', 'relate', 'email', 'open'}:
                 action_type = button_id
-                if button_id in {'email', 'open'}:
+                if button_id == 'open':
                     action_type = 'print'
                 can_be_sensitive |= any(
                     b.attrs.get('keyword', 'action') == action_type
@@ -540,7 +557,6 @@
         record_id = (self.screen.current_record.id
             if self.screen.current_record else None)
         record_ids = [r.id for r in self.screen.selected_records]
-        action = Action.evaluate(action, atype, self.screen.current_record)
         data = {
             'model': self.screen.model_name,
             'id': record_id,
@@ -567,11 +583,11 @@
         attach_btn.connect('drag_data_received',
             self.attach_drag_data_received)
 
+        pos = gtktoolbar.get_item_index(self.buttons['email'])
         iconstock = {
             'print': 'tryton-print',
             'action': 'tryton-launch',
             'relate': 'tryton-link',
-            'email': 'tryton-email',
             'open': 'tryton-open',
         }
         for action_type, special_action, action_name, tooltip in (
@@ -579,9 +595,8 @@
                 ('relate', 'relate', _('Relate'), _('Open related records')),
                 (None,) * 4,
                 ('print', 'open', _('Report'), _('Open report')),
-                ('print', 'email', _('E-Mail'), _('E-Mail report')),
                 ('print', 'print', _('Print'), _('Print report')),
-        ):
+                ):
             if action_type is not None:
                 tbutton = Gtk.ToggleToolButton()
                 tbutton.set_icon_widget(common.IconFactory.get_image(
@@ -598,7 +613,8 @@
                         tbutton._menu.get_children())
             else:
                 tbutton = Gtk.SeparatorToolItem()
-            gtktoolbar.insert(tbutton, -1)
+            gtktoolbar.insert(tbutton, pos)
+            pos += 1
 
         exports = toolbars['exports']
         if exports:
@@ -613,7 +629,9 @@
                 menuitem.connect('activate', self.do_export, export)
                 menu.add(menuitem)
 
-        gtktoolbar.insert(Gtk.SeparatorToolItem(), -1)
+        last_item = gtktoolbar.get_nth_item(gtktoolbar.get_n_items() - 1)
+        if not isinstance(last_item, Gtk.SeparatorToolItem):
+            gtktoolbar.insert(Gtk.SeparatorToolItem(), -1)
 
         url_button = Gtk.ToggleToolButton()
         url_button.set_icon_widget(
@@ -644,8 +662,6 @@
             new_action = action.copy()
             if special_action == 'print':
                 new_action['direct_print'] = True
-            elif special_action == 'email':
-                new_action['email_print'] = True
             menuitem = Gtk.MenuItem(label=set_underline(action['name']))
             menuitem.set_use_underline(True)
             menuitem.connect('activate', self._popup_menu_selected, widget,
diff -r 5ce1d6984748 -r 83287ed2d759 tryton/gui/window/tabcontent.py
--- a/tryton/gui/window/tabcontent.py   Sat Sep 05 22:59:38 2020 +0200
+++ b/tryton/gui/window/tabcontent.py   Thu Sep 17 17:18:02 2020 +0200
@@ -134,15 +134,16 @@
                 icon_name='tryton-open',
                 accel_path='<tryton>/Form/Report'),
             ToolbarItem(
-                id='print_email',
-                label=_("_E-Mail..."),
-                icon_name='tryton-email',
-                accel_path='<tryton>/Form/Email'),
-            ToolbarItem(
                 id='print',
                 label=_("_Print..."),
                 icon_name='tryton-print',
                 accel_path='<tryton>/Form/Print'),
+            ToolbarItem(
+                id='email',
+                label=_("_E-Mail..."),
+                tooltip=_("Send an e-mail using the record"),
+                icon_name='tryton-email',
+                accel_path='<tryton>/Form/Email'),
             None,
             ToolbarItem(
                 id='export',
diff -r 5ce1d6984748 -r 83287ed2d759 
tryton/gui/window/view_form/view/form_gtk/richtextbox.py
--- a/tryton/gui/window/view_form/view/form_gtk/richtextbox.py  Sat Sep 05 
22:59:38 2020 +0200
+++ b/tryton/gui/window/view_form/view/form_gtk/richtextbox.py  Thu Sep 17 
17:18:02 2020 +0200
@@ -1,19 +1,15 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
-from gettext import gettext as _
 from weakref import WeakKeyDictionary
 
-from gi.repository import Gtk, Gdk
+from gi.repository import Gtk
 
 from .textbox import TextBox
-from tryton.common import get_toplevel_window, IconFactory
-from tryton.common.htmltextbuffer import (serialize, deserialize,
-    setup_tags, normalize_markup, remove_tags, register_foreground,
-    FAMILIES, SIZE2SCALE, MIME, use_serialize_func)
+from tryton.common.htmltextbuffer import normalize_markup
+from tryton.common.richtext import (
+    register_format, set_content, get_content, add_toolbar)
 from tryton.config import CONFIG
 
-SIZES = sorted(SIZE2SCALE.keys())
-
 
 class RichTextBox(TextBox):
 
@@ -24,99 +20,19 @@
         self.tags = {}
         self.colors = {}
         if int(self.attrs.get('toolbar', 1)):
-            self.toolbar = self.get_toolbar(self.textview)
+            self.toolbar = add_toolbar(self.textview)
+            self.toolbar.set_style({
+                    'default': False,
+                    'both': Gtk.ToolbarStyle.BOTH,
+                    'text': Gtk.ToolbarStyle.TEXT,
+                    'icons': Gtk.ToolbarStyle.ICONS,
+                    }[CONFIG['client.toolbar']])
             self.widget.pack_start(
                 self.toolbar, expand=False, fill=True, padding=0)
 
-    def get_toolbar(self, textview):
-        toolbar = Gtk.Toolbar()
-        toolbar.set_style({
-                'default': False,
-                'both': Gtk.ToolbarStyle.BOTH,
-                'text': Gtk.ToolbarStyle.TEXT,
-                'icons': Gtk.ToolbarStyle.ICONS,
-                }[CONFIG['client.toolbar']])
-        tag_widgets = self.tag_widgets[textview] = {}
-
-        for icon, label in [
-                ('bold', _("Bold")),
-                ('italic', _("Italic")),
-                ('underline', _("Underline")),
-                ]:
-            button = Gtk.ToggleToolButton()
-            button.set_icon_widget(IconFactory.get_image(
-                    'tryton-format-%s' % icon,
-                    Gtk.IconSize.SMALL_TOOLBAR))
-            button.set_label(label)
-            button.connect('toggled', self.toggle_props, icon, textview)
-            toolbar.insert(button, -1)
-            tag_widgets[icon] = button
-
-        toolbar.insert(Gtk.SeparatorToolItem(), -1)
-
-        for name, options, active in [
-                ('family', FAMILIES, FAMILIES.index('normal')),
-                ('size', SIZES, SIZES.index('4')),
-                ]:
-            combobox = Gtk.ComboBoxText()
-            for option in options:
-                combobox.append_text(option)
-            combobox.set_active(active)
-            combobox.set_focus_on_click(False)
-            combobox.connect('changed', self.change_props, name, textview)
-            tool = Gtk.ToolItem()
-            tool.add(combobox)
-            toolbar.insert(tool, -1)
-            tag_widgets[name] = combobox
-
-        toolbar.insert(Gtk.SeparatorToolItem(), -1)
-
-        button = None
-        for name, label in [
-                ('left', _("Align Left")),
-                ('center', _("Align Center")),
-                ('right', _("Align Right")),
-                ('justify', _("Justify")),
-                ]:
-            icon = 'tryton-format-align-%s' % name
-            button = Gtk.RadioToolButton.new_from_widget(button)
-            button.set_icon_widget(IconFactory.get_image(
-                    icon, Gtk.IconSize.SMALL_TOOLBAR))
-            button.set_active(icon == 'left')
-            button.set_label(label)
-            button.connect(
-                'toggled', self.toggle_justification, name, textview)
-            toolbar.insert(button, -1)
-            tag_widgets[name] = button
-
-        toolbar.insert(Gtk.SeparatorToolItem(), -1)
-
-        for icon, label in [
-                ('foreground', _("Foreground Color")),
-                # TODO ('background', _('Background')),
-                ]:
-            button = Gtk.ToolButton()
-            if icon == 'foreground':
-                button.set_icon_widget(IconFactory.get_image(
-                        'tryton-format-color-text',
-                        Gtk.IconSize.SMALL_TOOLBAR))
-            button.set_label(label)
-            button.connect('clicked', self.toggle_color, icon, textview)
-            toolbar.insert(button, -1)
-            tag_widgets[icon] = button
-
-        return toolbar
-
     def _get_textview(self):
         textview = super(RichTextBox, self)._get_textview()
-        text_buffer = textview.get_buffer()
-        setup_tags(text_buffer)
-        text_buffer.register_serialize_format(str(MIME), serialize, None)
-        text_buffer.register_deserialize_format(str(MIME), deserialize, None)
-        text_buffer.connect_after(
-            'insert-text', self.insert_text_style, textview)
-        textview.connect_after('move-cursor', self.detect_style)
-        textview.connect('button-release-event', self.detect_style)
+        register_format(textview)
         return textview
 
     def translate_widget(self):
@@ -124,7 +40,7 @@
         textview = widget.get_children()[-1].get_child()
         if self.toolbar:
             widget.pack_start(
-                self.get_toolbar(textview), expand=False, fill=True, padding=0)
+                add_toolbar(textview), expand=False, fill=True, padding=0)
         return widget
 
     def translate_widget_set_readonly(self, widget, value):
@@ -151,212 +67,12 @@
         return False
 
     def set_buffer(self, value, textview):
-        text_buffer = textview.get_buffer()
-        text_buffer.handler_block_by_func(self.insert_text_style)
-        start = text_buffer.get_start_iter()
-        end = text_buffer.get_end_iter()
-        text_buffer.delete(start, end)
-        if use_serialize_func:
-            text_buffer.deserialize(text_buffer, MIME, start, value)
-        else:
-            deserialize(
-                text_buffer, text_buffer, start, value,
-                text_buffer.deserialize_get_can_create_tags(MIME), None)
-        text_buffer.handler_unblock_by_func(self.insert_text_style)
+        set_content(textview, value)
 
     def get_buffer(self, textview):
-        text_buffer = textview.get_buffer()
-        start = text_buffer.get_start_iter()
-        end = text_buffer.get_end_iter()
-        if use_serialize_func:
-            return text_buffer.serialize(text_buffer, MIME, start, end)
-        else:
-            return serialize(text_buffer, text_buffer, start, end, None)
+        return get_content(textview)
 
     def _readonly_set(self, value):
         super(RichTextBox, self)._readonly_set(value)
         if self.toolbar:
             self.toolbar.set_sensitive(not value)
-
-    def detect_style(self, textview, *args):
-        if not self.toolbar:
-            return
-        tag_widgets = self.tag_widgets[textview]
-        text_buffer = textview.get_buffer()
-        try:
-            start, end = text_buffer.get_selection_bounds()
-        except ValueError:
-            start = end = text_buffer.get_iter_at_mark(
-                text_buffer.get_insert())
-
-        def toggle_button(name, values):
-            try:
-                value, = values
-            except ValueError:
-                value = False
-            button = tag_widgets[name]
-            button.handler_block_by_func(self.toggle_props)
-            button.set_active(value)
-            button.handler_unblock_by_func(self.toggle_props)
-
-        def set_combobox(name, indexes):
-            try:
-                index, = indexes
-            except ValueError:
-                index = -1
-            combobox = tag_widgets[name]
-            combobox.handler_block_by_func(self.change_props)
-            combobox.set_active(index)
-            combobox.handler_unblock_by_func(self.change_props)
-
-        def toggle_justification(names, value):
-            if len(names) != 1:
-                value = False
-            for name in names:
-                button = tag_widgets[name]
-                button.handler_block_by_func(self.toggle_justification)
-                button.set_active(value)
-                button.handler_unblock_by_func(self.toggle_justification)
-
-        bolds, italics, underlines = set(), set(), set()
-        families, sizes, justifications = set(), set(), set()
-        self.colors['foreground'] = 'black'
-
-        iter_ = start.copy()
-        while True:
-            bold, italic, underline = False, False, False
-            family = FAMILIES.index('normal')
-            size = SIZES.index('4')
-            justification = 'left'
-
-            for tag in iter_.get_tags():
-                if not tag.props.name:
-                    continue
-                elif tag.props.name == 'bold':
-                    bold = True
-                elif tag.props.name == 'italic':
-                    italic = True
-                elif tag.props.name == 'underline':
-                    underline = True
-                elif tag.props.name.startswith('family'):
-                    _, family = tag.props.name.split()
-                    family = FAMILIES.index(family)
-                elif tag.props.name.startswith('size'):
-                    _, size = tag.props.name.split()
-                    size = SIZES.index(size)
-                elif tag.props.name.startswith('justification'):
-                    _, justification = tag.props.name.split()
-                elif tag.props.name.startswith('foreground'):
-                    _, self.colors['foreground'] = tag.props.name.split()
-            bolds.add(bold)
-            italics.add(italic)
-            underlines.add(underline)
-            families.add(family)
-            sizes.add(size)
-            justifications.add(justification)
-
-            iter_.forward_char()
-            if iter_.compare(end) > 0:
-                iter_ = end
-            if iter_.compare(end) == 0:
-                break
-
-        for name, values in [
-                ('bold', bolds),
-                ('italic', italics),
-                ('underline', underlines)]:
-            toggle_button(name, values)
-        set_combobox('family', families)
-        set_combobox('size', sizes)
-        toggle_justification(justifications, True)
-
-    def insert_text_style(self, text_buffer, iter_, text, length, textview):
-        if not self.toolbar:
-            return
-        # Text is already inserted so iter_ point to the end
-        start = iter_.copy()
-        start.backward_chars(length)
-        end = iter_.copy()
-        # Apply tags activated from toolbar
-        for name, widget in self.tag_widgets[textview].items():
-            self._apply_tool(text_buffer, name, widget, start, end)
-
-    def _apply_tool(self, text_buffer, name, tool, start, end):
-        # First test RadioToolButton as they inherit from ToggleToolButton
-        if isinstance(tool, Gtk.RadioToolButton):
-            name = 'justification %s' % name
-            if not tool.get_active():
-                remove_tags(text_buffer, start, end, name)
-            else:
-                remove_tags(text_buffer, start, end, 'justification')
-                text_buffer.apply_tag_by_name(name, start, end)
-        elif isinstance(tool, Gtk.ToggleToolButton):
-            if tool.get_active():
-                text_buffer.apply_tag_by_name(name, start, end)
-            else:
-                text_buffer.remove_tag_by_name(name, start, end)
-        elif isinstance(tool, Gtk.ComboBoxText):
-            value = tool.get_active_text()
-            remove_tags(text_buffer, start, end, name)
-            name = '%s %s' % (name, value)
-            text_buffer.apply_tag_by_name(name, start, end)
-
-    def toggle_props(self, toggle, name, textview):
-        text_buffer = textview.get_buffer()
-        try:
-            start, end = text_buffer.get_selection_bounds()
-        except ValueError:
-            return
-        self._apply_tool(text_buffer, name, toggle, start, end)
-
-    def change_props(self, combobox, name, textview):
-        text_buffer = textview.get_buffer()
-        try:
-            start, end = text_buffer.get_selection_bounds()
-        except ValueError:
-            return
-        self._apply_tool(text_buffer, name, combobox, start, end)
-
-    def toggle_justification(self, button, name, textview):
-        text_buffer = textview.get_buffer()
-        try:
-            start, end = text_buffer.get_selection_bounds()
-        except ValueError:
-            insert = text_buffer.get_insert()
-            start = text_buffer.get_iter_at_mark(insert)
-            end = start.copy()
-        start.set_line_offset(0)
-        if not end.ends_line():
-            end.forward_to_line_end()
-        self._apply_tool(text_buffer, name, button, start, end)
-
-    def toggle_color(self, button, name, textview):
-        text_buffer = textview.get_buffer()
-        insert = text_buffer.get_insert()
-        try:
-            start, end = text_buffer.get_selection_bounds()
-        except ValueError:
-            start = end = None
-        else:
-            # Use offset position to preserve across buffer modification
-            start = start.get_offset()
-            end = end.get_offset()
-
-        dialog = Gtk.ColorChooserDialog(
-            title=_('Select a color'),
-            transient_for=get_toplevel_window(),
-            use_alpha=False)
-        color = Gdk.RGBA()
-        if name in self.colors:
-            color.parse(self.colors[name])
-            dialog.set_rgba(color)
-        if dialog.run() == Gtk.ResponseType.OK:
-            color = dialog.get_rgba()
-            if start is not None and end is not None:
-                start = text_buffer.get_iter_at_offset(start)
-                end = text_buffer.get_iter_at_offset(end)
-                tag = register_foreground(text_buffer, color)
-                remove_tags(text_buffer, start, end, name)
-                text_buffer.apply_tag(tag, start, end)
-        dialog.destroy()
-        text_buffer.place_cursor(text_buffer.get_iter_at_mark(insert))

Reply via email to