From c19f57f64b0dd6e7d5f7f167da62998cf4cb5ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Silva?= Date: Fri, 19 Apr 2019 06:24:43 +0100 Subject: [PATCH] Add statistics for sentences and read time, allow user to toggle default Fixes #63 --- data/de.wolfvollprecht.UberWriter.gschema.xml | 14 +++ data/media/css/_gtk_base.css | 41 ++----- data/ui/Window.ui | 91 ++-------------- uberwriter/application.py | 18 +++ uberwriter/stats_counter.py | 18 ++- uberwriter/stats_handler.py | 103 ++++++++++++++++++ uberwriter/window.py | 68 ++++-------- 7 files changed, 180 insertions(+), 173 deletions(-) create mode 100644 uberwriter/stats_handler.py diff --git a/data/de.wolfvollprecht.UberWriter.gschema.xml b/data/de.wolfvollprecht.UberWriter.gschema.xml index 7a3b93b..6d2b13a 100644 --- a/data/de.wolfvollprecht.UberWriter.gschema.xml +++ b/data/de.wolfvollprecht.UberWriter.gschema.xml @@ -1,6 +1,13 @@ + + + + + + + @@ -54,6 +61,13 @@ Open file paths of the current session + + "words" + Default statistic + + Which statistic is shown on the main window. + + diff --git a/data/media/css/_gtk_base.css b/data/media/css/_gtk_base.css index 84261ab..b4fe25b 100644 --- a/data/media/css/_gtk_base.css +++ b/data/media/css/_gtk_base.css @@ -86,15 +86,10 @@ } -.status-bar-box label { - color: #666; -} - -.status-bar-box button { - /* finding reset */ +.stats-counter { + color: alpha(@foreground_color, 0.6); background-color: @background_color; text-shadow: inherit; - /*icon-shadow: inherit;*/ box-shadow: initial; background-clip: initial; background-origin: initial; @@ -106,37 +101,15 @@ border-image-repeat: initial; border-image-slice: initial; border-image-width: initial; - border-style: none; - -button-images: true; - border-radius: 2px; - color: #666; - padding: 3px 5px; + padding: 0px 16px; transition: 100ms ease-in; } -.status-bar-box button:hover, -.status-bar-box button:checked { - transition: 0s ease-in; - color: @background_color; - background-color: #666; -} - -.status-bar-box button:hover label, -.status-bar-box button:checked label { - color: @background_color; -} - -.status-bar-box button:active { - color: #EEE; - background-color: #EEE; - background-image: none; - box-shadow: 0 0 2px rgba(0,0,0,0.4) -} - -.status-bar-box separator { - border-color: #999; - border-right: none; +.stats-counter:hover, +.stats-counter:checked { + color: @foreground_color; + background-color: lighter(@background_color); } #PreviewMenuItem image { diff --git a/data/ui/Window.ui b/data/ui/Window.ui index 66571fe..bca48f4 100644 --- a/data/ui/Window.ui +++ b/data/ui/Window.ui @@ -75,97 +75,20 @@ False True - + True False crossfade 750 True - + + 0 Words True - False - - - True - False - end - True - Words: - - - 3 - 0 - - - - - True - False - end - 0 - right - 4 - 1 - - - 4 - 0 - - - - - True - False - end - 10 - 10 - 10 - 10 - vertical - - - 5 - 0 - - - - - True - False - end - Characters: - - - 6 - 0 - - - - - True - False - end - 11 - 11 - 0 - 6 - 1 - - - 7 - 0 - - - - - - - - - - - + True + True + Show Statistics + end diff --git a/uberwriter/application.py b/uberwriter/application.py index 39038c0..0ad632f 100644 --- a/uberwriter/application.py +++ b/uberwriter/application.py @@ -40,6 +40,8 @@ class Application(Gtk.Application): self.settings.connect("changed", self.on_settings_changed) + # Header bar + action = Gio.SimpleAction.new("new", None) action.connect("activate", self.on_new) self.add_action(action) @@ -60,6 +62,8 @@ class Application(Gtk.Application): action.connect("activate", self.on_search) self.add_action(action) + # App Menu + action = Gio.SimpleAction.new_stateful( "focus_mode", None, GLib.Variant.new_boolean(False)) action.connect("change-state", self.on_focus_mode) @@ -112,6 +116,14 @@ class Application(Gtk.Application): action.connect("activate", self.on_quit) self.add_action(action) + # Stats Menu + + stat_default = self.settings.get_string("stat-default") + action = Gio.SimpleAction.new_stateful( + "stat_default", GLib.VariantType.new('s'), GLib.Variant.new_string(stat_default)) + action.connect("activate", self.on_stat_default) + self.add_action(action) + # Shortcuts # TODO: be aware that a couple of shortcuts are defined in _gtk_base.css @@ -166,6 +178,8 @@ class Application(Gtk.Application): self.window.toggle_gradient_overlay(settings.get_value(key)) elif key == "input-format": self.window.reload_preview() + elif key == "stat-default": + self.window.update_default_stat() def on_new(self, _action, _value): self.window.new_document() @@ -232,6 +246,10 @@ class Application(Gtk.Application): def on_quit(self, _action, _param): self.quit() + def on_stat_default(self, action, value): + action.set_state(value) + self.settings.set_string("stat-default", value.get_string()) + # ~ if __name__ == "__main__": # ~ app = Application() # ~ app.run(sys.argv) diff --git a/uberwriter/stats_counter.py b/uberwriter/stats_counter.py index 0f8fbd8..b753143 100644 --- a/uberwriter/stats_counter.py +++ b/uberwriter/stats_counter.py @@ -9,7 +9,7 @@ from uberwriter import helpers class StatsCounter: - """Counts characters, words, sentences and reading time using a background thread.""" + """Counts characters, words, sentences and read time using a background thread.""" # Regexp that matches any character, except for newlines and subsequent spaces. CHARACTERS = re.compile(r"[^\s]|(?:[^\S\n](?!\s))") @@ -26,11 +26,11 @@ class StatsCounter: super().__init__() self.queue = Queue() - worker = Thread(target=self.__do_count_stats, name="stats-counter") + worker = Thread(target=self.__do_count, name="stats-counter") worker.daemon = True worker.start() - def count_stats(self, text, callback): + def count(self, text, callback): """Count stats for text, calling callback with a result when done. The callback argument contains the result, in the form: @@ -44,7 +44,7 @@ class StatsCounter: self.queue.put((None, None)) - def __do_count_stats(self): + def __do_count(self): while True: while True: (text, callback) = self.queue.get() @@ -61,10 +61,8 @@ class StatsCounter: sentence_count = len(re.findall(self.SENTENCES, text)) - dec_, int_ = math.modf(word_count / 200) - hours = int(int_ / 60) - minutes = int(int_ % 60) - seconds = round(dec_ * 0.6) - reading_time = (hours, minutes, seconds) + read_m, read_s = divmod(word_count / 200 * 60, 60) + read_h, read_m = divmod(read_m, 60) + read_time = (int(read_h), int(read_m), int(read_s)) - GLib.idle_add(callback, (character_count, word_count, sentence_count, reading_time)) + GLib.idle_add(callback, (character_count, word_count, sentence_count, read_time)) diff --git a/uberwriter/stats_handler.py b/uberwriter/stats_handler.py new file mode 100644 index 0000000..ec9f40a --- /dev/null +++ b/uberwriter/stats_handler.py @@ -0,0 +1,103 @@ +import math +import re +from gettext import gettext as _ +from queue import Queue +from threading import Thread + +from gi.repository import GLib, Gio, Gtk + +from uberwriter import helpers +from uberwriter.helpers import get_builder +from uberwriter.settings import Settings +from uberwriter.stats_counter import StatsCounter + + +class StatsHandler: + """Shows a default statistic on the stats button, and allows the user to toggle which one.""" + + def __init__(self, stats_button, text_view): + super().__init__() + + self.stats_button = stats_button + self.stats_button.connect("clicked", self.on_stats_button_clicked) + self.stats_button.connect("destroy", self.on_destroy) + + self.text_view = text_view + self.text_view.get_buffer().connect("changed", self.on_text_changed) + + self.popover = None + + self.characters = 0 + self.words = 0 + self.sentences = 0 + self.read_time = (0, 0, 0) + + self.settings = Settings.new() + self.default_stat = self.settings.get_enum("stat-default") + + self.stats_counter = StatsCounter() + + self.update_default_stat() + + def on_stats_button_clicked(self, _button): + self.stats_button.set_state_flags(Gtk.StateFlags.CHECKED, False) + + menu = Gio.Menu() + characters_menu_item = Gio.MenuItem.new(self.get_text_for_stat(0), None) + characters_menu_item.set_action_and_target_value( + "app.stat_default", GLib.Variant.new_string("characters")) + menu.append_item(characters_menu_item) + words_menu_item = Gio.MenuItem.new(self.get_text_for_stat(1), None) + words_menu_item.set_action_and_target_value( + "app.stat_default", GLib.Variant.new_string("words")) + menu.append_item(words_menu_item) + sentences_menu_item = Gio.MenuItem.new(self.get_text_for_stat(2), None) + sentences_menu_item.set_action_and_target_value( + "app.stat_default", GLib.Variant.new_string("sentences")) + menu.append_item(sentences_menu_item) + read_time_menu_item = Gio.MenuItem.new(self.get_text_for_stat(3), None) + read_time_menu_item.set_action_and_target_value( + "app.stat_default", GLib.Variant.new_string("read_time")) + menu.append_item(read_time_menu_item) + self.popover = Gtk.Popover.new_from_model(self.stats_button, menu) + self.popover.connect('closed', self.on_popover_closed) + self.popover.popup() + + def on_popover_closed(self, _popover): + self.stats_button.unset_state_flags(Gtk.StateFlags.CHECKED) + + self.popover = None + self.text_view.grab_focus() + + def on_text_changed(self, buf): + self.stats_counter.count( + buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False), + self.update_stats) + + def get_text_for_stat(self, stat): + if stat == 0: + return _("{:n} Characters".format(self.characters)) + elif stat == 1: + return _("{:n} Words".format(self.words)) + elif stat == 2: + return _("{:n} Sentences".format(self.sentences)) + elif stat == 3: + return _("{:d}:{:02d}:{:02d} Read Time".format(*self.read_time)) + + def update_stats(self, stats): + (characters, words, sentences, read_time) = stats + self.characters = characters + self.words = words + self.sentences = sentences + self.read_time = read_time + self.update_default_stat(False) + + def update_default_stat(self, close_popover=True): + stat = self.settings.get_enum("stat-default") + text = self.get_text_for_stat(stat) + self.stats_button.set_label(text) + if close_popover and self.popover: + self.popover.popdown() + + def on_destroy(self, _widget): + self.stats_counter.stop() diff --git a/uberwriter/window.py b/uberwriter/window.py index 95c4fe1..ce7bf25 100644 --- a/uberwriter/window.py +++ b/uberwriter/window.py @@ -18,7 +18,6 @@ import codecs import locale import logging import os -import re import urllib import webbrowser from gettext import gettext as _ @@ -26,7 +25,7 @@ from gettext import gettext as _ import gi from uberwriter.export_dialog import Export -from uberwriter.stats_counter import StatsCounter +from uberwriter.stats_handler import StatsHandler from uberwriter.text_view import TextView gi.require_version('Gtk', '3.0') @@ -82,8 +81,6 @@ class Window(Gtk.ApplicationWindow): self.set_default_size(900, 500) - self.connect('delete-event', self.on_destroy) - # Preferences self.settings = Settings.new() @@ -95,17 +92,6 @@ class Window(Gtk.ApplicationWindow): self.title_end = " – UberWriter" self.set_headerbar_title("New File" + self.title_end) - self.word_count = self.builder.get_object('word_count') - self.char_count = self.builder.get_object('char_count') - - # Setup status bar hide after 3 seconds - self.status_bar = self.builder.get_object('status_bar_box') - self.statusbar_revealer = self.builder.get_object('status_bar_revealer') - self.status_bar.get_style_context().add_class('status-bar-box') - self.status_bar_visible = True - self.was_motion = True - self.buffer_modified_for_status_bar = False - self.timestamp_last_mouse_motion = 0 if self.settings.get_value("poll-motion"): self.connect("motion-notify-event", self.on_motion_notify) @@ -118,11 +104,10 @@ class Window(Gtk.ApplicationWindow): self.text_view = TextView() self.text_view.props.halign = Gtk.Align.CENTER self.text_view.connect('focus-out-event', self.focus_out) + self.text_view.get_buffer().connect('changed', self.on_text_changed) self.text_view.show() self.text_view.grab_focus() - self.text_view.get_buffer().connect('changed', self.on_text_changed) - # Setup preview webview self.preview_webview = None @@ -132,7 +117,15 @@ class Window(Gtk.ApplicationWindow): self.editor_viewport = self.builder.get_object('editor_viewport') # Stats counter - self.stats_counter = StatsCounter() + self.stats_counter_revealer = self.builder.get_object('stats_counter_revealer') + self.stats_button = self.builder.get_object('stats_counter') + self.stats_button.get_style_context().add_class('stats-counter') + self.stats_handler = StatsHandler(self.stats_button, self.text_view) + + # Setup header/stats bar hide after 3 seconds + self.top_bottom_bars_visible = True + self.was_motion = True + self.buffer_modified_for_status_bar = False # some people seems to have performance problems with the overlay. # Let them disable it @@ -194,14 +187,6 @@ class Window(Gtk.ApplicationWindow): # Redraw contents of window self.queue_draw() - def update_stats_counts(self, stats): - """Updates line and character counts. - """ - - (characters, words, sentences, (hours, minutes, seconds)) = stats - self.char_count.set_text(str(characters)) - self.word_count.set_text(str(words)) - def on_text_changed(self, *_args): """called when the text changes, sets the self.did_change to true and updates the title and the counters to reflect that @@ -214,9 +199,6 @@ class Window(Gtk.ApplicationWindow): self.buffer_modified_for_status_bar = True - if self.status_bar_visible: - self.stats_counter.count_stats(self.text_view.get_text(), self.update_stats_counts) - def set_fullscreen(self, state): """Puts the application in fullscreen mode and show/hides the poller for motion in the top border @@ -485,6 +467,9 @@ class Window(Gtk.ApplicationWindow): self.set_filename() self.set_headerbar_title(_("New File") + self.title_end) + def update_default_stat(self): + self.stats_handler.update_default_stat() + def menu_toggle_sidebar(self, _widget=None): """WIP """ @@ -666,13 +651,13 @@ class Window(Gtk.ApplicationWindow): """ if (self.was_motion is False - and self.status_bar_visible + and self.top_bottom_bars_visible and self.buffer_modified_for_status_bar and self.text_view.props.has_focus): # pylint: disable=no-member # self.status_bar.set_state_flags(Gtk.StateFlags.INSENSITIVE, True) - self.statusbar_revealer.set_reveal_child(False) + self.stats_counter_revealer.set_reveal_child(False) self.headerbar.hb_revealer.set_reveal_child(False) - self.status_bar_visible = False + self.top_bottom_bars_visible = False self.buffer_modified_for_status_bar = False self.was_motion = False @@ -691,26 +676,24 @@ class Window(Gtk.ApplicationWindow): return if now - self.timestamp_last_mouse_motion > 100: # react on motion by fading in headerbar and statusbar - if self.status_bar_visible is False: - self.statusbar_revealer.set_reveal_child(True) + if self.top_bottom_bars_visible is False: + self.stats_counter_revealer.set_reveal_child(True) self.headerbar.hb_revealer.set_reveal_child(True) self.headerbar.hb.props.opacity = 1 - self.status_bar_visible = True + self.top_bottom_bars_visible = True self.buffer_modified_for_status_bar = False - self.stats_counter.count_stats(self.text_view.get_text(), self.update_stats_counts) # self.status_bar.set_state_flags(Gtk.StateFlags.NORMAL, True) self.was_motion = True def focus_out(self, _widget, _data=None): """events called when the window losses focus """ - if self.status_bar_visible is False: - self.statusbar_revealer.set_reveal_child(True) + if self.top_bottom_bars_visible is False: + self.stats_counter_revealer.set_reveal_child(True) self.headerbar.hb_revealer.set_reveal_child(True) self.headerbar.hb.props.opacity = 1 - self.status_bar_visible = True + self.top_bottom_bars_visible = True self.buffer_modified_for_status_bar = False - self.stats_counter.count_stats(self.text_view.get_text(), self.update_stats_counts) def draw_gradient(self, _widget, cr): """draw fading gradient over the top and the bottom of the @@ -789,8 +772,3 @@ class Window(Gtk.ApplicationWindow): webbrowser.open(web_view.get_uri()) decision.ignore() return True # Don't let the event "bubble up" - - def on_destroy(self, _widget, _data=None): - """Called when the Window is closing. - """ - self.stats_counter.stop()