# Copyright 2008 by Kate Scheppke and Wade Brainerd. # This file is part of Typing Turtle. # # Typing Turtle 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 3 of the License, or # (at your option) any later version. # # Typing Turtle 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 Typing Turtle. If not, see . #!/usr/bin/env python """Typing Turtle - Interactive typing tutor for the OLPC XO.""" # Import standard Python modules. import logging, os, math, time, copy, json, locale, datetime, random, re from gettext import gettext as _ # Set up localization. locale.setlocale(locale.LC_ALL, '') # Import PyGTK. import gobject, pygtk, gtk, pango # Import Sugar UI modules. import sugar.activity.activity from sugar.graphics import * from sugar.presence import presenceservice # Initialize logging. log = logging.getLogger('Typing Turtle') log.setLevel(logging.DEBUG) logging.basicConfig() # Import onscreen keyboard. import keyboard # Paragraph symbol unicode character. PARAGRAPH_CODE = u'\xb6' # Maximium width of a text line in text lesson mode. LINE_WIDTH = 80 # Requirements for earning medals. # Words per minute goals came from http://en.wikipedia.org/wiki/Words_per_minute. MEDALS = [ { 'name': 'bronze', 'wpm': 25, 'accuracy': 75 }, { 'name': 'silver', 'wpm': 35, 'accuracy': 85 }, { 'name': 'gold', 'wpm': 45, 'accuracy': 95 } ] class MedalScreen(gtk.EventBox): def __init__(self, medal, activity): gtk.EventBox.__init__(self) self.modify_bg(gtk.STATE_NORMAL, self.get_colormap().alloc_color('#ffffff')) self.medal = medal self.activity = activity cert0 = gtk.Label() cert0.set_markup("" + _('Certificate of\nAchievement') + "") cert1 = gtk.Label() cert1.set_markup("" + (_('This certifies that on %(date)s,\n%(nick)s earned a %(type)s medal\nin Typing Turtle lesson %(lesson)s.') % medal) + "") wpmlabel = gtk.Label() wpmlabel.set_markup("" + (_('Words Per Minute: %(wpm)d') % medal) + "" ) accuracylabel = gtk.Label() accuracylabel.set_markup("" + (_('Accuracy: %(accuracy)d%%') % medal) + "" ) statbox = gtk.HBox() statbox.pack_start(wpmlabel, True) statbox.pack_start(accuracylabel, True) oklabel = gtk.Label() oklabel.set_markup("" + _('Go Back') + '') okbtn = gtk.Button() okbtn.add(oklabel) okbtn.connect('clicked', self.ok_cb) btnbox = gtk.HBox() btnbox.pack_start(okbtn, True, False) vbox = gtk.VBox() vbox.pack_start(cert0, True, False, 0) vbox.pack_start(cert1, False, False, 0) vbox.pack_start(gtk.HSeparator(), False, False, 20) vbox.pack_start(statbox, False, False, 0) vbox.pack_start(gtk.HSeparator(), False, False, 20) vbox.pack_start(btnbox, False, False, 40) self.add(vbox) self.show_all() def ok_cb(self, widget): self.activity.pop_screen() class LessonScreen(gtk.VBox): def __init__(self, lesson, activity): gtk.VBox.__init__(self) self.lesson = lesson self.activity = activity # Build the user interface. title = gtk.Label() title.set_markup("" + lesson['name'] + "") title.set_alignment(1.0, 0.0) stoplabel = gtk.Label(_('Go Back')) stopbtn = gtk.Button() stopbtn.add(stoplabel) stopbtn.connect('clicked', self.stop_cb) # TODO- These will be replaced by graphical displays using gtk.DrawingArea. self.wpmlabel = gtk.Label() self.accuracylabel = gtk.Label() #self.wpmarea = gtk.DrawingArea() #self.wpmarea.connect('expose-event', self.wpm_expose_cb) #self.accuracyarea = gtk.DrawingArea() #self.accuracyarea.connect('expose-event', self.accuracy_expose_cb) gobject.timeout_add(250, self.timer_cb) hbox = gtk.HBox() hbox.pack_start(stopbtn, False, False, 10) hbox.pack_start(self.wpmlabel, True, False, 10) hbox.pack_start(self.accuracylabel, True, False, 10) hbox.pack_end(title, False, False, 10) # Set up font styles. self.tagtable = gtk.TextTagTable() instructions_tag = gtk.TextTag('instructions') #instructions_tag.props.size = 10000 instructions_tag.props.justification = gtk.JUSTIFY_CENTER self.tagtable.add(instructions_tag) text_tag = gtk.TextTag('text') text_tag.props.family = 'Monospace' self.tagtable.add(text_tag) correct_copy_tag = gtk.TextTag('correct-copy') correct_copy_tag.props.family = 'Monospace' correct_copy_tag.props.foreground = '#0000ff' self.tagtable.add(correct_copy_tag) incorrect_copy_tag = gtk.TextTag('incorrect-copy') incorrect_copy_tag.props.family = 'Monospace' incorrect_copy_tag.props.foreground = '#ff0000' self.tagtable.add(incorrect_copy_tag) # Set up the scrolling lesson text view. self.lessonbuffer = gtk.TextBuffer(self.tagtable) self.lessontext = gtk.TextView(self.lessonbuffer) self.lessontext.set_editable(False) self.lessontext.set_left_margin(20) self.lessontext.set_right_margin(20) self.lessontext.set_wrap_mode(gtk.WRAP_WORD) self.lessonscroll = gtk.ScrolledWindow() self.lessonscroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_ALWAYS) self.lessonscroll.add(self.lessontext) frame = gtk.Frame() frame.add(self.lessonscroll) self.keyboard = keyboard.Keyboard(self.activity) self.keyboard.set_layout(keyboard.DEFAULT_LAYOUT) activity.add_events(gtk.gdk.KEY_PRESS_MASK) activity.connect('key-press-event', self.key_press_cb) self.pack_start(hbox, False, False, 10) self.pack_start(frame, True, True) self.pack_start(self.keyboard, True) self.show_all() self.begin_lesson() def update_stats(self): if not self.start_time or self.lesson_finished: return self.total_time = time.time() - self.start_time if self.total_time >= 1.0: self.wpm = 60 * (self.correct_keys / 5) / self.total_time else: self.wpm = 1.0 self.accuracy = 100.0 * self.correct_keys / self.total_keys self.accuracylabel.set_markup(_('Accuracy: %(accuracy)d%%') % { 'accuracy' : int(self.accuracy) } ) self.wpmlabel.set_markup(_('WPM: %(wpm)d') % { 'wpm': int(self.wpm) } ) def timer_cb(self): self.update_stats() return True def begin_lesson(self): self.lesson_finished = False self.medal = None self.total_keys = 0 self.correct_keys = 0 self.incorrect_keys = 0 self.start_time = None self.next_step_idx = 0 self.advance_step() def wrap_line(self, line): words = re.split('(\W+)', line) new_lines = [] cur_line = '' for w in words: # TODO: Handle single word longer than a line. if not w.isspace() and len(cur_line) + len(w) > LINE_WIDTH: if len(cur_line): new_lines.append(cur_line) cur_line = '' cur_line += w if len(cur_line): new_lines.append(cur_line) return new_lines def advance_step(self): # Clear step related variables. self.step = None self.text = None self.line = None self.line_marks = None # End lesson if this is the last step. if self.next_step_idx >= len(self.lesson['steps']): print "Lesson finished." self.lesson_finished = True self.show_lesson_report() return # TODO - Play 'step finished' sound here. self.step = self.lesson['steps'][self.next_step_idx] self.next_step_idx = self.next_step_idx + 1 # Clear the text buffer and output the instructions. self.lessonbuffer.insert_with_tags_by_name( self.lessonbuffer.get_end_iter(), '\n\n' + self.step['instructions'] + '\n', 'instructions') self.text = unicode(self.step['text']) # Split text into lines. self.lines = self.text.splitlines(True) # Substitute paragraph codes. self.lines = [l.replace('\n', PARAGRAPH_CODE) for l in self.lines] # Split by line length in addition to by paragraphs. for i in range(0, len(self.lines)): line = self.lines[i] if len(line) > LINE_WIDTH: self.lines[i:i+1] = self.wrap_line(line) # Center single line steps. indent = '' #if len(self.lines) == 1: # indent = ' ' * ((LINE_LENGTH - len(self.lines[0]))/2) # Fill text buffer with text lines, each followed by room for the user to type. self.line_marks = {} line_idx = 0 for l in self.lines: # Add the text to copy. self.lessonbuffer.insert_with_tags_by_name( self.lessonbuffer.get_end_iter(), '\n' + indent + l.encode('utf-8') + '\n' + indent, 'text') # Leave a marker where we will later insert text. self.line_marks[line_idx] = self.lessonbuffer.create_mark(None, self.lessonbuffer.get_end_iter(), True) line_idx += 1 self.line_idx = 0 self.begin_line() def begin_line(self): self.line = self.lines[self.line_idx] self.line_mark = self.line_marks[self.line_idx] self.char_idx = 0 self.hilite_next_key() def key_press_cb(self, widget, event): # Ignore hotkeys. if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.MOD1_MASK): return # Extract information about the key pressed. key = gtk.gdk.keyval_to_unicode(event.keyval) if key != 0: key = chr(key) key_name = gtk.gdk.keyval_name(event.keyval) # Simply wait for a return keypress on the lesson finished screen. if self.lesson_finished: # TODO: Wait a second first. if key_name == 'Return': self.end_lesson() return # Convert Return keys to paragraph symbols. if key_name == 'Return': key = PARAGRAPH_CODE print "key_press_cb: key=%s key_name=%s event.keyval=%d" % (key, key_name, event.keyval) # Timer starts with first keypress. if not self.start_time: self.start_time = time.time() # Handle backspace by deleting text and optionally moving up lines. if key_name == 'BackSpace': # Move to previous line if at the end of the current one. if self.char_idx == 0 and self.line_idx > 0: self.line_idx -= 1 self.begin_line() self.char_idx = len(self.line) # Then delete the current character. if self.char_idx > 0: self.char_idx -= 1 iter = self.lessonbuffer.get_iter_at_mark(self.line_mark) iter.forward_chars(self.char_idx) iter_end = iter.copy() iter_end.forward_char() self.lessonbuffer.delete(iter, iter_end) self.hilite_next_key() # Process normal key presses. elif key != 0: # Check to see if they pressed the correct key. if key == self.line[self.char_idx]: tag_name = 'correct-copy' self.correct_keys += 1 self.total_keys += 1 else: # TODO - Play 'incorrect key' sound here. tag_name = 'incorrect-copy' self.incorrect_keys += 1 self.total_keys += 1 # Insert the key into the bufffer. iter = self.lessonbuffer.get_iter_at_mark(self.line_mark) iter.forward_chars(self.char_idx) self.lessonbuffer.insert_with_tags_by_name(iter, key, tag_name) # Advance to the next character (or else). self.char_idx += 1 if self.char_idx >= len(self.line): self.line_idx += 1 if self.line_idx >= len(self.lines): self.advance_step() else: self.begin_line() return self.update_stats() self.hilite_next_key() return False def hilite_next_key(self): # Hilite the next key on the virtual keyboard. self.keyboard.clear_hilite() if len(self.line) > 0: key = self.keyboard.find_key_by_letter(self.line[self.char_idx]) if key: key.set_hilite(True) # Move the cursor to the insert location. iter = self.lessonbuffer.get_iter_at_mark(self.line_mark) iter.forward_chars(self.char_idx) self.lessonbuffer.place_cursor(iter) # Gain focus (this causes the cursor line to draw). self.lessontext.grab_focus() # Scroll the TextView so the cursor is on screen. self.lessontext.scroll_to_mark(self.lessonbuffer.get_insert(), 0) def show_lesson_report(self): self.update_stats() lesson_name = self.lesson['name'] # Add to the lesson history. report = { 'lesson': lesson_name, 'time': self.total_time, 'wpm': self.wpm, 'accuracy': self.accuracy } self.activity.add_history(report) # Show the medal screen, if one should be given. got_medal = None for medal in MEDALS: if self.wpm >= medal['wpm'] and self.accuracy >= medal['accuracy']: got_medal = medal['name'] if got_medal: # Award the medal. medal = { 'lesson': lesson_name, 'type': got_medal, 'date': datetime.date.today().strftime('%B %d, %Y'), 'nick': self.activity.owner.props.nick, 'time': self.total_time, 'wpm': report['wpm'], 'accuracy': report['accuracy'] } self.medal = medal # Compare this medal with any existing medals for this lesson. # Only record the best one. add_medal = True if self.activity.data['medals'].has_key(lesson_name): old_medal = self.activity.data['medals'][lesson_name] order = ' '.join([m['name'] for m in MEDALS]) add_idx = order.index(medal['type']) old_idx = order.index(old_medal['type']) if add_idx < old_idx: add_medal = False elif add_idx == old_idx: if medal['accuracy'] < old_medal['accuracy']: add_medal = False elif medal['accuracy'] == old_medal['accuracy']: if medal['wpm'] < old_medal['wpm']: add_medal = False if add_medal: # Upgrade the player's level if needed. if self.lesson['level'] > self.activity.data['level']: self.activity.data['level'] = self.lesson['level'] self.activity.data['motd'] = 'newlevel' self.activity.data['medals'][lesson_name] = medal self.activity.mainscreen.update_medals() # Display results to the user. text = '\n' congrats = [ _('Good job!'), _('Well done!'), _('Nice work!'), _('Way to go!') ] text += random.choice(congrats) + '\n\n' text += _('You finished the lesson in %(time)d seconds, with %(errors)d errors.\n\n') % \ { 'time': int(self.total_time), 'errors': self.incorrect_keys } text += _('Your words per minute (WPM) was %(wpm)d, and your accuracy was %(accuracy)d%%.\n\n') % \ report if self.medal: # TODO: Play medal sound here. text += _('Congratulations! You earned a %(type)s medal!\n\nPress Enter to see your certificate.') % \ medal else: # Comment on what the user needs to do better. need_wpm = report['wpm'] < MEDALS[0]['wpm'] need_accuracy = report['accuracy'] < MEDALS[0]['accuracy'] if need_accuracy and need_wpm: text += _('You need to practice this lesson more before moving on. If you are having a hard time, ' 'repeat the earlier lessons until you have mastered them completely before trying this one ' 'again.\n\n') elif need_accuracy: text += _('You almost got a medal! Next time, try not to make as many errors!\n\n') elif need_wpm: text += _('You almost got a medal! Next time, try to type a little faster!\n\n') text += _('Press Enter to return to the main screen.') self.lessonbuffer.set_text('') self.lessonbuffer.insert_with_tags_by_name( self.lessonbuffer.get_end_iter(), text, 'instructions') def end_lesson(self): self.activity.pop_screen() # Show the new medal if there was one. if self.medal: self.activity.push_screen(MedalScreen(self.medal, self.activity)) def stop_cb(self, widget): self.activity.pop_screen() class MainScreen(gtk.VBox): def __init__(self, activity): gtk.VBox.__init__(self) self.activity = activity # Build background. title = gtk.Label() title.set_markup("" + _('Typing Turtle') + "") subtitle = gtk.Label() subtitle.set_markup(_('Welcome to Typing Turtle! To begin, select a lesson from the list below.')) spacer = gtk.HBox() # Lessons header. headerbox = gtk.VBox() label = gtk.Label() label.set_alignment(0.0, 0.5) label.set_markup(""+_('Available Lessons')+"") headerbox.pack_start(label, False) headerbox.pack_start(gtk.HSeparator(), False) # Build lessons list. self.lessonbox = gtk.VBox() self.lessonbox.set_spacing(10) bundle_path = sugar.activity.activity.get_bundle_path() code = locale.getlocale(locale.LC_ALL)[0] path = bundle_path + '/lessons/' + code + '/' # Find all .lesson files in ./lessons/en_US/ for example. lessons = [] for f in os.listdir(path): fd = open(path + f, 'r') try: lesson = json.read(fd.read()) lessons.append(lesson) finally: fd.close() lessons.sort(lambda x, y: x['level'] - y['level']) for l in lessons: label = gtk.Label() label.set_alignment(0.0, 0.5) label.set_markup("" + l['name'] + "\n" + l['description']) btn = gtk.Button() btn.lesson = l btn.add(label) btn.connect('clicked', self.lesson_clicked_cb) medalimage = gtk.Image() medalbtn = gtk.Button() medalbtn.lesson = l medalbtn.add(medalimage) medalbtn.connect('clicked', self.medal_clicked_cb) hbox = gtk.HBox() hbox.pack_start(btn, True, True, 10) hbox.pack_end(medalbtn, False, False) hbox.button = btn hbox.medalbutton = medalbtn hbox.lesson = l hbox.medalimage = medalimage self.lessonbox.pack_start(hbox, False) self.lessonscroll = gtk.ScrolledWindow() self.lessonscroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) self.lessonscroll.add_with_viewport(self.lessonbox) self.pack_start(title, False, True, 10) self.pack_start(subtitle, False) self.pack_start(spacer, False, False, 50) self.pack_start(headerbox, False) self.pack_start(self.lessonscroll, True) self.update_medals() def update_medals(self): for l in self.lessonbox: # Disable the lesson button unless available. lesson_available = self.activity.data['level'] >= l.lesson['requiredlevel'] l.button.set_sensitive(lesson_available) l.medalbutton.set_sensitive(lesson_available) # Update the medal image. medal_type = 'none' if self.activity.data['medals'].has_key(l.lesson['name']): medal_type = self.activity.data['medals'][l.lesson['name']]['type'] bundle = sugar.activity.activity.get_bundle_path() images = { 'none': bundle+'/images/no-medal.jpg', 'bronze': bundle+'/images/bronze-medal.jpg', 'silver': bundle+'/images/silver-medal.jpg', 'gold': bundle+'/images/gold-medal.jpg' } l.medalimage.set_from_file(images[medal_type]) def lesson_clicked_cb(self, widget): self.activity.push_screen(LessonScreen(widget.lesson, self.activity)) def medal_clicked_cb(self, widget): if self.activity.data['medals'].has_key(widget.lesson['name']): medal = self.activity.data['medals'][widget.lesson['name']] self.activity.push_screen(MedalScreen(medal, self.activity)) # This is the main Typing Turtle activity class. # # It owns the main application window, and all the various toolbars and options. # Activity Screens are stored in a stack, with the currently active screen on top. class TypingTurtle(sugar.activity.activity.Activity): def __init__ (self, handle): sugar.activity.activity.Activity.__init__(self, handle) self.set_title(_("Typing Turtle")) self.build_toolbox() self.screens = [] self.screenbox = gtk.VBox() self.owner = presenceservice.get_instance().get_owner() # All data which is saved in the Journal entry is placed in this dictionary. self.data = { 'motd': 'welcome', 'level': 0, 'history': [], 'medals': {} } # This has to happen last, because it calls the read_file method when restoring from the Journal. self.set_canvas(self.screenbox) # Start with the main screen. self.mainscreen = MainScreen(self) self.push_screen(self.mainscreen) self.show_all() # Hide the sharing button from the activity toolbar since we don't support sharing. activity_toolbar = self.tbox.get_activity_toolbar() activity_toolbar.share.props.visible = False def build_toolbox(self): self.tbox = sugar.activity.activity.ActivityToolbox(self) self.tbox.show_all() self.set_toolbox(self.tbox) def push_screen(self, screen): if len(self.screens): self.screenbox.remove(self.screens[-1]) self.screenbox.pack_start(screen, True, True) self.screens.append(screen) def pop_screen(self): self.screenbox.remove(self.screens[-1]) self.screens.pop() if len(self.screens): self.screenbox.pack_start(self.screens[-1]) def add_history(self, entry): self.data['history'].append(entry) def read_file(self, file_path): if self.metadata['mime_type'] != 'text/plain': return fd = open(file_path, 'r') try: text = fd.read() print "read %s" % text self.data = json.read(text) finally: fd.close() def write_file(self, file_path): if not self.metadata['mime_type']: self.metadata['mime_type'] = 'text/plain' fd = open(file_path, 'w') try: text = json.write(self.data) fd.write(text) print "wrote %s" % text finally: fd.close()