Merge pull request #146 from goncalossilva/ft.stats

This PR improves statistics counting, as well as adds support for more of them, fixing #63. It does so in a iA Writer inspired way, meaning:
* The stats bar displays one stat, word count by default.
* The stats bar now contains a button that [displays all stats and allows toggling between them](https://cl.ly/a0ce3fad3d72/gnome-shell-screenshot-QRNG0Z.png).
* The default stat is saved between sessions.
* All the stats are objective and deterministic. For instance, I contemplated adding GhostWriter's "Pages" estimation, but it's outright broken in my testing, and I also think we should be strict about what too include. Too many things will make it less useful. Regardless, it's trivial to extend.
* Calculations are done on a worker thread, to prevent hogging the UI and allowing future extensibility without much consideration.
github/fork/yochananmarqos/patch-1
somas95 2019-04-20 13:21:37 +02:00 committed by GitHub
commit f2d00f2f0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 245 additions and 172 deletions

View File

@ -2,6 +2,14 @@
<schemalist>
<enum id='de.wolfvollprecht.UberWriter.Stat'>
<value nick='characters' value='0' />
<value nick='words' value='1' />
<value nick='sentences' value='2' />
<value nick='paragraphs' value='3' />
<value nick='read_time' value='4' />
</enum>
<schema path="/de/wolfvollprecht/UberWriter/" id="de.wolfvollprecht.UberWriter">
<key name='dark-mode-auto' type='b'>
@ -54,6 +62,13 @@
Open file paths of the current session
</description>
</key>
<key name='stat-default' enum='de.wolfvollprecht.UberWriter.Stat'>
<default>"words"</default>
<summary>Default statistic</summary>
<description>
Which statistic is shown on the main window.
</description>
</key>
</schema>

View File

@ -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 {

View File

@ -75,97 +75,20 @@
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<child>
<object class="GtkRevealer" id="status_bar_revealer">
<object class="GtkRevealer" id="stats_counter_revealer">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="transition_type">crossfade</property>
<property name="transition_duration">750</property>
<property name="reveal_child">True</property>
<child>
<object class="GtkGrid" id="status_bar_box">
<object class="GtkButton" id="stats_counter">
<property name="label" translatable="yes">0 Words</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkLabel" id="label2">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Words:</property>
</object>
<packing>
<property name="left_attach">3</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="word_count">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">end</property>
<property name="label">0</property>
<property name="justify">right</property>
<property name="width_chars">4</property>
<property name="xalign">1</property>
</object>
<packing>
<property name="left_attach">4</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkSeparator" id="separator1">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">end</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_start">10</property>
<property name="margin_end">10</property>
<property name="orientation">vertical</property>
</object>
<packing>
<property name="left_attach">5</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label1">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">end</property>
<property name="label" translatable="yes">Characters:</property>
</object>
<packing>
<property name="left_attach">6</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="char_count">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">end</property>
<property name="margin_right">11</property>
<property name="margin_end">11</property>
<property name="label">0</property>
<property name="width_chars">6</property>
<property name="xalign">1</property>
</object>
<packing>
<property name="left_attach">7</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Show Statistics</property>
<property name="halign">end</property>
</object>
</child>
</object>

View File

@ -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)

View File

@ -0,0 +1,75 @@
import math
import re
from queue import Queue
from threading import Thread
from gi.repository import GLib
from uberwriter import helpers
class StatsCounter:
"""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))")
# Regexp that matches Asian letters, general symbols and hieroglyphs,
# as well as sequences of word characters optionally containing non-word characters in-between.
WORDS = re.compile(r"[\u3040-\uffff]|(?:\w+\S?\w*)+", re.UNICODE)
# Regexp that matches sentence-ending punctuation characters, ie. full stop, question mark,
# exclamation mark, paragraph, and variants.
SENTENCES = re.compile(r"[^\n][.。।෴۔።?՞;⸮؟?፧꘏⳺⳻⁇﹖⁈⁉‽!﹗!՜߹႟᥄\n]+")
# Regexp that matches paragraphs, ie. anything separated by newlines.
PARAGRAPHS = re.compile(r".+\n?")
def __init__(self):
super().__init__()
self.queue = Queue()
worker = Thread(target=self.__do_count, name="stats-counter")
worker.daemon = True
worker.start()
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:
(characters, words, sentences, (hours, minutes, seconds))"""
self.queue.put((text, callback))
def stop(self):
"""Stops the background worker. StatsCounter shouldn't be used after this."""
self.queue.put((None, None))
def __do_count(self):
while True:
while True:
(text, callback) = self.queue.get()
if text is None and callback is None:
return
if self.queue.empty():
break
text = helpers.pandoc_convert(text, to="plain")
character_count = len(re.findall(self.CHARACTERS, text))
word_count = len(re.findall(self.WORDS, text))
sentence_count = len(re.findall(self.SENTENCES, text))
paragraph_count = len(re.findall(self.PARAGRAPHS, text))
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, paragraph_count, read_time))

