comparison browser/sat_browser/editor_widget.py @ 1124:28e3eb3bb217

files reorganisation and installation rework: - files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory) - VERSION file is now used, as for other SàT projects - replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly - removed check for data_dir if it's empty - installation tested working in virtual env - libervia launching script is now in bin/libervia
author Goffi <goffi@goffi.org>
date Sat, 25 Aug 2018 17:59:48 +0200
parents src/browser/sat_browser/editor_widget.py@f2170536ba23
children 2af117bfe6cc
comparison
equal deleted inserted replaced
1123:63a4b8fe9782 1124:28e3eb3bb217
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2011-2018 Jérôme Poisson <goffi@goffi.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.log import getLogger
21 log = getLogger(__name__)
22 from sat_browser import strings
23
24 from pyjamas.ui.HTML import HTML
25 from pyjamas.ui.SimplePanel import SimplePanel
26 from pyjamas.ui.TextArea import TextArea
27 from pyjamas.ui import KeyboardListener as keyb
28 from pyjamas.ui.FocusListener import FocusHandler
29 from pyjamas.ui.ClickListener import ClickHandler
30 from pyjamas.ui.MouseListener import MouseHandler
31 from pyjamas.Timer import Timer
32 from pyjamas import DOM
33
34 import html_tools
35
36
37 class MessageBox(TextArea):
38 """A basic text area for entering messages"""
39
40 def __init__(self, host):
41 TextArea.__init__(self)
42 self.host = host
43 self.size = (0, 0)
44 self.setStyleName('messageBox')
45 self.addKeyboardListener(self)
46 MouseHandler.__init__(self)
47 self.addMouseListener(self)
48
49 def onBrowserEvent(self, event):
50 # XXX: woraroung a pyjamas bug: self.currentEvent is not set
51 # so the TextBox's cancelKey doens't work. This is a workaround
52 # FIXME: fix the bug upstream
53 self.currentEvent = event
54 TextArea.onBrowserEvent(self, event)
55
56 def onKeyPress(self, sender, keycode, modifiers):
57 _txt = self.getText()
58
59 def history_cb(text):
60 self.setText(text)
61 Timer(5, lambda timer: self.setCursorPos(len(text)))
62
63 if keycode == keyb.KEY_ENTER:
64 if _txt:
65 self.host.selected_widget.onTextEntered(_txt)
66 self.host._updateInputHistory(_txt) # FIXME: why using a global variable ?
67 self.setText('')
68 sender.cancelKey()
69 elif keycode == keyb.KEY_UP:
70 self.host._updateInputHistory(_txt, -1, history_cb)
71 elif keycode == keyb.KEY_DOWN:
72 self.host._updateInputHistory(_txt, +1, history_cb)
73 else:
74 self._onComposing()
75
76 def _onComposing(self):
77 """Callback when the user is composing a text."""
78 self.host.selected_widget.chat_state_machine._onEvent("composing")
79
80 def onMouseUp(self, sender, x, y):
81 size = (self.getOffsetWidth(), self.getOffsetHeight())
82 if size != self.size:
83 self.size = size
84 self.host.resize()
85
86 def onSelectedChange(self, selected):
87 self._selected_cache = selected
88
89
90 class BaseTextEditor(object):
91 """Basic definition of a text editor. The method edit gets a boolean parameter which
92 should be set to True when you want to edit the text and False to only display it."""
93
94 def __init__(self, content=None, strproc=None, modifiedCb=None, afterEditCb=None):
95 """
96 Remark when inheriting this class: since the setContent method could be
97 overwritten by the child class, you should consider calling this __init__
98 after all the parameters affecting this setContent method have been set.
99 @param content: dict with at least a 'text' key
100 @param strproc: method to be applied on strings to clean the content
101 @param modifiedCb: method to be called when the text has been modified.
102 This method can return:
103 - True: the modification will be saved and afterEditCb called;
104 - False: the modification won't be saved and afterEditCb called;
105 - None: the modification won't be saved and afterEditCb not called.
106 @param afterEditCb: method to be called when the edition is done
107 """
108 if content is None:
109 content = {'text': ''}
110 assert 'text' in content
111 if strproc is None:
112 def strproc(text):
113 try:
114 return text.strip()
115 except (TypeError, AttributeError):
116 return text
117 self.strproc = strproc
118 self._modifiedCb = modifiedCb
119 self._afterEditCb = afterEditCb
120 self.initialized = False
121 self.edit_listeners = []
122 self.setContent(content)
123
124 def setContent(self, content=None):
125 """Set the editable content.
126 The displayed content, which is set from the child class, could differ.
127
128 @param content (dict): content data, need at least a 'text' key
129 """
130 if content is None:
131 content = {'text': ''}
132 elif not isinstance(content, dict):
133 content = {'text': content}
134 assert 'text' in content
135 self._original_content = {}
136 for key in content:
137 self._original_content[key] = self.strproc(content[key])
138
139 def getContent(self):
140 """Get the current edited or editable content.
141 @return: dict with at least a 'text' key
142 """
143 raise NotImplementedError
144
145 def setOriginalContent(self, content):
146 """Use this method with care! Content initialization should normally be
147 done with self.setContent. This method exists to let you trick the editor,
148 e.g. for self.modified to return True also when nothing has been modified.
149 @param content: dict
150 """
151 self._original_content = content
152
153 def getOriginalContent(self):
154 """
155 @return (dict): the original content before modification (i.e. content given in __init__)
156 """
157 return self._original_content
158
159 def modified(self, content=None):
160 """Check if the content has been modified.
161 Remark: we don't use the direct comparison because we want to ignore empty elements
162 @content: content to be check against the original content or None to use the current content
163 @return: True if the content has been modified.
164 """
165 if content is None:
166 content = self.getContent()
167 # the following method returns True if one non empty element exists in a but not in b
168 diff1 = lambda a, b: [a[key] for key in set(a.keys()).difference(b.keys()) if a[key]] != []
169 # the following method returns True if the values for the common keys are not equals
170 diff2 = lambda a, b: [1 for key in set(a.keys()).intersection(b.keys()) if a[key] != b[key]] != []
171 # finally the combination of both to return True if a difference is found
172 diff = lambda a, b: diff1(a, b) or diff1(b, a) or diff2(a, b)
173
174 return diff(content, self._original_content)
175
176 def edit(self, edit, abort=False):
177 """
178 Remark: the editor must be visible before you call this method.
179 @param edit: set to True to edit the content or False to only display it
180 @param abort: set to True to cancel the edition and loose the changes.
181 If edit and abort are both True, self.abortEdition can be used to ask for a
182 confirmation. When edit is False and abort is True, abortion is actually done.
183 """
184 if edit:
185 self.setFocus(True)
186 if abort:
187 content = self.getContent()
188 if not self.modified(content) or self.abortEdition(content): # e.g: ask for confirmation
189 self.edit(False, True)
190 return
191 else:
192 if not self.initialized:
193 return
194 content = self.getContent()
195 if abort:
196 self._afterEditCb(content)
197 return
198 if self._modifiedCb and self.modified(content):
199 result = self._modifiedCb(content) # e.g.: send a message or update something
200 if result is not None:
201 if self._afterEditCb:
202 self._afterEditCb(content) # e.g.: restore the display mode
203 if result is True:
204 self.setContent(content)
205 elif self._afterEditCb:
206 self._afterEditCb(content)
207
208 self.initialized = True
209
210 def setFocus(self, focus):
211 """
212 @param focus: set to True to focus the editor
213 """
214 raise NotImplementedError
215
216 def abortEdition(self, content):
217 return True
218
219 def addEditListener(self, listener):
220 """Add a method to be called whenever the text is edited.
221 @param listener: method taking two arguments: sender, keycode"""
222 self.edit_listeners.append(listener)
223
224
225 class SimpleTextEditor(BaseTextEditor, FocusHandler, keyb.KeyboardHandler, ClickHandler):
226 """Base class for manage a simple text editor."""
227
228 CONVERT_NEW_LINES = True
229 VALIDATE_WITH_SHIFT_ENTER = True
230
231 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
232 """
233 @param content
234 @param modifiedCb
235 @param afterEditCb
236 @param options (dict): can have the following value:
237 - no_xhtml: set to True to clean any xhtml content.
238 - enhance_display: if True, the display text will be enhanced with strings.addURLToText
239 - listen_keyboard: set to True to terminate the edition with <enter> or <escape>.
240 - listen_focus: set to True to terminate the edition when the focus is lost.
241 - listen_click: set to True to start the edition when you click on the widget.
242 """
243 self.options = {'no_xhtml': False,
244 'enhance_display': True,
245 'listen_keyboard': True,
246 'listen_focus': False,
247 'listen_click': False
248 }
249 if options:
250 self.options.update(options)
251 if self.options['listen_focus']:
252 FocusHandler.__init__(self)
253 if self.options['listen_click']:
254 ClickHandler.__init__(self)
255 keyb.KeyboardHandler.__init__(self)
256 strproc = lambda text: html_tools.html_sanitize(html_tools.html_strip(text)) if self.options['no_xhtml'] else html_tools.html_strip(text)
257 BaseTextEditor.__init__(self, content, strproc, modifiedCb, afterEditCb)
258 self.textarea = self.display = None
259
260 def setContent(self, content=None):
261 BaseTextEditor.setContent(self, content)
262
263 def getContent(self):
264 raise NotImplementedError
265
266 def edit(self, edit, abort=False):
267 BaseTextEditor.edit(self, edit)
268 if edit:
269 if self.options['listen_focus'] and self not in self.textarea._focusListeners:
270 self.textarea.addFocusListener(self)
271 if self.options['listen_click']:
272 self.display.clearClickListener()
273 if self not in self.textarea._keyboardListeners:
274 self.textarea.addKeyboardListener(self)
275 else:
276 self.setDisplayContent()
277 if self.options['listen_focus']:
278 try:
279 self.textarea.removeFocusListener(self)
280 except ValueError:
281 pass
282 if self.options['listen_click'] and self not in self.display._clickListeners:
283 self.display.addClickListener(self)
284 try:
285 self.textarea.removeKeyboardListener(self)
286 except ValueError:
287 pass
288
289 def setDisplayContent(self):
290 text = self._original_content['text']
291 if not self.options['no_xhtml']:
292 text = strings.addURLToImage(text)
293 if self.options['enhance_display']:
294 text = strings.addURLToText(text)
295 if self.CONVERT_NEW_LINES:
296 text = html_tools.convertNewLinesToXHTML(text)
297 text = strings.fixXHTMLLinks(text)
298 self.display.setHTML(text)
299
300 def setFocus(self, focus):
301 raise NotImplementedError
302
303 def onKeyDown(self, sender, keycode, modifiers):
304 for listener in self.edit_listeners:
305 listener(self.textarea, keycode, modifiers) # FIXME: edit_listeners must either be removed, or send an action instead of keycode/modifiers
306 if not self.options['listen_keyboard']:
307 return
308 if keycode == keyb.KEY_ENTER and (not self.VALIDATE_WITH_SHIFT_ENTER or modifiers & keyb.MODIFIER_SHIFT):
309 self.textarea.setFocus(False)
310 if not self.options['listen_focus']:
311 self.edit(False)
312
313 def onLostFocus(self, sender):
314 """Finish the edition when focus is lost"""
315 if self.options['listen_focus']:
316 self.edit(False)
317
318 def onClick(self, sender=None):
319 """Start the edition when the widget is clicked"""
320 if self.options['listen_click']:
321 self.edit(True)
322
323 def onBrowserEvent(self, event):
324 if self.options['listen_focus']:
325 FocusHandler.onBrowserEvent(self, event)
326 if self.options['listen_click']:
327 ClickHandler.onBrowserEvent(self, event)
328 keyb.KeyboardHandler.onBrowserEvent(self, event)
329
330
331 class HTMLTextEditor(SimpleTextEditor, HTML, FocusHandler, keyb.KeyboardHandler):
332 """Manage a simple text editor with the HTML 5 "contenteditable" property."""
333
334 CONVERT_NEW_LINES = False # overwrite definition in SimpleTextEditor
335
336 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
337 HTML.__init__(self)
338 SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
339 self.textarea = self.display = self
340
341 def getContent(self):
342 text = DOM.getInnerHTML(self.getElement())
343 return {'text': self.strproc(text) if text else ''}
344
345 def edit(self, edit, abort=False):
346 if edit:
347 self.textarea.setHTML(self._original_content['text'])
348 self.getElement().setAttribute('contenteditable', 'true' if edit else 'false')
349 SimpleTextEditor.edit(self, edit, abort)
350
351 def setFocus(self, focus):
352 if focus:
353 self.getElement().focus()
354 else:
355 self.getElement().blur()
356
357
358 class LightTextEditor(SimpleTextEditor, SimplePanel, FocusHandler, keyb.KeyboardHandler):
359 """Manage a simple text editor with a TextArea for editing, HTML for display."""
360
361 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
362 SimplePanel.__init__(self)
363 SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
364 self.textarea = TextArea()
365 self.display = HTML()
366
367 def getContent(self):
368 text = self.textarea.getText()
369 return {'text': self.strproc(text) if text else ''}
370
371 def edit(self, edit, abort=False):
372 if edit:
373 self.textarea.setText(self._original_content['text'])
374 self.setWidget(self.textarea if edit else self.display)
375 SimpleTextEditor.edit(self, edit, abort)
376
377 def setFocus(self, focus):
378 if focus and self.isAttached():
379 self.textarea.setCursorPos(len(self.textarea.getText()))
380 self.textarea.setFocus(focus)