# -*- coding: UTF-8 -*- # calculate.py, sugar calculator, by: # Reinier Heeres # Miguel Alvarez # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # # Change log: # 2007-07-03: rwh, first version import types import os from gettext import gettext as _ import string import logging _logger = logging.getLogger('Calculate') import gobject import pygtk pygtk.require('2.0') import gtk import pango import base64 from sugar.activity import activity import sugar.profile from sugar.graphics.icon import CanvasIcon from sugar.graphics.xocolor import XoColor from shareable_activity import ShareableActivity from layout import CalcLayout from mathlib import MathLib from astparser import AstParser, ParseError from svgimage import SVGImage from decimal import Decimal from rational import Rational class Equation: def __init__(self, label=None, eqn=None, res=None, col=None, owner=None, \ eqnstr=None, ml=None): if eqnstr is not None: self.parse(eqnstr) elif eqn is not None: self.set(label, eqn, res, col, owner) self.ml = ml def set(self, label, eqn, res, col, owner): """Set equation properties.""" self.label = label self.equation = eqn self.result = res self.color = col self.owner = owner def __str__(self): if isinstance(self.result, SVGImage): svg_data = "" + base64.b64encode(self.result.get_svg_data()) return "%s;%s;%s;%s;%s\n" % \ (self.label, self.equation, svg_data, self.color.to_string(), self.owner) else: return "%s;%s;%s;%s;%s\n" % \ (self.label, self.equation, self.result, self.color.to_string(), self.owner) def parse(self, str): """Parse equation object string representation.""" str = str.rstrip("\r\n") l = str.split(';') if len(l) != 5: _logger.error(_('Equation.parse() string invalid (%s)'), str) return False if l[2].startswith(""): l[2] = SVGImage(data=base64.b64decode(l[2][5:])) # Should figure out how to use MathLib directly in a non-hacky way else: try: l[2] = Decimal(l[2]) except Exception, inst: pass self.set(l[0], l[1], l[2], XoColor(color_string=l[3]), l[4]) def format_history_buf(self, buf): """Apply proper formatting to a gtk.TextBuffer for the history""" iter_start = buf.get_start_iter() iter_colon = buf.get_start_iter() iter_end = buf.get_end_iter() iter_middle = buf.get_iter_at_line(1) try: pos = buf.get_text(iter_start, iter_end).index(':') iter_colon.forward_chars(pos) except: buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_SMALL), iter_start, iter_middle) else: buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_SMALL_NARROW), iter_start, iter_colon) buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_SMALL), iter_colon, iter_middle) buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_BIG, justification=gtk.JUSTIFY_RIGHT), iter_middle, iter_end) col = self.color.get_fill_color() buf.apply_tag(buf.create_tag(foreground=col), iter_start, iter_end) def create_textview(self): """Create a gtk.TextView object for this equation.""" if isinstance(self.result, SVGImage): w = self.result.get_image() else: text = "" if len(self.label) > 0: text += str(self.label) + ": " r = self.ml.format_number(self.result) text += str(self.equation) + "\n=" + r w = gtk.TextView() w.set_wrap_mode(gtk.WRAP_WORD) b = w.get_buffer() b.set_text(text) self.format_history_buf(b) return w class Calculate(ShareableActivity): TYPE_FUNCTION = 1 TYPE_OP_PRE = 2 TYPE_OP_POST = 3 TYPE_TEXT = 4 SELECT_NONE = 0 SELECT_SELECT = 1 SELECT_TAB = 2 KEYMAP = { 'Return': lambda o: o.process(), 'period': '.', 'equal': '=', 'plus': '+', 'minus': '-', 'asterisk': '*', 'multiply': '', 'divide': '', 'slash': '/', 'BackSpace': lambda o: o.remove_character(-1), 'Delete': lambda o: o.remove_character(1), 'parenleft': '(', 'parenright': ')', 'exclam': '!', 'ampersand': '&', 'bar': '|', 'asciicircum': '^', 'less': '<', 'greater': '>', 'percent': '%', 'comma': ',', 'underscore': '_', 'Left': lambda o: o.move_left(), 'Right': lambda o: o.move_right(), 'Up': lambda o: o.get_older(), 'Down': lambda o: o.get_newer(), 'colon': lambda o: o.label_entered(), 'Home': lambda o: o.text_entry.set_position(0), 'End': lambda o: o.text_entry.set_position(len(o.text_entry.get_text())), 'Tab': lambda o: o.tab_complete(), } CTRL_KEYMAP = { 'c': lambda o: o.text_copy(), 'v': lambda o: o.text_paste(), 'x': lambda o: o.text_cut(), } SHIFT_KEYMAP = { 'Left': lambda o: o.expand_selection(-1), 'Right': lambda o: o.expand_selection(1), 'Home': lambda o: o.expand_selection(-1000), 'End': lambda o: o.expand_selection(1000), } IDENTIFIER_CHARS = u"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_ " def __init__(self, handle): ShareableActivity.__init__(self, handle) self.old_eqs = [] self.ml = MathLib() self.parser = AstParser(self.ml) self.KEYMAP['multiply'] = self.ml.mul_sym self.KEYMAP['divide'] = self.ml.div_sym self.clipboard = gtk.Clipboard() self.select_reason = self.SELECT_SELECT self.buffer = u"" self.showing_version = 0 self.showing_error = False self.ans_inserted = False self.show_vars = False self.connect("key_press_event", self.keypress_cb) self.connect("destroy", self.cleanup_cb) self.color = sugar.profile.get_color() ## self.icon = CanvasIcon( ## icon_name = 'theme:stock-buddy', ## xo_color = XoColor(self.color)) self.layout = CalcLayout(self) self.label_entry = self.layout.label_entry self.text_entry = self.layout.text_entry self.last_eq = self.layout.last_eq.get_buffer() self.last_eq_sig = None self.last_eqn_textview = None self.reset() self.layout.show_it() self.connect('joined', self._joined_cb) self.parser.log_debug_info() def ignore_key_cb(self, widget, event): return True def cleanup_cb(self, arg): _logger.debug('Cleaning up...') def equation_pressed_cb(self, eqn): """Callback for when an equation box is clicked""" if isinstance(eqn.result, SVGImage): return True if len(eqn.label) > 0: text = eqn.label else: text = self.parser.ml.format_number(eqn.result) self.button_pressed(self.TYPE_TEXT, text) return True def format_last_eq_buf(self, buf, res, offset=0): """ Format the 'last equation' gtk.TextBuffer properly. Input: buf: the gtk.TextBuffer res: the result, ParseError object in case of error offset: offset where the equation starts in the TextBuffer """ eq_start = buf.get_start_iter() eq_middle = buf.get_iter_at_line(1) eq_end = buf.get_end_iter() buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_BIG_NARROW), eq_start, eq_middle) # String results should be a little smaller if type(res) == types.StringType or res is None: buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_BIG_NARROW, justification=gtk.JUSTIFY_RIGHT), eq_middle, eq_end) else: buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_BIGGER, justification=gtk.JUSTIFY_RIGHT), eq_middle, eq_end) # Format error if isinstance(res, ParseError): range = res.get_range() eq_start.forward_chars(range[0] + offset) end = self.last_eq.get_start_iter() end.forward_chars(range[1] + offset) self.last_eq.apply_tag(self.last_eq.create_tag(foreground='#FF0000'), eq_start, end) self.last_eq.apply_tag(self.last_eq.create_tag(foreground='#FF0000'), eq_middle, eq_end) def set_last_equation(self, eqn): """Fill the 'last equation' TextView""" if len(eqn.label) > 0: text = eqn.label + ': ' + eqn.equation offset = len(eqn.label) + 2 else: text = eqn.equation offset = 0 if self.last_eq_sig is not None: self.layout.last_eq.disconnect(self.last_eq_sig) self.last_eq_sig = None if isinstance(eqn.result, ParseError): text += "\n" + str(eqn.result) else: if isinstance(eqn.result, SVGImage): pass else: text += '\n= ' + self.ml.format_number(eqn.result) self.last_eq_sig = self.layout.last_eq.connect('button-press-event', \ lambda a1, a2, e: self.equation_pressed_cb(e), eqn) self.last_eq.set_text(text) self.format_last_eq_buf(self.last_eq, eqn.result, offset) def set_error_equation(self, eqn): """Set equation with error markers. Since set_last_equation implements this we can just forward the call.""" self.set_last_equation(eqn) def clear_equations(self): """Clear the list of old equations.""" self.old_eqs = [] self.showing_version = 0 def add_equation(self, eq, prepend=False, drawlasteq=False, tree=None): """ Insert equation in the history list and set variable if assignment. Input: eq: the equation object prepend: if True, prepend to list, else append drawlasteq: if True, draw in 'last equation' textbox and queue the buffer to be added to the history next time an equation is added. tree: the parsed tree, this will be used to set the label variable so that the equation can be used symbolicaly. """ if eq.equation is not None and len(eq.equation) > 0: if prepend: self.old_eqs.insert(0, eq) else: self.old_eqs.append(eq) self.showing_version = len(self.old_eqs) if self.last_eqn_textview is not None and drawlasteq: # Prepending here should be the opposite: prepend -> eqn on top. # We always own this equation self.layout.add_equation(self.last_eqn_textview, True, prepend=not prepend) self.last_eqn_textview = None own = (eq.owner == self.get_owner_id()) w = eq.create_textview() w.connect('button-press-event', lambda w, e: self.equation_pressed_cb(eq)) if drawlasteq: self.set_last_equation(eq) # SVG images can't be plotted in last equation window if isinstance(eq.result, SVGImage): self.layout.add_equation(w, own, prepend=not prepend) else: self.last_eqn_textview = w else: self.layout.add_equation(w, own, prepend=not prepend) if eq.label is not None and len(eq.label) > 0: w = self.create_var_textview(eq.label, eq.result) if w is not None: self.layout.add_variable(eq.label, w) if tree is None: tree = self.parser.parse(eq.equation) self.parser.set_var(eq.label, tree) def process_async(self, eqn): """Parse and process an equation asynchronously.""" def process(self): """Parse the equation entered and show the result""" s = unicode(self.text_entry.get_text()) label = unicode(self.label_entry.get_text()) _logger.debug('process(): parsing %r, label: %r', s, label) try: tree = self.parser.parse(s) res = self.parser.evaluate(tree) except ParseError, e: res = e self.showing_error = True if type(res) == types.StringType and res.find('') > -1: res = SVGImage(data=res) _logger.debug('Result: %r', res) # If parsing went ok, see if we have to replace the previous answer # to get a (more) exact result if self.ans_inserted and not isinstance(res, ParseError) and \ not isinstance(res, SVGImage): ansvar = self.format_insert_ans() pos = s.find(ansvar) if len(ansvar) > 6 and pos != -1: s2 = s.replace(ansvar, 'LastEqn') _logger.debug('process(): replacing previous answer %r: %r', ansvar, s2) tree = self.parser.parse(s2) res = self.parser.evaluate(tree) eqn = Equation(label, s, res, self.color, self.get_owner_id(), ml=self.ml) if isinstance(res, ParseError): self.set_error_equation(eqn) else: self.add_equation(eqn, drawlasteq=True, tree=tree) self.send_message("add_eq", value=str(eqn)) self.parser.set_var('Ans', eqn.result) self.parser.set_var('LastEqn', tree) self.showing_error = False self.ans_inserted = False self.text_entry.set_text(u'') self.label_entry.set_text(u'') return res is not None def format_var_buf(self, buf): """Apply formatting to a gtk.TextBuffer to show a variable""" iter_start = buf.get_start_iter() iter_end = buf.get_end_iter() buf.apply_tag(buf.create_tag(font=CalcLayout.FONT_SMALL_NARROW), iter_start, iter_end) col = self.color.get_fill_color() buf.apply_tag(buf.create_tag(foreground=col), iter_start, iter_end) def create_var_textview(self, name, value): """Create a gtk.TextView for a variable""" reserved = ["Ans", "LastEqn", "help"] if name in reserved: return None w = gtk.TextView() w.set_left_margin(5) w.set_right_margin(5) b = w.get_buffer() b.set_text(name + ":\t" + str(value)) self.format_var_buf(b) return w def clear(self): self.text_entry.set_text(u'') self.text_entry.grab_focus() return True def reset(self): self.clear() return True ########################################## # Journal functions ########################################## def write_file(self, file_path): """Write journal entries, Calculate Journal Version (cjv) 1.0""" _logger.info(_('Writing to journal (%s)'), file_path) f = open(file_path, 'w') f.write("cjv 1.0\n") sel = self.text_entry.get_selection_bounds() pos = self.text_entry.get_position() if len(sel) == 0: sel = (pos, pos) f.write("%s;%d;%d;%d\n" % (self.text_entry.get_text(), pos, sel[0], sel[1])) # In reverse order for eq in self.old_eqs: f.write(str(eq)) f.close() def read_file(self, file_path): """Read journal entries, version 1.0""" _logger.info('Reading from journal (%s)', file_path) f = open(file_path, 'r') str = f.readline().rstrip("\r\n") # chomp l = str.split() if len(l) != 2: _logger.error('Unable to determine version') return False version = l[1] if len(version) > 1 and version[0:2] == "1.": _logger.info('Reading journal entry (version %s)', version) str = f.readline().rstrip("\r\n") l = str.split(';') if len(l) != 4: _logger.error('State line invalid (%s)', str) return False self.text_entry.set_text(l[0]) self.text_entry.set_position(int(l[1])) if l[2] != l[3]: self.text_entry.select_region(int(l[2]), int(l[3])) self.clear_equations() for str in f: eq = Equation(eqnstr=str, ml=self.ml) self.add_equation(eq, prepend=False) return True else: _logger.error('Unable to read journal entry, unknown version (%s)', version) return False ########################################## # User interaction functions ########################################## def remove_character(self, dir): pos = self.text_entry.get_position() str = self.text_entry.get_text() sel = self.text_entry.get_selection_bounds() if len(sel) == 0: if pos + dir <= len(self.text_entry.get_text()) and pos + dir >= 0: if dir < 0: self.text_entry.delete_text(pos+dir, pos) else: self.text_entry.delete_text(pos, pos+dir) else: self.text_entry.delete_text(sel[0], sel[1]) def move_left(self): pos = self.text_entry.get_position() if pos > 0: self.text_entry.set_position(pos - 1) def move_right(self): pos = self.text_entry.get_position() if pos < len(self.text_entry.get_text()): self.text_entry.set_position(pos + 1) def label_entered(self): if len(self.label_entry.get_text()) > 0: return pos = self.text_entry.get_position() str = self.text_entry.get_text() self.label_entry.set_text(str[:pos]) self.text_entry.set_text(str[pos:]) def tab_complete(self): # Get start of variable name str = self.text_entry.get_text() if len(str) == 0: return sel = self.text_entry.get_selection_bounds() if len(sel) == 0: end_ofs = self.text_entry.get_position() else: end_ofs = sel[0] start_ofs = end_ofs - 1 while start_ofs > 0 and str[start_ofs - 1] in self.IDENTIFIER_CHARS: start_ofs -= 1 if end_ofs - start_ofs <= 0: return False partial_name = str[start_ofs:end_ofs] _logger.debug('tab-completing %s...', partial_name) # Lookup matching variables vars = self.parser.get_names(start=partial_name) if len(vars) == 0: return False # Nothing selected, select first match if len(sel) == 0: name = vars[0] self.text_entry.set_text(str[:start_ofs] + name + str[end_ofs:]) # Select next matching variable else: full_name = str[start_ofs:sel[1]] if full_name not in vars: name = vars[0] else: name = vars[(vars.index(full_name) + 1) % len(vars)] self.text_entry.set_text(str[:start_ofs] + name + str[sel[1]:]) self.text_entry.set_position(start_ofs + len(name)) self.text_entry.select_region(end_ofs, start_ofs + len(name)) self.select_reason = self.SELECT_TAB return True # Selection related functions def expand_selection(self, dir): # _logger.info('Expanding selection in dir %d', dir) sel = self.text_entry.get_selection_bounds() slen = len(self.text_entry.get_text()) pos = self.text_entry.get_position() if len(sel) == 0: sel = (pos, pos) if dir < 0: newpos = max(0, sel[0] + dir) self.text_entry.set_position(newpos) # apparently no such thing as a cursor position during select self.text_entry.select_region(newpos, sel[1]) elif dir > 0: newpos = min(sel[1] + dir, slen) self.text_entry.set_position(newpos) self.text_entry.select_region(sel[0], newpos) self.select_reason = self.SELECT_SELECT def text_copy(self): str = self.text_entry.get_text() sel = self.text_entry.get_selection_bounds() # _logger.info('text_copy, sel: %r, str: %s', sel, str) if len(sel) == 2: (start, end) = sel self.clipboard.set_text(str[start:end]) def get_clipboard_text(self): text = self.clipboard.wait_for_text() if text is None: return "" else: return text def text_paste(self): self.button_pressed(self.TYPE_TEXT, self.get_clipboard_text()) def text_cut(self): self.text_copy() self.remove_character(1) def keypress_cb(self, widget, event): if self.label_entry.is_focus() or \ self.toolbox.get_activity_toolbar().title.is_focus(): return key = gtk.gdk.keyval_name(event.keyval) if event.hardware_keycode == 219: if (event.state & gtk.gdk.SHIFT_MASK): key = 'divide' else: key = 'multiply' _logger.debug('Key: %s (%r, %r)', key, event.keyval, event.hardware_keycode) if (event.state & gtk.gdk.CONTROL_MASK) and self.CTRL_KEYMAP.has_key(key): f = self.CTRL_KEYMAP[key] return f(self) elif (event.state & gtk.gdk.SHIFT_MASK) and self.SHIFT_KEYMAP.has_key(key): f = self.SHIFT_KEYMAP[key] return f(self) elif unicode(key) in self.IDENTIFIER_CHARS: self.button_pressed(self.TYPE_TEXT, key) elif self.KEYMAP.has_key(key): f = self.KEYMAP[key] if type(f) is types.StringType or \ type(f) is types.UnicodeType: self.button_pressed(self.TYPE_TEXT, f) else: return f(self) return True def get_older(self): self.showing_version = max(0, self.showing_version - 1) if self.showing_version == len(self.old_eqs) - 1: self.buffer = self.text_entry.get_text() if len(self.old_eqs) > 0: self.text_entry.set_text(self.old_eqs[self.showing_version].equation) def get_newer(self): self.showing_version = min(len(self.old_eqs), self.showing_version + 1) if self.showing_version == len(self.old_eqs): self.text_entry.set_text(self.buffer) else: self.text_entry.set_text(self.old_eqs[self.showing_version].equation) def add_text(self, str): self.button_pressed(self.TYPE_TEXT, str) # This function should be split up properly def button_pressed(self, type, str): sel = self.text_entry.get_selection_bounds() pos = self.text_entry.get_position() # If selection by tab completion just manipulate end if len(sel) == 2 and self.select_reason != self.SELECT_SELECT: pos = sel[1] sel = () self.text_entry.grab_focus() if len(sel) == 2: (start, end) = sel text = self.text_entry.get_text() elif len(sel) != 0: _logger.error('button_pressed(): len(sel) != 0 or 2') return False if type == self.TYPE_FUNCTION: if len(sel) == 0: self.text_entry.insert_text(str + '()', pos) self.text_entry.set_position(pos + len(str) + 1) else: self.text_entry.set_text(text[:start] + str + '(' + text[start:end] + ')' + text[end:]) self.text_entry.set_position(end + len(str) + 2) elif type == self.TYPE_OP_PRE: if len(sel) is 2: pos = start self.text_entry.insert_text(str, pos) self.text_entry.set_position(pos + len(str)) elif type == self.TYPE_OP_POST: if len(sel) is 2: pos = end elif pos == 0: ans = self.format_insert_ans() str = ans + str self.ans_inserted = True self.text_entry.insert_text(str, pos) self.text_entry.set_position(pos + len(str)) elif type == self.TYPE_TEXT: tlen = len(self.text_entry.get_text()) if len(sel) == 2: tlen -= (end - start) if tlen == 0 and (str in self.parser.get_diadic_operators() \ or str in self.parser.get_post_operators()) and \ self.parser.get_var('Ans') is not None: # and \ # logic better? (str not in self.parser.get_pre_operators() or str == '+'): ans = self.format_insert_ans() self.text_entry.set_text(ans + str) self.text_entry.set_position(len(ans) + len(str)) self.ans_inserted = True elif len(sel) is 2: self.text_entry.set_text(text[:start] + str + text[end:]) self.text_entry.set_position(pos + start - end + len(str)) else: self.text_entry.insert_text(str, pos) self.text_entry.set_position(pos + len(str)) else: _logger.error(_('button_pressed(): invalid type')) def message_received(self, msg, **kwargs): _logger.debug('Message received: %s(%r)', msg, kwargs) value = kwargs.get('value', None) if msg == "add_eq": eq = Equation(eqnstr=str(value), ml=self.ml) self.add_equation(eq) elif msg == "req_sync": data = [] for eq in self.old_eqs: data.append(str(eq)) self.send_message("sync", value=data) elif msg == "sync": tmp = [] self.clear_equations() for eq_str in value: _logger.debug('receive_message: %s', str(eq_str)) self.add_equation(Equation(eqnstr=str(eq_str)), ml=self.ml) def _joined_cb(self, gobj): _logger.debug('Requesting synchronization') self.send_message('req_sync') def format_insert_ans(self): ans = self.parser.get_var('Ans') if isinstance(ans, Rational): return str(ans) elif ans is not None: return self.ml.format_number(ans) else: return '' def main(): win = gtk.Window(gtk.WINDOW_TOPLEVEL) t = Calculate(win) gtk.main() return 0 if __name__ == "__main__": main()