View File

@ -0,0 +1,98 @@
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.paragraphs = 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()
stats = self.settings.props.settings_schema.get_key("stat-default").get_range()[1]
for i, stat in enumerate(stats):
menu_item = Gio.MenuItem.new(self.get_text_for_stat(i), None)
menu_item.set_action_and_target_value("app.stat_default", GLib.Variant.new_string(stat))
menu.append_item(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 _("{:n} Paragraphs".format(self.paragraphs))
elif stat == 4:
return _("{:d}:{:02d}:{:02d} Read Time".format(*self.read_time))
else:
raise ValueError("Unknown stat {}".format(stat))
def update_stats(self, stats):
(characters, words, sentences, paragraphs, read_time) = stats
self.characters = characters
self.words = words
self.sentences = sentences
self.paragraphs = paragraphs
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()

View File

@ -18,7 +18,6 @@ import codecs
import locale
import logging
import os
import re
import urllib
import webbrowser
from gettext import gettext as _
@ -26,6 +25,7 @@ from gettext import gettext as _
import gi
from uberwriter.export_dialog import Export
from uberwriter.stats_handler import StatsHandler
from uberwriter.text_view import TextView
gi.require_version('Gtk', '3.0')
@ -66,8 +66,6 @@ class Window(Gtk.ApplicationWindow):
'close-window': (GObject.SIGNAL_ACTION, None, ())
}
WORDCOUNT = re.compile(r"(?!\-\w)[\s#*\+\-]+", re.UNICODE)
def __init__(self, app):
"""Set up the main window"""
@ -94,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)
@ -117,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
@ -130,6 +116,17 @@ class Window(Gtk.ApplicationWindow):
self.scrolled_window.add(self.text_view)
self.editor_viewport = self.builder.get_object('editor_viewport')
# Stats counter
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
self.overlay_id = None
@ -190,26 +187,6 @@ class Window(Gtk.ApplicationWindow):
# Redraw contents of window
self.queue_draw()
def update_line_and_char_count(self):
"""it... it updates line and characters count
"""
if self.status_bar_visible is False:
return
text = self.text_view.get_text()
self.char_count.set_text(str(len(text)))
words = re.split(self.WORDCOUNT, text)
length = len(words)
# Last word a "space"
if not words[-1]:
length = length - 1
# First word a "space" (happens in focus mode...)
if not words[0]:
length = length - 1
if length == -1:
length = 0
self.word_count.set_text(str(length))
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
@ -221,7 +198,6 @@ class Window(Gtk.ApplicationWindow):
self.set_headerbar_title("* " + title)
self.buffer_modified_for_status_bar = True
self.update_line_and_char_count()
def set_fullscreen(self, state):
"""Puts the application in fullscreen mode and show/hides
@ -491,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
"""
@ -672,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
@ -697,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.update_line_and_char_count()
# 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.update_line_and_char_count()
def draw_gradient(self, _widget, cr):
"""draw fading gradient over the top and the bottom of the
@ -764,12 +741,6 @@ class Window(Gtk.ApplicationWindow):
self.destroy()
return
def on_destroy(self, _widget, _data=None):
"""Called when the TexteditorWindow is closed.
"""
# Clean up code for saving application state should be added here.
Gtk.main_quit()
def set_headerbar_title(self, title):
"""set the desired headerbar title
"""