Merge branch 'master'

ft.librem5
Manuel Genovés 2019-05-12 21:24:30 +02:00
commit 7e34e9cc62
34 changed files with 1175 additions and 464 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ flatpak/*
data/ui/shortcut_handlers data/ui/shortcut_handlers
*.ui~ *.ui~
.vscode/ .vscode/
.idea/
*.glade~ *.glade~
dist/uberwriter-2.0b0-py3.7.egg dist/uberwriter-2.0b0-py3.7.egg
builddir/* builddir/*

View File

@ -10,6 +10,13 @@
<value nick='read_time' value='4' /> <value nick='read_time' value='4' />
</enum> </enum>
<enum id='de.wolfvollprecht.UberWriter.PreviewMode'>
<value nick='full-width' value='0' />
<value nick='half-width' value='1' />
<value nick='half-height' value='2' />
<value nick='windowed' value='3' />
</enum>
<schema path="/de/wolfvollprecht/UberWriter/" id="de.wolfvollprecht.UberWriter"> <schema path="/de/wolfvollprecht/UberWriter/" id="de.wolfvollprecht.UberWriter">
<key name='dark-mode-auto' type='b'> <key name='dark-mode-auto' type='b'>
@ -41,6 +48,13 @@
It can cause performance problems to some users. It can cause performance problems to some users.
</description> </description>
</key> </key>
<key name='sync-scroll' type='b'>
<default>true</default>
<summary>Synchronize editor/preview scrolling</summary>
<description>
Keep the editor and preview scroll positions in sync.
</description>
</key>
<key name='input-format' type='s'> <key name='input-format' type='s'>
<default>"markdown"</default> <default>"markdown"</default>
<summary>Input format</summary> <summary>Input format</summary>
@ -69,6 +83,20 @@
Which statistic is shown on the main window. Which statistic is shown on the main window.
</description> </description>
</key> </key>
<key name='characters-per-line' type='i'>
<default>66</default>
<summary>Characters per line</summary>
<description>
Maximum number of characters per line within the editor.
</description>
</key>
<key name='preview-mode' enum='de.wolfvollprecht.UberWriter.PreviewMode'>
<default>"full-width"</default>
<summary>Preview mode</summary>
<description>
How to display the preview.
</description>
</key>
</schema> </schema>

View File

@ -22,25 +22,11 @@
/* Main window and text colors */ /* Main window and text colors */
.uberwriter-window { .uberwriter-window {
/*border-radius: 7px 7px 3px 3px;*/
background: @theme_base_color; background: @theme_base_color;
color: @theme_fg_color; color: @theme_fg_color;
caret-color: @theme_fg_color; caret-color: @theme_fg_color;
} }
.uberwriter-window .uberwriter-editor {
font-family: 'Fira Mono', monospace;
font-size: 16px;
}
.uberwriter-window.small .uberwriter-editor {
font-size: 14px;
}
.uberwriter-window.large .uberwriter-editor {
font-size: 18px;
}
#titlebar-revealer { #titlebar-revealer {
padding: 0; padding: 0;
} }
@ -55,10 +41,32 @@
} }
.uberwriter-editor { .uberwriter-editor {
-gtk-key-bindings: editor-bindings;
border: none; border: none;
background-color: transparent; background-color: transparent;
text-decoration-color: @error_color; text-decoration-color: @error_color;
-gtk-key-bindings: editor-bindings; font-family: 'Fira Mono', monospace;
font-size: 16px;
}
.uberwriter-editor.size14 {
font-size: 14px;
}
.uberwriter-editor.size15 {
font-size: 15px;
}
.uberwriter-editor.size16 {
font-size: 16px;
}
.uberwriter-editor.size17 {
font-size: 17px;
}
.uberwriter-editor.size18 {
font-size: 18px;
} }
.uberwriter-editor text { .uberwriter-editor text {
@ -84,9 +92,8 @@
padding: 0; padding: 0;
} }
.stats-counter { .inline-button {
color: alpha(@theme_fg_color, 0.6); color: alpha(@theme_fg_color, 0.6);
background-color: @theme_base_color;
text-shadow: inherit; text-shadow: inherit;
box-shadow: initial; box-shadow: initial;
background-clip: initial; background-clip: initial;
@ -104,8 +111,8 @@
transition: 100ms ease-in; transition: 100ms ease-in;
} }
.stats-counter:hover, .inline-button:hover,
.stats-counter:checked { .inline-button:checked {
color: @theme_fg_color; color: @theme_fg_color;
background-color: mix(@theme_base_color, @theme_bg_color, 0.5); background-color: mix(@theme_base_color, @theme_bg_color, 0.5);
} }

View File

@ -1,4 +1,4 @@
@import url("web/web__base.css"); @import url("base.css");
:root { :root {
--text-color: #eeeeec; --text-color: #eeeeec;

View File

@ -1,4 +1,4 @@
@import url("web/web__base.css"); @import url("base.css");
:root { :root {
--text-color: #3b3e45; --text-color: #3b3e45;

View File

@ -1,4 +1,4 @@
@import url("web/web__base.css"); @import url("base.css");
:root { :root {
--text-color: #d3dae3; --text-color: #d3dae3;

View File

@ -1 +1 @@
@import url("web/web_arc.css"); @import url("arc.css");

View File

@ -45,7 +45,7 @@ html {
} }
} }
@media screen and (min-width: 1000px) { @media screen and (min-width: 1280px) {
html { html {
font-size: 18px; font-size: 18px;
} }
@ -57,9 +57,9 @@ body {
font-family: "Fira Sans", fira-sans, sans-serif, color-emoji; font-family: "Fira Sans", fira-sans, sans-serif, color-emoji;
line-height: 1.5; line-height: 1.5;
word-wrap: break-word; word-wrap: break-word;
max-width: 978px; max-width: 980px;
margin: auto; margin: auto;
padding: 2em; padding: 4em;
} }
a { a {

View File

@ -1,4 +1,4 @@
@import url("web/web__base.css"); @import url("base.css");
a { a {
text-decoration: underline; text-decoration: underline;

View File

@ -1,4 +1,4 @@
@import url("web/web__base.css"); @import url("base.css");
a { a {
text-decoration: underline; text-decoration: underline;

View File

@ -138,11 +138,11 @@
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkLabel" id="format_label"> <object class="GtkLabel" id="sync_scroll_label">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="halign">start</property> <property name="halign">start</property>
<property name="label" translatable="yes">Input format</property> <property name="label" translatable="yes">Synchronize editor/preview scrolling</property>
<property name="justify">right</property> <property name="justify">right</property>
</object> </object>
<packing> <packing>
@ -151,17 +151,29 @@
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkButton" id="input_format_help_button"> <object class="GtkSwitch" id="sync_scroll_switch">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">False</property> <property name="halign">end</property>
<property name="image">help</property>
</object> </object>
<packing> <packing>
<property name="left_attach">1</property> <property name="left_attach">2</property>
<property name="top_attach">4</property> <property name="top_attach">4</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkLabel" id="input_format_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Input format</property>
<property name="justify">right</property>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">5</property>
</packing>
</child>
<child> <child>
<object class="GtkComboBox" id="input_format_combobox"> <object class="GtkComboBox" id="input_format_combobox">
<property name="visible">True</property> <property name="visible">True</property>
@ -172,9 +184,24 @@
</object> </object>
<packing> <packing>
<property name="left_attach">2</property> <property name="left_attach">2</property>
<property name="top_attach">4</property> <property name="top_attach">5</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkButton" id="input_format_help_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="image">help</property>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">5</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child> <child>
<placeholder/> <placeholder/>
</child> </child>

51
data/ui/Preview.ui 100644
View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkImage" id="pan-down">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">pan-down-symbolic</property>
<property name="icon_size">2</property>
</object>
<object class="GtkBox" id="preview">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
<child>
<object class="GtkRevealer" id="preview_mode_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="GtkButton" id="preview_mode_button">
<property name="label" translatable="yes">Full-Width</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Switch Preview Mode</property>
<property name="halign">end</property>
<property name="image">pan-down</property>
<property name="image_position">right</property>
<property name="always_show_image">True</property>
<style>
<class name="inline-button"/>
</style>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
</interface>

View File

@ -3,37 +3,32 @@
<interface> <interface>
<requires lib="gtk+" version="3.20"/> <requires lib="gtk+" version="3.20"/>
<!-- interface-local-resource-path ../media --> <!-- interface-local-resource-path ../media -->
<object class="GtkAdjustment" id="adjustment1"> <object class="GtkImage" id="edit-find-replace">
<property name="upper">100</property> <property name="visible">True</property>
<property name="step_increment">1</property> <property name="can_focus">False</property>
<property name="page_increment">10</property> <property name="icon_name">edit-find-replace-symbolic</property>
</object> </object>
<object class="GtkImage" id="amunt"> <object class="GtkImage" id="go-up">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="icon_name">go-up-symbolic</property> <property name="icon_name">go-up-symbolic</property>
</object> </object>
<object class="GtkImage" id="avall"> <object class="GtkImage" id="go_down">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="icon_name">go-down-symbolic</property> <property name="icon_name">go-down-symbolic</property>
</object> </object>
<object class="GtkImage" id="ortografia1"> <object class="GtkImage" id="pan-down">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">pan-down-symbolic</property>
<property name="icon_size">2</property>
</object>
<object class="GtkImage" id="spell-check">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="stock">gtk-spell-check</property> <property name="stock">gtk-spell-check</property>
</object> </object>
<object class="GtkRecentFilter" id="recentfilter1">
<mime-types>
<mime-type>text/plain</mime-type>
<mime-type>text/x-markdown</mime-type>
</mime-types>
</object>
<object class="GtkImage" id="reemplaza">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">edit-find-replace-symbolic</property>
</object>
<object class="GtkOverlay" id="FullscreenOverlay"> <object class="GtkOverlay" id="FullscreenOverlay">
<property name="name">FullscreenOverlay</property> <property name="name">FullscreenOverlay</property>
<property name="visible">True</property> <property name="visible">True</property>
@ -43,10 +38,10 @@
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<child> <child>
<object class="GtkPaned" id="main_pained"> <object class="GtkPaned" id="main_paned">
<property name="visible">True</property> <property name="visible">True</property>
<property name="app_paintable">True</property> <property name="app_paintable">True</property>
<property name="can_focus">True</property> <property name="can_focus">False</property>
<child> <child>
<object class="GtkBox" id="sidebar_box"> <object class="GtkBox" id="sidebar_box">
<property name="width_request">200</property> <property name="width_request">200</property>
@ -70,65 +65,75 @@
</packing> </packing>
</child> </child>
<child> <child>
<object class="GtkGrid" id="grid2"> <object class="GtkBox" id="content">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="hexpand">True</property> <property name="homogeneous">True</property>
<child> <child>
<object class="GtkRevealer" id="stats_counter_revealer"> <object class="GtkBox" id="editor">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="transition_type">crossfade</property> <property name="orientation">vertical</property>
<property name="transition_duration">750</property>
<property name="reveal_child">True</property>
<child>
<object class="GtkButton" id="stats_counter">
<property name="label" translatable="yes">0 Words</property>
<property name="visible">True</property>
<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>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">1</property>
</packing>
</child>
<child>
<object class="GtkViewport" id="editor_viewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="hscroll_policy">natural</property>
<property name="vscroll_policy">natural</property>
<property name="shadow_type">none</property>
<child> <child>
<object class="GtkScrolledWindow" id="editor_scrolledwindow"> <object class="GtkScrolledWindow" id="editor_scrolledwindow">
<property name="height_request">500</property>
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="vexpand">True</property> <property name="vexpand">True</property>
<property name="vadjustment">adjustment1</property>
<child> <child>
<placeholder/> <placeholder/>
</child> </child>
</object> </object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRevealer" id="editor_stats_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="GtkButton" id="editor_stats_button">
<property name="label" translatable="yes">0 Words</property>
<property name="visible">True</property>
<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>
<property name="image">pan-down</property>
<property name="image_position">right</property>
<property name="always_show_image">True</property>
<style>
<class name="inline-button"/>
</style>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child> </child>
</object> </object>
<packing> <packing>
<property name="left_attach">0</property> <property name="expand">False</property>
<property name="top_attach">0</property> <property name="fill">True</property>
<property name="position">0</property>
</packing> </packing>
</child> </child>
<child>
<placeholder/>
</child>
</object> </object>
<packing> <packing>
<property name="resize">False</property> <property name="resize">True</property>
<property name="shrink">False</property> <property name="shrink">False</property>
</packing> </packing>
</child> </child>
@ -191,7 +196,7 @@
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Previous Match</property> <property name="tooltip_text" translatable="yes">Previous Match</property>
<property name="image">amunt</property> <property name="image">go-up</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@ -205,7 +210,7 @@
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Next Match</property> <property name="tooltip_text" translatable="yes">Next Match</property>
<property name="image">avall</property> <property name="image">go_down</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@ -264,7 +269,7 @@
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Open Replace</property> <property name="tooltip_text" translatable="yes">Open Replace</property>
<property name="image">reemplaza</property> <property name="image">edit-find-replace</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
@ -345,7 +350,7 @@
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">True</property> <property name="can_focus">True</property>
<property name="receives_default">True</property> <property name="receives_default">True</property>
<property name="image">ortografia1</property> <property name="image">spell-check</property>
</object> </object>
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>

View File

@ -1,5 +1,2 @@
regex regex
enchant pypandoc==1.4
pypandoc==1.4
pyenchant
pygtkspellcheck

View File

@ -19,7 +19,7 @@ import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from uberwriter import window from uberwriter import main_window
from uberwriter import application from uberwriter import application
from uberwriter.helpers import set_up_logging from uberwriter.helpers import set_up_logging
from uberwriter.config import get_version from uberwriter.config import get_version

View File

@ -15,10 +15,12 @@ from gettext import gettext as _
import gi import gi
from uberwriter.main_window import MainWindow
gi.require_version('Gtk', '3.0') # pylint: disable=wrong-import-position gi.require_version('Gtk', '3.0') # pylint: disable=wrong-import-position
from gi.repository import GLib, Gio, Gtk, GdkPixbuf from gi.repository import GLib, Gio, Gtk, GdkPixbuf
from uberwriter import window from uberwriter import main_window
from uberwriter.settings import Settings from uberwriter.settings import Settings
from uberwriter.helpers import set_up_logging from uberwriter.helpers import set_up_logging
from uberwriter.preferences_dialog import PreferencesDialog from uberwriter.preferences_dialog import PreferencesDialog
@ -120,10 +122,18 @@ class Application(Gtk.Application):
stat_default = self.settings.get_string("stat-default") stat_default = self.settings.get_string("stat-default")
action = Gio.SimpleAction.new_stateful( action = Gio.SimpleAction.new_stateful(
"stat_default", GLib.VariantType.new('s'), GLib.Variant.new_string(stat_default)) "stat_default", GLib.VariantType.new("s"), GLib.Variant.new_string(stat_default))
action.connect("activate", self.on_stat_default) action.connect("activate", self.on_stat_default)
self.add_action(action) self.add_action(action)
# Preview Menu
preview_mode = self.settings.get_string("preview-mode")
action = Gio.SimpleAction.new_stateful(
"preview_mode", GLib.VariantType.new("s"), GLib.Variant.new_string(preview_mode))
action.connect("activate", self.on_preview_mode)
self.add_action(action)
# Shortcuts # Shortcuts
# TODO: be aware that a couple of shortcuts are defined in base.css # TODO: be aware that a couple of shortcuts are defined in base.css
@ -147,7 +157,7 @@ class Application(Gtk.Application):
# Windows are associated with the application # Windows are associated with the application
# when the last one is closed the application shuts down # when the last one is closed the application shuts down
# self.window = Window(application=self, title="UberWriter") # self.window = Window(application=self, title="UberWriter")
self.window = window.Window(self) self.window = MainWindow(self)
if self.args: if self.args:
self.window.load_file(self.args[0]) self.window.load_file(self.args[0])
@ -178,8 +188,12 @@ class Application(Gtk.Application):
self.window.toggle_gradient_overlay(settings.get_value(key)) self.window.toggle_gradient_overlay(settings.get_value(key))
elif key == "input-format": elif key == "input-format":
self.window.reload_preview() self.window.reload_preview()
elif key == "sync-scroll":
self.window.reload_preview(reshow=True)
elif key == "stat-default": elif key == "stat-default":
self.window.update_default_stat() self.window.update_default_stat()
elif key == "preview-mode":
self.window.update_preview_mode()
def on_new(self, _action, _value): def on_new(self, _action, _value):
self.window.new_document() self.window.new_document()
@ -250,6 +264,10 @@ class Application(Gtk.Application):
action.set_state(value) action.set_state(value)
self.settings.set_string("stat-default", value.get_string()) self.settings.set_string("stat-default", value.get_string())
def on_preview_mode(self, action, value):
action.set_state(value)
self.settings.set_string("preview-mode", value.get_string())
# ~ if __name__ == "__main__": # ~ if __name__ == "__main__":
# ~ app = Application() # ~ app = Application()
# ~ app.run(sys.argv) # ~ app.run(sys.argv)

View File

@ -48,8 +48,32 @@ class MainHeaderbar: #pylint: disable=too-few-public-methods
self.hb_container.add(self.hb_revealer) self.hb_container.add(self.hb_revealer)
self.hb_container.show() self.hb_container.show()
self.btns = buttons(app) self.btns = main_buttons(app)
pack_buttons(self.hb, self.btns) pack_main_buttons(self.hb, self.btns)
self.hb.show_all()
class PreviewHeaderbar:
"""Sets up the preview headerbar
"""
def __init__(self):
self.hb = Gtk.HeaderBar().new()
self.hb.props.show_close_button = True
self.hb.get_style_context().add_class("titlebar")
self.hb_revealer = Gtk.Revealer(name="titlebar-revealer")
self.hb_revealer.add(self.hb)
self.hb_revealer.props.transition_duration = 750
self.hb_revealer.props.transition_type = Gtk.RevealerTransitionType.CROSSFADE
self.hb_revealer.show()
self.hb_revealer.set_reveal_child(True)
self.hb_container = Gtk.Frame(name="titlebar-container")
self.hb_container.set_shadow_type(Gtk.ShadowType.NONE)
self.hb_container.add(self.hb_revealer)
self.hb_container.show()
self.hb.show_all() self.hb.show_all()
@ -70,14 +94,14 @@ class FullscreenHeaderbar:
self.hb.show() self.hb.show()
self.events.hide() self.events.hide()
self.btns = buttons(app) self.btns = main_buttons(app)
fs_btn_exit = Gtk.Button().new_from_icon_name("view-restore-symbolic", fs_btn_exit = Gtk.Button().new_from_icon_name("view-restore-symbolic",
Gtk.IconSize.BUTTON) Gtk.IconSize.BUTTON)
fs_btn_exit.set_tooltip_text(_("Exit Fullscreen")) fs_btn_exit.set_tooltip_text(_("Exit Fullscreen"))
fs_btn_exit.set_action_name("app.fullscreen") fs_btn_exit.set_action_name("app.fullscreen")
pack_buttons(self.hb, self.btns, fs_btn_exit) pack_main_buttons(self.hb, self.btns, fs_btn_exit)
self.hb.show_all() self.hb.show_all()
@ -101,7 +125,8 @@ class FullscreenHeaderbar:
else: else:
self.revealer.set_reveal_child(False) self.revealer.set_reveal_child(False)
def buttons(app):
def main_buttons(app):
"""constructor for the headerbar buttons """constructor for the headerbar buttons
Returns: Returns:
@ -154,7 +179,7 @@ def buttons(app):
return btn return btn
def pack_buttons(headerbar, btn, btn_exit=None): def pack_main_buttons(headerbar, btn, btn_exit=None):
"""Pack the given buttons in the given headerbar """Pack the given buttons in the given headerbar
Arguments: Arguments:

View File

@ -55,7 +55,7 @@ def get_builder(builder_file_name):
def path_to_file(path): def path_to_file(path):
"""Return a file path (file:///) for the given path""" """Return a file path (file:///) for the given path"""
return "file:///" + path return "file://" + path
def get_media_file(media_file_path): def get_media_file(media_file_path):

View File

@ -28,9 +28,11 @@ from urllib.parse import unquote
import gi import gi
from uberwriter.text_view_markup_handler import MarkupHandler
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GdkPixbuf, GObject from gi.repository import Gtk, Gdk, GdkPixbuf
from uberwriter import latex_to_PNG, text_view_markup_handler from uberwriter import latex_to_PNG
from uberwriter.settings import Settings from uberwriter.settings import Settings
from uberwriter.fix_table import FixTable from uberwriter.fix_table import FixTable
@ -360,8 +362,8 @@ class InlinePreview:
text = self.text_buffer.get_text(start_iter, end_iter, False) text = self.text_buffer.get_text(start_iter, end_iter, False)
math = text_view_markup_handler.regex["MATH"] math = MarkupHandler.regex["MATH"]
link = text_view_markup_handler.regex["LINK"] link = MarkupHandler.regex["LINK"]
footnote = re.compile(r'\[\^([^\s]+?)\]') footnote = re.compile(r'\[\^([^\s]+?)\]')
image = re.compile(r"!\[(.*?)\]\((.+?)\)") image = re.compile(r"!\[(.*?)\]\((.+?)\)")
@ -455,8 +457,7 @@ class InlinePreview:
path = path[7:] path = path[7:]
elif not path.startswith("/"): elif not path.startswith("/"):
# then the path is relative # then the path is relative
base_path = self.settings.get_value( base_path = self.settings.get_string("open-file-path")
"open-file-path").get_string()
path = base_path + "/" + path path = base_path + "/" + path
LOGGER.info(path) LOGGER.info(path)

View File

@ -19,19 +19,18 @@ import locale
import logging import logging
import os import os
import urllib import urllib
import webbrowser
from gettext import gettext as _ from gettext import gettext as _
import gi import gi
from uberwriter.export_dialog import Export from uberwriter.export_dialog import Export
from uberwriter.preview_handler import PreviewHandler
from uberwriter.stats_handler import StatsHandler from uberwriter.stats_handler import StatsHandler
from uberwriter.styled_window import StyledWindow
from uberwriter.text_view import TextView from uberwriter.text_view import TextView
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
gi.require_version('WebKit2', '4.0') # pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk, GObject, GLib, Gio from gi.repository import Gtk, Gdk, GObject, GLib, Gio
from gi.repository import WebKit2 as WebKit
import cairo import cairo
@ -54,7 +53,7 @@ LOGGER = logging.getLogger('uberwriter')
CONFIG_PATH = os.path.expanduser("~/.config/uberwriter/") CONFIG_PATH = os.path.expanduser("~/.config/uberwriter/")
class Window(Gtk.ApplicationWindow): class MainWindow(StyledWindow):
__gsignals__ = { __gsignals__ = {
'save-file': (GObject.SIGNAL_ACTION, None, ()), 'save-file': (GObject.SIGNAL_ACTION, None, ()),
'open-file': (GObject.SIGNAL_ACTION, None, ()), 'open-file': (GObject.SIGNAL_ACTION, None, ()),
@ -68,17 +67,17 @@ class Window(Gtk.ApplicationWindow):
def __init__(self, app): def __init__(self, app):
"""Set up the main window""" """Set up the main window"""
Gtk.ApplicationWindow.__init__(self, super().__init__(application=Gio.Application.get_default(), title="Uberwriter")
application=Gio.Application.get_default(),
title="Uberwriter") self.get_style_context().add_class('uberwriter-window')
# Set UI # Set UI
self.builder = get_builder('Window') builder = get_builder('Window')
root = self.builder.get_object("FullscreenOverlay") root = builder.get_object("FullscreenOverlay")
root.connect('style-updated', self.apply_current_theme) self.connect("delete-event", self.on_delete_called)
self.add(root) self.add(root)
self.set_default_size(900, 500) self.set_default_size(1000, 600)
# Preferences # Preferences
self.settings = Settings.new() self.settings = Settings.new()
@ -86,7 +85,7 @@ class Window(Gtk.ApplicationWindow):
# Headerbars # Headerbars
self.headerbar = headerbars.MainHeaderbar(app) self.headerbar = headerbars.MainHeaderbar(app)
self.set_titlebar(self.headerbar.hb_container) self.set_titlebar(self.headerbar.hb_container)
self.fs_headerbar = headerbars.FullscreenHeaderbar(self.builder, app) self.fs_headerbar = headerbars.FullscreenHeaderbar(builder, app)
self.title_end = " UberWriter" self.title_end = " UberWriter"
self.set_headerbar_title("New File" + self.title_end) self.set_headerbar_title("New File" + self.title_end)
@ -99,28 +98,26 @@ class Window(Gtk.ApplicationWindow):
self.accel_group = Gtk.AccelGroup() self.accel_group = Gtk.AccelGroup()
self.add_accel_group(self.accel_group) self.add_accel_group(self.accel_group)
self.scrolled_window = builder.get_object('editor_scrolledwindow')
# Setup text editor # Setup text editor
self.text_view = TextView() self.text_view = TextView(self.settings.get_int("characters-per-line"))
self.text_view.props.halign = Gtk.Align.CENTER
self.text_view.connect('focus-out-event', self.focus_out) self.text_view.connect('focus-out-event', self.focus_out)
self.text_view.get_buffer().connect('changed', self.on_text_changed) self.text_view.get_buffer().connect('changed', self.on_text_changed)
self.text_view.show() self.text_view.show()
self.text_view.grab_focus() self.text_view.grab_focus()
# Setup preview webview
self.preview_webview = None
self.scrolled_window = self.builder.get_object('editor_scrolledwindow')
self.scrolled_window.get_style_context().add_class('uberwriter-scrolled-window')
self.scrolled_window.add(self.text_view) self.scrolled_window.add(self.text_view)
self.editor_viewport = self.builder.get_object('editor_viewport')
# Stats counter # Setup stats counter
self.stats_counter_revealer = self.builder.get_object('stats_counter_revealer') self.stats_revealer = builder.get_object('editor_stats_revealer')
self.stats_button = self.builder.get_object('stats_counter') self.stats_button = builder.get_object('editor_stats_button')
self.stats_button.get_style_context().add_class('stats-counter')
self.stats_handler = StatsHandler(self.stats_button, self.text_view) self.stats_handler = StatsHandler(self.stats_button, self.text_view)
# Setup preview
content = builder.get_object('content')
editor = builder.get_object('editor')
self.preview_handler = PreviewHandler(self, content, editor, self.text_view)
# Setup header/stats bar hide after 3 seconds # Setup header/stats bar hide after 3 seconds
self.top_bottom_bars_visible = True self.top_bottom_bars_visible = True
self.was_motion = True self.was_motion = True
@ -142,8 +139,8 @@ class Window(Gtk.ApplicationWindow):
### ###
# Sidebar initialization test # Sidebar initialization test
### ###
self.paned_window = self.builder.get_object("main_pained") self.paned_window = builder.get_object("main_paned")
self.sidebar_box = self.builder.get_object("sidebar_box") self.sidebar_box = builder.get_object("sidebar_box")
self.sidebar = Sidebar(self) self.sidebar = Sidebar(self)
self.sidebar_box.hide() self.sidebar_box.hide()
@ -151,40 +148,7 @@ class Window(Gtk.ApplicationWindow):
# Search and replace initialization # Search and replace initialization
# Same interface as Sidebar ;) # Same interface as Sidebar ;)
### ###
self.searchreplace = SearchAndReplace(self, self.text_view) self.searchreplace = SearchAndReplace(self, self.text_view, builder)
# Window resize
self.window_resize(self)
self.connect("configure-event", self.window_resize)
self.connect("delete-event", self.on_delete_called)
# Set current theme
self.apply_current_theme()
self.get_style_context().add_class('uberwriter-window')
def apply_current_theme(self, *_):
"""Adjusts the window, CSD and preview for the current theme.
"""
# Get current theme
theme, changed = Theme.get_current_changed()
if changed:
# Set theme variant (dark/light)
Gtk.Settings.get_default().set_property(
"gtk-application-prefer-dark-theme",
GLib.Variant("b", theme.is_dark))
# Set theme css
style_provider = Gtk.CssProvider()
style_provider.load_from_path(helpers.get_css_path("gtk/base.css"))
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(), style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
# Reload preview if it exists
self.reload_preview()
# Redraw contents of window
self.queue_draw()
def on_text_changed(self, *_args): def on_text_changed(self, *_args):
"""called when the text changes, sets the self.did_change to true and """called when the text changes, sets the self.did_change to true and
@ -209,19 +173,16 @@ class Window(Gtk.ApplicationWindow):
if state.get_boolean(): if state.get_boolean():
self.fullscreen() self.fullscreen()
self.fs_headerbar.events.show() self.fs_headerbar.events.show()
else: else:
self.unfullscreen() self.unfullscreen()
self.fs_headerbar.events.hide() self.fs_headerbar.events.hide()
self.text_view.grab_focus() self.text_view.grab_focus()
def set_focus_mode(self, state): def set_focus_mode(self, state):
"""toggle focusmode """toggle focusmode
""" """
focus_mode = state.get_boolean() self.text_view.set_focus_mode(state.get_boolean())
self.text_view.set_focus_mode(focus_mode)
self.text_view.grab_focus() self.text_view.grab_focus()
def set_hemingway_mode(self, state): def set_hemingway_mode(self, state):
@ -231,47 +192,21 @@ class Window(Gtk.ApplicationWindow):
self.text_view.set_hemingway_mode(state.get_boolean()) self.text_view.set_hemingway_mode(state.get_boolean())
self.text_view.grab_focus() self.text_view.grab_focus()
def window_resize(self, window, event=None): def toggle_preview(self, state):
"""set paddings dependant of the window size """Toggle the preview mode
Arguments:
state {gtk bool} -- Desired state of the preview mode (enabled/disabled)
""" """
# Ensure the window receiving the event is the one we care about, ie. the main window. if state.get_boolean():
# On Wayland (bug?), sub-windows such as the recents popover will also trigger this. self.text_view.grab_focus()
if event and event.window != window.get_window(): self.preview_handler.show()
return
# Adjust text editor width depending on window width, so that:
# - The number of characters per line is adequate (http://webtypography.net/2.1.2)
# - The number of characters stays constant while resizing the window / font
# - There is enough text margin for MarkupBuffer to apply indents / negative margins
#
# TODO: Avoid hard-coding. Font size is clearer than unclear dimensions, but not ideal.
w_width = event.width if event else window.get_allocation().width
if w_width < 900:
font_size = 14
self.get_style_context().add_class("small")
self.get_style_context().remove_class("large")
elif w_width < 1280:
font_size = 16
self.get_style_context().remove_class("small")
self.get_style_context().remove_class("large")
else: else:
font_size = 18 self.preview_handler.hide()
self.get_style_context().remove_class("small") self.text_view.grab_focus()
self.get_style_context().add_class("large")
font_width = int(font_size * 1/1.6) # Ratio specific to Fira Mono return True
width = 67 * font_width - 1 # 66 characters
horizontal_margin = 8 * font_width # 8 characters
width_request = width + horizontal_margin * 2
if self.text_view.props.width_request != width_request:
self.text_view.props.width_request = width_request
self.text_view.set_left_margin(horizontal_margin)
self.text_view.set_right_margin(horizontal_margin)
self.scrolled_window.props.width_request = width_request
# TODO: refactorizable # TODO: refactorizable
def save_document(self, _widget=None, _data=None): def save_document(self, _widget=None, _data=None):
@ -467,6 +402,9 @@ class Window(Gtk.ApplicationWindow):
def update_default_stat(self): def update_default_stat(self):
self.stats_handler.update_default_stat() self.stats_handler.update_default_stat()
def update_preview_mode(self):
self.preview_handler.update_preview_mode()
def menu_toggle_sidebar(self, _widget=None): def menu_toggle_sidebar(self, _widget=None):
"""WIP """WIP
""" """
@ -479,9 +417,7 @@ class Window(Gtk.ApplicationWindow):
status {gtk bool} -- Desired status of the spellchecking status {gtk bool} -- Desired status of the spellchecking
""" """
self.text_view.gspell_view\ self.text_view.set_spellcheck(state.get_boolean())
.set_inline_spell_checking(state.get_boolean()
and not self.text_view.focus_mode)
def toggle_gradient_overlay(self, state): def toggle_gradient_overlay(self, state):
"""Toggle the gradient overlay """Toggle the gradient overlay
@ -495,58 +431,8 @@ class Window(Gtk.ApplicationWindow):
elif self.overlay_id: elif self.overlay_id:
self.scrolled_window.disconnect(self.overlay_id) self.scrolled_window.disconnect(self.overlay_id)
def toggle_preview(self, state): def reload_preview(self, reshow=False):
"""Toggle the preview mode self.preview_handler.reload(reshow=reshow)
Arguments:
state {gtk bool} -- Desired state of the preview mode (enabled/disabled)
"""
if state.get_boolean():
self.show_preview()
else:
self.show_text_editor()
return True
def show_text_editor(self):
self.scrolled_window.remove(self.scrolled_window.get_child())
self.scrolled_window.add(self.text_view)
self.text_view.show()
self.preview_webview.destroy()
self.preview_webview = None
self.queue_draw()
def show_preview(self, loaded=False):
if loaded:
self.scrolled_window.remove(self.scrolled_window.get_child())
self.scrolled_window.add(self.preview_webview)
self.preview_webview.show()
self.queue_draw()
else:
args = ['--standalone',
'--mathjax',
'--css=' + Theme.get_current().web_css_path,
'--lua-filter=' + helpers.get_script_path('relative_to_absolute.lua'),
'--lua-filter=' + helpers.get_script_path('task-list.lua')]
output = helpers.pandoc_convert(self.text_view.get_text(), to="html5", args=args)
if self.preview_webview is None:
self.preview_webview = WebKit.WebView()
self.preview_webview.get_settings().set_allow_universal_access_from_file_urls(True)
# Show preview once the load is finished
self.preview_webview.connect("load-changed", self.on_preview_load_change)
# This saying that all links will be opened in default browser, \
# but local files are opened in appropriate apps:
self.preview_webview.connect("decide-policy", self.on_click_link)
self.preview_webview.load_html(output, 'file://localhost/')
def reload_preview(self):
if self.preview_webview:
self.show_preview()
def load_file(self, filename=None): def load_file(self, filename=None):
"""Open File from command line or open / open recent etc.""" """Open File from command line or open / open recent etc."""
@ -617,15 +503,10 @@ class Window(Gtk.ApplicationWindow):
True -- Gtk things True -- Gtk things
""" """
if (self.was_motion is False if (not self.was_motion
and self.top_bottom_bars_visible
and self.buffer_modified_for_status_bar and self.buffer_modified_for_status_bar
and self.text_view.props.has_focus): # pylint: disable=no-member and self.text_view.props.has_focus):
# self.status_bar.set_state_flags(Gtk.StateFlags.INSENSITIVE, True) self.reveal_top_bottom_bars(False)
self.stats_counter_revealer.set_reveal_child(False)
self.headerbar.hb_revealer.set_reveal_child(False)
self.top_bottom_bars_visible = False
self.buffer_modified_for_status_bar = False
self.was_motion = False self.was_motion = False
return True return True
@ -643,24 +524,22 @@ class Window(Gtk.ApplicationWindow):
return return
if now - self.timestamp_last_mouse_motion > 100: if now - self.timestamp_last_mouse_motion > 100:
# react on motion by fading in headerbar and statusbar # react on motion by fading in headerbar and statusbar
if self.top_bottom_bars_visible is False: self.reveal_top_bottom_bars(True)
self.stats_counter_revealer.set_reveal_child(True)
self.headerbar.hb_revealer.set_reveal_child(True)
self.headerbar.hb.props.opacity = 1
self.top_bottom_bars_visible = True
self.buffer_modified_for_status_bar = False
# self.status_bar.set_state_flags(Gtk.StateFlags.NORMAL, True)
self.was_motion = True self.was_motion = True
def focus_out(self, _widget, _data=None): def focus_out(self, _widget, _data=None):
"""events called when the window losses focus """events called when the window losses focus
""" """
if self.top_bottom_bars_visible is False: self.reveal_top_bottom_bars(True)
self.stats_counter_revealer.set_reveal_child(True)
self.headerbar.hb_revealer.set_reveal_child(True) def reveal_top_bottom_bars(self, reveal):
self.headerbar.hb.props.opacity = 1 if self.top_bottom_bars_visible != reveal:
self.top_bottom_bars_visible = True self.headerbar.hb_revealer.set_reveal_child(reveal)
self.buffer_modified_for_status_bar = False self.stats_revealer.set_reveal_child(reveal)
for revealer in self.preview_handler.get_top_bottom_bar_revealers():
revealer.set_reveal_child(reveal)
self.top_bottom_bars_visible = reveal
self.buffer_modified_for_status_bar = reveal
def draw_gradient(self, _widget, cr): def draw_gradient(self, _widget, cr):
"""draw fading gradient over the top and the bottom of the """draw fading gradient over the top and the bottom of the
@ -726,18 +605,4 @@ class Window(Gtk.ApplicationWindow):
else: else:
self.filename = None self.filename = None
base_path = "/" base_path = "/"
self.settings.set_value("open-file-path", GLib.Variant("s", base_path)) self.settings.set_string("open-file-path", base_path)
def on_preview_load_change(self, webview, event):
"""swaps text editor with preview once the load is complete
"""
if event == WebKit.LoadEvent.FINISHED:
self.show_preview(loaded=True)
def on_click_link(self, web_view, decision, _decision_type):
"""provide ability for self.webview to open links in default browser
"""
if web_view.get_uri().startswith(("http://", "https://", "www.")):
webbrowser.open(web_view.get_uri())
decision.ignore()
return True # Don't let the event "bubble up"

View File

@ -82,8 +82,12 @@ class PreferencesDialog:
self.gradient_overlay_switch.set_active(self.settings.get_value("gradient-overlay")) self.gradient_overlay_switch.set_active(self.settings.get_value("gradient-overlay"))
self.gradient_overlay_switch.connect("state-set", self.on_gradient_overlay) self.gradient_overlay_switch.connect("state-set", self.on_gradient_overlay)
self.sync_scroll_switch = self.builder.get_object("sync_scroll_switch")
self.sync_scroll_switch.set_active(self.settings.get_value("sync-scroll"))
self.sync_scroll_switch.connect("state-set", self.on_sync_scroll)
input_format_store = Gtk.ListStore(int, str) input_format_store = Gtk.ListStore(int, str)
input_format = self.settings.get_value("input-format").get_string() input_format = self.settings.get_string("input-format")
input_format_active = 0 input_format_active = 0
for i, fmt in enumerate(self.formats): for i, fmt in enumerate(self.formats):
input_format_store.append([i, fmt["name"]]) input_format_store.append([i, fmt["name"]])
@ -107,28 +111,32 @@ class PreferencesDialog:
preferences_window.show() preferences_window.show()
def on_dark_mode_auto(self, _, state): def on_dark_mode_auto(self, _, state):
self.settings.set_value("dark-mode-auto", GLib.Variant.new_boolean(state)) self.settings.set_boolean("dark-mode-auto", state)
if state and self.dark_mode_switch.get_active(): if state and self.dark_mode_switch.get_active():
self.dark_mode_switch.set_active(GLib.Variant.new_boolean(False)) self.dark_mode_switch.set_active(GLib.Variant.new_boolean(False))
return False return False
def on_dark_mode(self, _, state): def on_dark_mode(self, _, state):
self.settings.set_value("dark-mode", GLib.Variant.new_boolean(state)) self.settings.set_boolean("dark-mode", state)
if state and self.dark_mode_auto_switch.get_active(): if state and self.dark_mode_auto_switch.get_active():
self.dark_mode_auto_switch.set_active(GLib.Variant.new_boolean(False)) self.dark_mode_auto_switch.set_active(GLib.Variant.new_boolean(False))
return False return False
def on_spellcheck(self, _, state): def on_spellcheck(self, _, state):
self.settings.set_value("spellcheck", GLib.Variant.new_boolean(state)) self.settings.set_boolean("spellcheck", state)
return False return False
def on_gradient_overlay(self, _, state): def on_gradient_overlay(self, _, state):
self.settings.set_value("gradient-overlay", GLib.Variant.new_boolean(state)) self.settings.set_boolean("gradient-overlay", state)
return False
def on_sync_scroll(self, _, state):
self.settings.set_boolean("sync-scroll", state)
return False return False
def on_input_format(self, combobox): def on_input_format(self, combobox):
fmt = self.formats[combobox.get_active()] fmt = self.formats[combobox.get_active()]
self.settings.set_value("input-format", GLib.Variant.new_string(fmt["format"])) self.settings.set_string("input-format", fmt["format"])
def on_input_format_help(self, _): def on_input_format_help(self, _):
fmt = self.formats[self.input_format_combobox.get_active()] fmt = self.formats[self.input_format_combobox.get_active()]

View File

@ -0,0 +1,49 @@
from queue import Queue
from threading import Thread
from gi.repository import GLib
from uberwriter import helpers
from uberwriter.theme import Theme
class PreviewConverter:
"""Converts markdown to html using a background thread."""
def __init__(self):
super().__init__()
self.queue = Queue()
worker = Thread(target=self.__do_convert, name="preview-converter")
worker.daemon = True
worker.start()
def convert(self, text, callback, *user_data):
"""Converts text to html, calling callback when done.
The callback argument contains the result."""
self.queue.put((text, callback, user_data))
def stop(self):
"""Stops the background worker. PreviewConverter shouldn't be used after this."""
self.queue.put((None, None))
def __do_convert(self):
while True:
while True:
(text, callback, user_data) = self.queue.get()
if text is None and callback is None:
return
if self.queue.empty():
break
args = ['--standalone',
'--mathjax',
'--css=' + Theme.get_current().web_css_path,
'--lua-filter=' + helpers.get_script_path('relative_to_absolute.lua'),
'--lua-filter=' + helpers.get_script_path('task-list.lua')]
text = helpers.pandoc_convert(text, to="html5", args=args)
GLib.idle_add(callback, text, *user_data)

View File

@ -0,0 +1,166 @@
import math
import webbrowser
from enum import auto, IntEnum
import gi
from uberwriter.helpers import get_builder
from uberwriter.preview_renderer import PreviewRenderer
from uberwriter.settings import Settings
gi.require_version('WebKit2', '4.0')
from gi.repository import WebKit2, GLib
from uberwriter.preview_converter import PreviewConverter
from uberwriter.web_view import WebView
class Step(IntEnum):
CONVERT_HTML = auto()
LOAD_WEBVIEW = auto()
RENDER = auto()
class PreviewHandler:
"""Handles showing/hiding the preview, and allows the user to toggle between modes.
The rendering itself is handled by `PreviewRendered`. This class handles conversion/loading and
connects it all together (including synchronization, ie. text changes, scroll)."""
def __init__(self, window, content, editor, text_view):
self.text_view = text_view
self.web_view = None
self.web_view_pending_html = None
builder = get_builder("Preview")
preview = builder.get_object("preview")
mode_button = builder.get_object("preview_mode_button")
self.mode_revealer = builder.get_object("preview_mode_revealer")
self.preview_converter = PreviewConverter()
self.preview_renderer = PreviewRenderer(
window, content, editor, text_view, preview, self.mode_revealer, mode_button)
window.connect("style-updated", self.reload)
self.text_changed_handler_id = None
self.settings = Settings.new()
self.web_scroll_handler_id = None
self.text_scroll_handler_id = None
self.loading = False
self.shown = False
def show(self):
self.__show()
def __show(self, html=None, step=Step.CONVERT_HTML):
if step == Step.CONVERT_HTML:
# First step: convert text to HTML.
buf = self.text_view.get_buffer()
self.preview_converter.convert(
buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False),
self.__show, Step.LOAD_WEBVIEW)
elif step == Step.LOAD_WEBVIEW:
# Second step: load HTML.
self.loading = True
if not self.web_view:
self.web_view = WebView()
self.web_view.get_settings().set_allow_universal_access_from_file_urls(True)
# Show preview once the load is finished
self.web_view.connect("load-changed", self.on_load_changed)
# All links will be opened in default browser, but local files are opened in apps.
self.web_view.connect("decide-policy", self.on_click_link)
if self.web_view.is_loading():
self.web_view_pending_html = html
else:
self.web_view.load_html(html, "file://localhost/")
elif step == Step.RENDER:
# Last step: show the preview. This is a one-time step.
if self.shown:
return
self.shown = True
self.text_changed_handler_id = \
self.text_view.get_buffer().connect("changed", self.__show)
GLib.idle_add(self.web_view.set_scroll_scale, self.text_view.get_scroll_scale())
self.preview_renderer.show(self.web_view)
if self.settings.get_boolean("sync-scroll"):
self.web_scroll_handler_id = \
self.web_view.connect("scroll-scale-changed", self.on_web_view_scrolled)
self.text_scroll_handler_id = \
self.text_view.connect("scroll-scale-changed", self.on_text_view_scrolled)
def reload(self, *_widget, reshow=False):
if self.shown:
if reshow:
self.hide()
self.show()
def hide(self):
if self.shown:
self.shown = False
self.text_view.get_buffer().disconnect(self.text_changed_handler_id)
GLib.idle_add(self.text_view.set_scroll_scale, self.web_view.get_scroll_scale())
self.preview_renderer.hide(self.web_view)
if self.text_scroll_handler_id:
self.text_view.disconnect(self.text_scroll_handler_id)
self.text_scroll_handler_id = None
if self.web_scroll_handler_id:
self.web_view.disconnect(self.web_scroll_handler_id)
self.web_scroll_handler_id = None
if self.loading:
self.loading = False
self.web_view.destroy()
self.web_view = None
def update_preview_mode(self):
self.preview_renderer.update_mode(self.web_view)
def get_top_bottom_bar_revealers(self):
if self.shown and not self.preview_renderer.window:
return [self.mode_revealer]
else:
return []
def on_load_changed(self, _web_view, event):
if event == WebKit2.LoadEvent.FINISHED:
self.loading = False
if self.web_view_pending_html:
self.__show(html=self.web_view_pending_html, step=Step.LOAD_WEBVIEW)
self.web_view_pending_html = None
else:
self.__show(step=Step.RENDER)
def on_text_view_scrolled(self, _text_view, scale):
if self.shown and not math.isclose(scale, self.web_view.get_scroll_scale(), rel_tol=1e-5):
self.web_view.set_scroll_scale(scale)
def on_web_view_scrolled(self, _web_view, scale):
if self.shown and self.text_view.get_mapped() and \
not math.isclose(scale, self.text_view.get_scroll_scale(), rel_tol=1e-5):
self.text_view.set_scroll_scale(scale)
@staticmethod
def on_click_link(web_view, decision, _decision_type):
if web_view.get_uri().startswith(("http://", "https://", "www.")):
webbrowser.open(web_view.get_uri())
decision.ignore()
return True

View File

@ -0,0 +1,174 @@
from gettext import gettext as _
from gi.repository import Gtk, Gio, GLib
from uberwriter import headerbars
from uberwriter.settings import Settings
from uberwriter.styled_window import StyledWindow
class PreviewRenderer:
"""Renders the preview according to the user selected mode."""
# Must match the order/index defined in gschema.xml
FULL_WIDTH = 0
HALF_WIDTH = 1
HALF_HEIGHT = 2
WINDOWED = 3
def __init__(
self, main_window, content, editor, text_view, preview, mode_revealer, mode_button):
self.main_window = main_window
self.main_window.connect("delete-event", self.on_window_closed)
self.content = content
self.editor = editor
self.text_view = text_view
self.preview = preview
self.mode_revealer = mode_revealer
self.mode_button = mode_button
self.mode_button.connect("clicked", self.show_mode_popover)
self.settings = Settings.new()
self.popover = None
self.window = None
self.headerbar = None
self.mode = self.settings.get_enum("preview-mode")
self.update_mode()
def show(self, web_view):
"""Show the preview, depending on the currently selected mode."""
# Windowed preview: create a window and show the preview in it.
if self.mode == self.WINDOWED:
# Create transient window of the main window.
self.window = StyledWindow(application=self.main_window.get_application())
self.window.connect("delete-event", self.on_window_closed)
# Create a custom header bar and move the mode button there.
headerbar = headerbars.PreviewHeaderbar()
self.headerbar = headerbar.hb
self.headerbar.set_title(_("Preview"))
self.mode_button.get_style_context().remove_class("inline-button")
self.mode_revealer.remove(self.mode_button)
self.headerbar.pack_end(self.mode_button)
self.window.set_titlebar(headerbar.hb_container)
# Position it next to the main window.
width, height = self.main_window.get_size()
self.window.resize(width, height)
x, y = self.main_window.get_position()
if x is not None and y is not None:
self.main_window.move(x, y)
self.window.move(x + width + 16, y)
# Add webview and show.
self.window.add(web_view)
self.window.show()
else:
self.preview.pack_start(web_view, True, True, 0)
self.content.add(self.preview)
# Full-width preview: swap editor with preview.
if self.mode == self.FULL_WIDTH:
self.content.remove(self.editor)
# Half-width preview: set horizontal orientation and add the preview.
# Ask for a minimum width that respects the editor's minimum requirements.
elif self.mode == self.HALF_WIDTH:
self.content.set_orientation(Gtk.Orientation.HORIZONTAL)
self.content.set_size_request(self.text_view.get_min_width() * 2, -1)
# Half-height preview: set vertical orientation and add the preview.
# Ask for a minimum height that provides a comfortable experience.
elif self.mode == self.HALF_HEIGHT:
self.content.set_orientation(Gtk.Orientation.VERTICAL)
self.content.set_size_request(-1, 768)
else:
raise ValueError("Unknown preview mode {}".format(self.mode))
web_view.show()
def hide(self, web_view):
"""Hide the preview, depending on the currently selected mode."""
# Windowed preview: remove preview and destroy window.
if self.mode == self.WINDOWED:
self.main_window.present()
self.headerbar.remove(self.mode_button)
self.mode_button.get_style_context().add_class("inline-button")
self.mode_revealer.add(self.mode_button)
self.headerbar = None
self.window.remove(web_view)
self.window.destroy()
self.window = None
else:
self.preview.remove(web_view)
self.content.remove(self.preview)
# Full-width preview: swap preview with editor.
if self.mode == self.FULL_WIDTH:
self.content.add(self.editor)
# Half-width/height previews: remove preview and reset size requirements.
elif self.mode == self.HALF_WIDTH or self.mode == self.HALF_HEIGHT:
self.content.set_size_request(-1, -1)
else:
raise ValueError("Unknown preview mode {}".format(self.mode))
def update_mode(self, web_view=None):
"""Update preview mode, adjusting the mode button and the preview itself."""
mode = self.settings.get_enum("preview-mode")
if web_view and mode != self.mode:
self.hide(web_view)
self.mode = mode
self.show(web_view)
else:
self.mode = mode
if self.mode_button:
text = self.get_text_for_preview_mode(self.mode)
self.mode_button.set_label(text)
if self.popover:
self.popover.popdown()
def show_mode_popover(self, button):
"""Show preview mode popover."""
self.mode_button.set_state_flags(Gtk.StateFlags.CHECKED, False)
menu = Gio.Menu()
modes = self.settings.props.settings_schema.get_key("preview-mode").get_range()[1]
for i, mode in enumerate(modes):
menu_item = Gio.MenuItem.new(self.get_text_for_preview_mode(i), None)
menu_item.set_action_and_target_value("app.preview_mode", GLib.Variant.new_string(mode))
menu.append_item(menu_item)
self.popover = Gtk.Popover.new_from_model(button, menu)
self.popover.connect('closed', self.on_popover_closed)
self.popover.popup()
def on_popover_closed(self, _popover):
self.mode_button.unset_state_flags(Gtk.StateFlags.CHECKED)
self.popover = None
self.text_view.grab_focus()
def on_window_closed(self, window, _event):
preview_action = window.get_application().lookup_action("preview")
preview_action.change_state(GLib.Variant.new_boolean(False))
def get_text_for_preview_mode(self, mode):
if mode == self.FULL_WIDTH:
return _("Full-Width")
elif mode == self.HALF_WIDTH:
return _("Half-Width")
elif mode == self.HALF_HEIGHT:
return _("Half-Height")
elif mode == self.WINDOWED:
return _("Windowed")
else:
raise ValueError("Unknown preview mode {}".format(mode))

View File

@ -1,48 +0,0 @@
class Scroller:
def __init__(self, scrolled_window, source_pos, target_pos):
super().__init__()
self.scrolled_window = scrolled_window
self.source_pos = source_pos
self.target_pos = target_pos
self.duration = max(200, (target_pos - source_pos) / 50) * 1000
self.is_started = False
self.is_setup = False
self.start_time = 0
self.end_time = 0
self.tick_callback_id = 0
def start(self):
self.is_started = True
self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
def end(self):
self.scrolled_window.remove_tick_callback(self.tick_callback_id)
self.is_started = False
def setup(self, time):
self.start_time = time
self.end_time = time + self.duration
self.is_setup = True
def on_tick(self, widget, frame_clock):
def ease_out_cubic(value):
return pow(value - 1, 3) + 1
now = frame_clock.get_frame_time()
if not self.is_setup:
self.setup(now)
if now < self.end_time:
time = float(now - self.start_time) / float(self.end_time - self.start_time)
else:
time = 1
self.end()
time = ease_out_cubic(time)
pos = self.source_pos + (time * (self.target_pos - self.source_pos))
widget.get_vadjustment().props.value = pos
return True

View File

@ -33,32 +33,32 @@ class SearchAndReplace:
uberwriter uberwriter
""" """
def __init__(self, parentwindow, textview): def __init__(self, parentwindow, textview, builder):
self.parentwindow = parentwindow self.parentwindow = parentwindow
self.textview = textview self.textview = textview
self.textbuffer = textview.get_buffer() self.textbuffer = textview.get_buffer()
self.box = parentwindow.builder.get_object("searchbar_placeholder") self.box = builder.get_object("searchbar_placeholder")
self.box.set_reveal_child(False) self.box.set_reveal_child(False)
self.searchbar = parentwindow.builder.get_object("searchbar") self.searchbar = builder.get_object("searchbar")
self.searchentry = parentwindow.builder.get_object("searchentrybox") self.searchentry = builder.get_object("searchentrybox")
self.searchentry.connect('changed', self.search) self.searchentry.connect('changed', self.search)
self.searchentry.connect('activate', self.scrolltonext) self.searchentry.connect('activate', self.scrolltonext)
self.searchentry.connect('key-press-event', self.key_pressed) self.searchentry.connect('key-press-event', self.key_pressed)
self.open_replace_button = parentwindow.builder.get_object("replace") self.open_replace_button = builder.get_object("replace")
self.open_replace_button.connect("toggled", self.toggle_replace) self.open_replace_button.connect("toggled", self.toggle_replace)
self.nextbutton = parentwindow.builder.get_object("next_result") self.nextbutton = builder.get_object("next_result")
self.prevbutton = parentwindow.builder.get_object("previous_result") self.prevbutton = builder.get_object("previous_result")
self.regexbutton = parentwindow.builder.get_object("regex") self.regexbutton = builder.get_object("regex")
self.casesensitivebutton = parentwindow.builder.get_object("case_sensitive") self.casesensitivebutton = builder.get_object("case_sensitive")
self.replacebox = parentwindow.builder.get_object("replace_placeholder") self.replacebox = builder.get_object("replace_placeholder")
self.replacebox.set_reveal_child(False) self.replacebox.set_reveal_child(False)
self.replace_one_button = parentwindow.builder.get_object("replace_one") self.replace_one_button = builder.get_object("replace_one")
self.replace_all_button = parentwindow.builder.get_object("replace_all") self.replace_all_button = builder.get_object("replace_all")
self.replaceentry = parentwindow.builder.get_object("replaceentrybox") self.replaceentry = builder.get_object("replaceentrybox")
self.replace_all_button.connect('clicked', self.replace_all) self.replace_all_button.connect('clicked', self.replace_all)
self.replace_one_button.connect('clicked', self.replace_clicked) self.replace_one_button.connect('clicked', self.replace_clicked)
@ -96,7 +96,8 @@ class SearchAndReplace:
""" """
show search box show search box
""" """
if self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False: if self.textview.get_mapped() and (
self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False):
self.searchbar.set_search_mode(True) self.searchbar.set_search_mode(True)
self.box.set_reveal_child(True) self.box.set_reveal_child(True)
self.searchentry.grab_focus() self.searchentry.grab_focus()

View File

@ -11,8 +11,11 @@ from uberwriter import helpers
class StatsCounter: class StatsCounter:
"""Counts characters, words, sentences and read 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. # Regexp that matches characters, with the following exceptions:
CHARACTERS = re.compile(r"[^\s]|(?:[^\S\n](?!\s))") # * Newlines
# * Sequential spaces
# * Sequential dashes
CHARACTERS = re.compile(r"[^\s-]|(?:[^\S\n](?!\s)|-(?![-\n]))")
# Regexp that matches Asian letters, general symbols and hieroglyphs, # Regexp that matches Asian letters, general symbols and hieroglyphs,
# as well as sequences of word characters optionally containing non-word characters in-between. # as well as sequences of word characters optionally containing non-word characters in-between.

View File

@ -1,13 +1,7 @@
import math
import re
from gettext import gettext as _ from gettext import gettext as _
from queue import Queue
from threading import Thread
from gi.repository import GLib, Gio, Gtk from gi.repository import GLib, Gio, Gtk
from uberwriter import helpers
from uberwriter.helpers import get_builder
from uberwriter.settings import Settings from uberwriter.settings import Settings
from uberwriter.stats_counter import StatsCounter from uberwriter.stats_counter import StatsCounter
@ -15,6 +9,13 @@ from uberwriter.stats_counter import StatsCounter
class StatsHandler: class StatsHandler:
"""Shows a default statistic on the stats button, and allows the user to toggle which one.""" """Shows a default statistic on the stats button, and allows the user to toggle which one."""
# Must match the order/index defined in gschema.xml
CHARACTERS = 0
WORDS = 1
SENTENCES = 2
PARAGRAPHS = 3
READ_TIME = 4
def __init__(self, stats_button, text_view): def __init__(self, stats_button, text_view):
super().__init__() super().__init__()
@ -34,7 +35,6 @@ class StatsHandler:
self.read_time = (0, 0, 0) self.read_time = (0, 0, 0)
self.settings = Settings.new() self.settings = Settings.new()
self.default_stat = self.settings.get_enum("stat-default")
self.stats_counter = StatsCounter() self.stats_counter = StatsCounter()
@ -65,16 +65,16 @@ class StatsHandler:
self.update_stats) self.update_stats)
def get_text_for_stat(self, stat): def get_text_for_stat(self, stat):
if stat == 0: if stat == self.CHARACTERS:
return _("{:n} Characters".format(self.characters)) return _("{:n} Characters").format(self.characters)
elif stat == 1: elif stat == self.WORDS:
return _("{:n} Words".format(self.words)) return _("{:n} Words").format(self.words)
elif stat == 2: elif stat == self.SENTENCES:
return _("{:n} Sentences".format(self.sentences)) return _("{:n} Sentences").format(self.sentences)
elif stat == 3: elif stat == self.PARAGRAPHS:
return _("{:n} Paragraphs".format(self.paragraphs)) return _("{:n} Paragraphs").format(self.paragraphs)
elif stat == 4: elif stat == self.READ_TIME:
return _("{:d}:{:02d}:{:02d} Read Time".format(*self.read_time)) return _("{:d}:{:02d}:{:02d} Read Time").format(*self.read_time)
else: else:
raise ValueError("Unknown stat {}".format(stat)) raise ValueError("Unknown stat {}".format(stat))

View File

@ -0,0 +1,37 @@
import gi
from uberwriter import helpers
from uberwriter.theme import Theme
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib
class StyledWindow(Gtk.ApplicationWindow):
"""A window that will redraw itself upon theme changes."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.connect("style-updated", self.apply_current_theme)
self.apply_current_theme()
def apply_current_theme(self, *_):
"""Adjusts the window, CSD and preview for the current theme."""
# Get current theme
theme, changed = Theme.get_current_changed()
if changed:
# Set theme variant (dark/light)
Gtk.Settings.get_default().set_property(
"gtk-application-prefer-dark-theme",
GLib.Variant("b", theme.is_dark))
# Set theme css
style_provider = Gtk.CssProvider()
style_provider.load_from_path(helpers.get_css_path("gtk/base.css"))
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(), style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
# Redraw contents of window
self.queue_draw()

View File

@ -1,17 +1,19 @@
import gi import gi
from gi.repository.GObject import SignalMatchType
from uberwriter.inline_preview import InlinePreview from uberwriter.inline_preview import InlinePreview
from uberwriter.text_view_format_inserter import FormatInserter from uberwriter.text_view_format_inserter import FormatInserter
from uberwriter.text_view_markup_handler import MarkupHandler from uberwriter.text_view_markup_handler import MarkupHandler
from uberwriter.text_view_scroller import TextViewScroller
from uberwriter.text_view_undo_redo_handler import UndoRedoHandler from uberwriter.text_view_undo_redo_handler import UndoRedoHandler
from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT
from uberwriter.scroller import Scroller
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
gi.require_version('Gspell', '1') gi.require_version('Gspell', '1')
from gi.repository import Gtk, Gdk, GObject, GLib, Gspell from gi.repository import Gtk, Gdk, GObject, GLib, Gspell
import logging import logging
LOGGER = logging.getLogger('uberwriter') LOGGER = logging.getLogger('uberwriter')
@ -36,10 +38,13 @@ class TextView(Gtk.TextView):
'insert-header': (GObject.SignalFlags.ACTION, None, ()), 'insert-header': (GObject.SignalFlags.ACTION, None, ()),
'insert-strikethrough': (GObject.SignalFlags.ACTION, None, ()), 'insert-strikethrough': (GObject.SignalFlags.ACTION, None, ()),
'undo': (GObject.SignalFlags.ACTION, None, ()), 'undo': (GObject.SignalFlags.ACTION, None, ()),
'redo': (GObject.SignalFlags.ACTION, None, ()) 'redo': (GObject.SignalFlags.ACTION, None, ()),
'scroll-scale-changed': (GObject.SIGNAL_RUN_LAST, None, (float,)),
} }
def __init__(self): font_sizes = [18, 17, 16, 15, 14] # Must match CSS selectors in gtk/base.css
def __init__(self, line_chars):
super().__init__() super().__init__()
# Appearance # Appearance
@ -49,11 +54,18 @@ class TextView(Gtk.TextView):
self.set_pixels_inside_wrap(8) self.set_pixels_inside_wrap(8)
self.get_style_context().add_class('uberwriter-editor') self.get_style_context().add_class('uberwriter-editor')
# Text sizing
self.props.halign = Gtk.Align.FILL
self.line_chars = line_chars
self.font_size = 16
self.get_style_context().add_class('size16')
# General behavior # General behavior
self.get_buffer().connect('changed', self.on_text_changed)
self.connect('size-allocate', self.on_size_allocate) self.connect('size-allocate', self.on_size_allocate)
self.get_buffer().connect('changed', self.on_text_changed)
# Spell checking # Spell checking
self.spellcheck = True
self.gspell_view = Gspell.TextView.get_from_gtk_text_view(self) self.gspell_view = Gspell.TextView.get_from_gtk_text_view(self)
self.gspell_view.basic_setup() self.gspell_view.basic_setup()
@ -85,6 +97,7 @@ class TextView(Gtk.TextView):
# Scrolling # Scrolling
self.scroller = None self.scroller = None
self.connect('parent-set', self.on_parent_set)
self.get_buffer().connect('mark-set', self.on_mark_set) self.get_buffer().connect('mark-set', self.on_mark_set)
# Focus mode # Focus mode
@ -95,6 +108,12 @@ class TextView(Gtk.TextView):
self.hemingway_mode = False self.hemingway_mode = False
self.connect('key-press-event', self.on_key_press_event) self.connect('key-press-event', self.on_key_press_event)
# While resizing the TextView, there is unwanted scroll upwards if a top margin is present.
# When a size allocation is detected, this variable will hold the scroll to re-set until the
# UI is idle again.
# TODO: Find a better way to handle unwanted scroll.
self.frozen_scroll_scale = None
def get_text(self): def get_text(self):
text_buffer = self.get_buffer() text_buffer = self.get_buffer()
start_iter = text_buffer.get_start_iter() start_iter = text_buffer.get_start_iter()
@ -105,13 +124,59 @@ class TextView(Gtk.TextView):
text_buffer = self.get_buffer() text_buffer = self.get_buffer()
text_buffer.set_text(text) text_buffer.set_text(text)
def on_text_changed(self, *_): def get_scroll_scale(self):
self.markup.apply() return self.scroller.get_scroll_scale() if self.scroller else 0
GLib.idle_add(self.scroll_to)
def set_scroll_scale(self, scale):
if self.scroller:
self.scroller.set_scroll_scale(scale)
def on_size_allocate(self, *_): def on_size_allocate(self, *_):
self.update_horizontal_margin()
self.update_vertical_margin() self.update_vertical_margin()
self.markup.update_margins_indents() self.markup.update_margins_indents()
self.queue_draw()
# TODO: Find a better way to handle unwanted scroll on resize.
self.frozen_scroll_scale = self.get_scroll_scale()
GLib.idle_add(self.unfreeze_scroll_scale)
def on_text_changed(self, *_):
self.markup.apply()
self.smooth_scroll_to()
def on_parent_set(self, *_):
parent = self.get_parent()
if parent:
parent.set_size_request(self.get_min_width(), 500)
self.scroller = TextViewScroller(self, parent)
parent.get_vadjustment().connect("changed", self.on_scroll_scale_changed)
parent.get_vadjustment().connect("value-changed", self.on_scroll_scale_changed)
else:
self.scroller = None
def on_mark_set(self, _text_buffer, _location, mark, _data=None):
if mark.get_name() == 'insert':
self.markup.apply()
self.smooth_scroll_to(mark)
elif mark.get_name() == 'gtk_drag_target':
self.smooth_scroll_to(mark)
return True
def on_button_release_event(self, _widget, _event):
if self.focus_mode:
self.markup.apply()
return False
def on_scroll_scale_changed(self, *_):
if self.frozen_scroll_scale is not None:
self.set_scroll_scale(self.frozen_scroll_scale)
else:
self.emit("scroll-scale-changed", self.get_scroll_scale())
def unfreeze_scroll_scale(self):
self.frozen_scroll_scale = None
self.queue_draw()
def set_focus_mode(self, focus_mode): def set_focus_mode(self, focus_mode):
"""Toggle focus mode. """Toggle focus mode.
@ -120,10 +185,33 @@ class TextView(Gtk.TextView):
and the surrounding text is greyed out.""" and the surrounding text is greyed out."""
self.focus_mode = focus_mode self.focus_mode = focus_mode
self.gspell_view.set_inline_spell_checking(not focus_mode)
self.update_vertical_margin() self.update_vertical_margin()
self.markup.apply() self.markup.apply()
self.scroll_to() self.smooth_scroll_to()
self.set_spellcheck(self.spellcheck)
def set_spellcheck(self, spellcheck):
self.spellcheck = spellcheck
self.gspell_view.set_inline_spell_checking(self.spellcheck and not self.focus_mode)
def update_horizontal_margin(self):
width = self.get_allocation().width
# Ensure the appropriate font size is being used
for font_size in self.font_sizes:
if width >= self.get_min_width(font_size) or font_size == self.font_sizes[-1]:
if font_size != self.font_size:
self.font_size = font_size
for fs in self.font_sizes:
self.get_style_context().remove_class("size{}".format(fs))
self.get_style_context().add_class("size{}".format(font_size))
break
# Apply margin with the remaining space to allow for markup
line_width = (self.line_chars + 1) * int(self.get_char_width(self.font_size)) - 1
horizontal_margin = (width - line_width) / 2
self.props.left_margin = horizontal_margin
self.props.right_margin = horizontal_margin
def update_vertical_margin(self): def update_vertical_margin(self):
if self.focus_mode: if self.focus_mode:
@ -134,11 +222,6 @@ class TextView(Gtk.TextView):
self.props.top_margin = 80 self.props.top_margin = 80
self.props.bottom_margin = 64 self.props.bottom_margin = 64
def on_button_release_event(self, _widget, _event):
if self.focus_mode:
self.markup.apply()
return False
def set_hemingway_mode(self, hemingway_mode): def set_hemingway_mode(self, hemingway_mode):
"""Toggle hemingway mode. """Toggle hemingway mode.
@ -158,48 +241,34 @@ class TextView(Gtk.TextView):
self.get_buffer().set_text('') self.get_buffer().set_text('')
self.undo_redo.clear() self.undo_redo.clear()
def scroll_to(self, mark=None): def smooth_scroll_to(self, mark=None):
"""Scrolls if needed to ensure mark is visible. """Scrolls if needed to ensure mark is visible.
If mark is unspecified, the cursor is used.""" If mark is unspecified, the cursor is used."""
margin = 32 if self.scroller is None:
scrolled_window = self.get_ancestor(Gtk.ScrolledWindow.__gtype__)
if not scrolled_window:
return
va = scrolled_window.get_vadjustment()
if va.props.page_size < margin * 2:
return return
if mark is None:
mark = self.get_buffer().get_insert()
GLib.idle_add(self.scroller.smooth_scroll_to_mark, mark, self.focus_mode)
text_buffer = self.get_buffer() def get_min_width(self, font_size=None):
if mark: """Returns the minimum width of this text view."""
mark_iter = text_buffer.get_iter_at_mark(mark)
else:
mark_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert())
mark_rect = self.get_iter_location(mark_iter)
pos_y = mark_rect.y + mark_rect.height + self.props.top_margin if font_size is None:
pos = pos_y - va.props.value font_size = self.font_sizes[-1]
target_pos = None return (self.line_chars + self.get_pad_chars(font_size) + 1) \
if self.focus_mode: * self.get_char_width(font_size) - 1
if pos != (va.props.page_size * 0.5):
target_pos = pos_y - (va.props.page_size * 0.5)
elif pos > va.props.page_size - margin:
target_pos = pos_y - va.props.page_size + margin
elif pos < margin:
target_pos = pos_y - margin
if self.scroller and self.scroller.is_started: def get_pad_chars(self, font_size):
self.scroller.end() """Returns the amount of character padding for font_size.
if target_pos:
self.scroller = Scroller(scrolled_window, va.props.value, target_pos)
self.scroller.start()
def on_mark_set(self, _text_buffer, _location, mark, _data=None): Markup can use up to 7 in normal conditions."""
if mark.get_name() == 'insert':
self.markup.apply() return 8 * (1 + font_size - self.font_sizes[-1])
if self.focus_mode:
self.scroll_to(mark) @staticmethod
elif mark.get_name() == 'gtk_drag_target': def get_char_width(font_size):
self.scroll_to(mark) """Returns the font width for a given size. Note: specific to Fira Mono!"""
return True
return font_size * 1 / 1.6

View File

@ -37,13 +37,13 @@ class MarkupHandler:
"BOLDITALIC": re.compile(r"(\*\*\*|___)(.+?)\1"), "BOLDITALIC": re.compile(r"(\*\*\*|___)(.+?)\1"),
"STRIKETHROUGH": re.compile(r"~~.+?~~"), "STRIKETHROUGH": re.compile(r"~~.+?~~"),
"LINK": re.compile(r"(\[).*(\]\(.+?\))"), "LINK": re.compile(r"(\[).*(\]\(.+?\))"),
"HORIZONTALRULE": re.compile(r"\n\n([ ]{0,3}[*\-_]{3,}[ ]*)\n", re.MULTILINE), "HORIZONTALRULE": re.compile(r"\n\n([ ]{0,3}[*\-_]{3,}[ ]*)\n\n", re.MULTILINE),
"LIST": re.compile(r"^((?:\t|[ ]{4})*)[\-*+] .+", re.MULTILINE), "LIST": re.compile(r"^((?:\t|[ ]{4})*)[\-*+] .+", re.MULTILINE),
"NUMERICLIST": re.compile(r"^((\d|[a-z]|#)+[.)]) ", re.MULTILINE), "NUMERICLIST": re.compile(r"^((\d|[a-z]|#)+[.)]) ", re.MULTILINE),
"NUMBEREDLIST": re.compile(r"^((?:\t|[ ]{4})*)((?:\d|[a-z])+[.)]) .+", re.MULTILINE), "NUMBEREDLIST": re.compile(r"^((?:\t|[ ]{4})*)((?:\d|[a-z])+[.)]) .+", re.MULTILINE),
"BLOCKQUOTE": re.compile(r"^[ ]{0,3}(?:>|(?:> )+).+", re.MULTILINE), "BLOCKQUOTE": re.compile(r"^[ ]{0,3}(?:>|(?:> )+).+", re.MULTILINE),
"HEADER": re.compile(r"^[ ]{0,3}(#{1,6}) [^\n]+", re.MULTILINE), "HEADER": re.compile(r"^[ ]{0,3}(#{1,6}) [^\n]+", re.MULTILINE),
"HEADER_UNDER": re.compile(r"^[ ]{0,3}\w.+\n[ ]{0,3}[=\-]{3,}", re.MULTILINE), "HEADER_UNDER": re.compile(r"^\n[ ]{0,3}\w.+\n[ ]{0,3}[=\-]{3,}", re.MULTILINE),
"CODE": re.compile(r"(?:^|\n)[ ]{0,3}(([`~]{3}).+?[ ]{0,3}\2)(?:\n|$)", re.DOTALL), "CODE": re.compile(r"(?:^|\n)[ ]{0,3}(([`~]{3}).+?[ ]{0,3}\2)(?:\n|$)", re.DOTALL),
"TABLE": re.compile(r"^[\-+]{5,}\n(.+?)\n[\-+]{5,}\n", re.DOTALL), "TABLE": re.compile(r"^[\-+]{5,}\n(.+?)\n[\-+]{5,}\n", re.DOTALL),
"MATH": re.compile(r"[$]{1,2}([^` ].+?[^`\\ ])[$]{1,2}"), "MATH": re.compile(r"[$]{1,2}([^` ].+?[^`\\ ])[$]{1,2}"),
@ -79,10 +79,10 @@ class MarkupHandler:
strikethrough=False, strikethrough=False,
justification=Gtk.Justification.LEFT) justification=Gtk.Justification.LEFT)
self.table = buffer.create_tag('table') self.table = buffer.create_tag('table',
self.table.set_property('wrap-mode', Gtk.WrapMode.NONE) wrap_mode=Gtk.WrapMode.NONE,
self.table.set_property('pixels-above-lines', 0) pixels_above_lines=0,
self.table.set_property('pixels-below-lines', 0) pixels_below_lines=0)
self.mathtext = buffer.create_tag('mathtext') self.mathtext = buffer.create_tag('mathtext')
@ -94,6 +94,8 @@ class MarkupHandler:
# Margin and indents # Margin and indents
# A baseline margin is set to allow negative offsets for formatting headers, lists, etc # A baseline margin is set to allow negative offsets for formatting headers, lists, etc
self.margins_indents = {} self.margins_indents = {}
self.baseline_margin = 0
self.char_width = 0
self.update_margins_indents() self.update_margins_indents()
# Style # Style
@ -272,11 +274,10 @@ class MarkupHandler:
def get_margin_indent_tag(self, margin_level, indent_level): def get_margin_indent_tag(self, margin_level, indent_level):
level = (margin_level, indent_level) level = (margin_level, indent_level)
if level not in self.margins_indents: if level not in self.margins_indents:
tag = self.text_buffer.create_tag(
"margin_indent_" + str(margin_level) + "_" + str(indent_level))
margin, indent = self.get_margin_indent(margin_level, indent_level) margin, indent = self.get_margin_indent(margin_level, indent_level)
tag.set_property("left-margin", margin) tag = self.text_buffer.create_tag(
tag.set_property("indent", indent) "margin_indent_{}_{}".format(margin_level, indent_level),
left_margin=margin, indent=indent)
self.margins_indents[level] = tag self.margins_indents[level] = tag
return tag return tag
else: else:
@ -284,7 +285,7 @@ class MarkupHandler:
def get_margin_indent(self, margin_level, indent_level, baseline_margin=None, char_width=None): def get_margin_indent(self, margin_level, indent_level, baseline_margin=None, char_width=None):
if baseline_margin is None: if baseline_margin is None:
baseline_margin = self.text_view.get_left_margin() baseline_margin = self.text_view.props.left_margin
if char_width is None: if char_width is None:
char_width = helpers.get_char_width(self.text_view) char_width = helpers.get_char_width(self.text_view)
margin = max(baseline_margin + char_width * margin_level, 0) margin = max(baseline_margin + char_width * margin_level, 0)
@ -292,16 +293,21 @@ class MarkupHandler:
return margin, indent return margin, indent
def update_margins_indents(self): def update_margins_indents(self):
baseline_margin = self.text_view.get_left_margin() baseline_margin = self.text_view.props.left_margin
char_width = helpers.get_char_width(self.text_view) char_width = helpers.get_char_width(self.text_view)
# Adjust tab size, as character width can change # Bail out if neither the baseline margin nor character width change
if baseline_margin == self.baseline_margin and char_width == self.char_width:
return
self.baseline_margin = baseline_margin
self.char_width = char_width
# Adjust tab size
tab_array = Pango.TabArray.new(1, True) tab_array = Pango.TabArray.new(1, True)
tab_array.set_tab(0, Pango.TabAlign.LEFT, 4 * char_width) tab_array.set_tab(0, Pango.TabAlign.LEFT, 4 * char_width)
self.text_view.set_tabs(tab_array) self.text_view.set_tabs(tab_array)
# Adjust margins and indents, as character width can change # Adjust margins and indents
for level, tag in self.margins_indents.items(): for level, tag in self.margins_indents.items():
margin, indent = self.get_margin_indent(*level, baseline_margin, char_width) margin, indent = self.get_margin_indent(*level, baseline_margin, char_width)
tag.set_property("left-margin", margin) tag.set_properties(left_margin=margin, indent=indent)
tag.set_property("indent", indent)

View File

@ -0,0 +1,106 @@
class TextViewScroller:
def __init__(self, text_view, scrolled_window):
super().__init__()
self.text_view = text_view
self.scrolled_window = scrolled_window
self.smooth_scroller = None
def get_scroll_scale(self):
vap = self.scrolled_window.get_vadjustment().props
if vap.upper > vap.page_size:
return vap.value / (vap.upper - vap.page_size)
else:
return 0
def set_scroll_scale(self, scale):
vap = self.scrolled_window.get_vadjustment().props
vap.value = (vap.upper - vap.page_size) * scale
def scroll_to_mark(self, mark, center):
"""Scrolls until mark is visible, if needed."""
target_pos = self.get_target_pos_for_mark(mark, center)
if target_pos:
self.scrolled_window.get_vadjustment().set_value(target_pos)
def smooth_scroll_to_mark(self, mark, center):
"""Smoothly scrolls until mark is visible, if needed."""
if self.smooth_scroller and self.smooth_scroller.is_started:
self.smooth_scroller.end()
target_pos = self.get_target_pos_for_mark(mark, center)
if target_pos:
source_pos = self.scrolled_window.get_vadjustment().props.value
self.smooth_scroller = SmoothScroller(self.scrolled_window, source_pos, target_pos)
self.smooth_scroller.start()
def get_target_pos_for_mark(self, mark, center):
margin = 32
mark_iter = self.text_view.get_buffer().get_iter_at_mark(mark)
mark_rect = self.text_view.get_iter_location(mark_iter)
vap = self.scrolled_window.get_vadjustment().props
pos_y = mark_rect.y + mark_rect.height + self.text_view.props.top_margin
pos_viewport_y = pos_y - vap.value
target_pos = None
if center:
if pos_viewport_y != vap.page_size / 2:
target_pos = pos_y - (vap.page_size / 2)
elif pos_viewport_y > vap.page_size - margin:
target_pos = pos_y - vap.page_size + margin
elif pos_viewport_y < margin:
target_pos = pos_y - margin - mark_rect.height
return target_pos
class SmoothScroller:
def __init__(self, scrolled_window, source_pos, target_pos):
super().__init__()
self.scrolled_window = scrolled_window
self.source_pos = source_pos
self.target_pos = target_pos
self.duration = max(100, (target_pos - source_pos) / 50) * 1000
self.is_started = False
self.is_setup = False
self.start_time = 0
self.end_time = 0
self.tick_callback_id = 0
def start(self):
self.is_started = True
self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
def end(self):
self.scrolled_window.remove_tick_callback(self.tick_callback_id)
self.is_started = False
def setup(self, time):
self.start_time = time
self.end_time = time + self.duration
self.is_setup = True
def on_tick(self, widget, frame_clock):
def ease_out_cubic(value):
return pow(value - 1, 3) + 1
now = frame_clock.get_frame_time()
if not self.is_setup:
self.setup(now)
if now < self.end_time:
time = float(now - self.start_time) / float(self.end_time - self.start_time)
else:
time = 1
self.end()
time = ease_out_cubic(time)
pos = self.source_pos + (time * (self.target_pos - self.source_pos))
widget.get_vadjustment().props.value = pos
return True

View File

@ -31,8 +31,8 @@ class Theme:
@classmethod @classmethod
def get_current_changed(cls): def get_current_changed(cls):
theme_name = Gtk.Settings.get_default().get_property('gtk-theme-name') theme_name = Gtk.Settings.get_default().get_property('gtk-theme-name')
dark_mode_auto = cls.settings.get_value('dark-mode-auto').get_boolean() dark_mode_auto = cls.settings.get_boolean('dark-mode-auto')
dark_mode = cls.settings.get_value('dark-mode').get_boolean() dark_mode = cls.settings.get_boolean('dark-mode')
current_theme = cls.get_for_name(theme_name) current_theme = cls.get_for_name(theme_name)
if not dark_mode_auto and dark_mode != current_theme.is_dark and current_theme.inverse_name: if not dark_mode_auto and dark_mode != current_theme.is_dark and current_theme.inverse_name:
current_theme = cls.get_for_name(current_theme.inverse_name, current_theme.name) current_theme = cls.get_for_name(current_theme.inverse_name, current_theme.name)

View File

@ -0,0 +1,115 @@
import gi
gi.require_version('WebKit2', '4.0')
from gi.repository import WebKit2, GLib, GObject
class WebView(WebKit2.WebView):
"""A WebView that provides read/write access to scroll.
It does so using JavaScript, by continuously monitoring it while loaded.
The alternative is using a WebExtension and C-bindings (see reference), but that is more
complicated implementation-wise, as well as build-wise until we start building with Meson.
Reference: https://github.com/aperezdc/webkit2gtk-python-webextension-example
"""
GET_SCROLL_SCALE_JS = """
e = document.documentElement;
e.scrollHeight > e.clientHeight ? e.scrollTop / (e.scrollHeight - e.clientHeight) : -1;
"""
SET_SCROLL_SCALE_JS = """
scale = {:.16f};
e = document.documentElement;
e.scrollTop = (e.scrollHeight - e.clientHeight) * scale;
"""
__gsignals__ = {
"scroll-scale-changed": (GObject.SIGNAL_RUN_LAST, None, (float,)),
}
def __init__(self):
super().__init__()
self.connect("load-changed", self.on_load_changed)
self.connect("load-failed", self.on_load_failed)
self.connect("size-allocate", self.on_size_allocate)
self.connect("destroy", self.on_destroy)
self.scroll_scale = 0.0
self.pending_scroll_scale = None
self.state_loaded = False
self.state_load_failed = False
self.state_discard_result = False
self.state_waiting = False
self.timeout_id = None
def get_scroll_scale(self):
return self.scroll_scale
def set_scroll_scale(self, scale):
self.pending_scroll_scale = scale
self.state_loop()
def on_load_changed(self, _web_view, event):
self.state_loaded = event >= WebKit2.LoadEvent.COMMITTED and not self.state_load_failed
self.state_load_failed = False
self.state_discard_result = event == WebKit2.LoadEvent.STARTED and self.state_waiting
self.pending_scroll_scale = self.scroll_scale
self.state_loop()
def on_load_failed(self, _web_view, _event):
self.state_loaded = False
self.state_load_failed = True
self.state_loop()
def on_size_allocate(self, *_):
self.set_scroll_scale(self.scroll_scale)
def on_destroy(self, _widget):
self.state_loaded = False
self.state_loop()
def read_scroll_scale(self):
self.state_waiting = True
self.run_javascript(
self.GET_SCROLL_SCALE_JS, None, self.finish_read_scroll_scale)
def write_scroll_scale(self, scroll_scale):
self.run_javascript(
self.SET_SCROLL_SCALE_JS.format(scroll_scale), None, None)
def finish_read_scroll_scale(self, _web_view, result):
self.state_waiting = False
if not self.state_discard_result:
result = self.run_javascript_finish(result)
self.state_loop(result.get_js_value().to_double())
else:
self.state_discard_result = False
self.state_loop()
def state_loop(self, scroll_scale=None, delay=16): # 16ms ~ 60hz
# Remove any pending callbacks
if self.timeout_id:
GLib.source_remove(self.timeout_id)
self.timeout_id = None
# Set scroll scale if specified, and the state is not dirty
if scroll_scale not in (None, -1, self.scroll_scale):
self.scroll_scale = scroll_scale
self.emit("scroll-scale-changed", self.scroll_scale)
# Handle the current state
if not self.state_loaded or self.state_load_failed or self.state_waiting:
return
if self.pending_scroll_scale:
self.write_scroll_scale(self.pending_scroll_scale)
self.pending_scroll_scale = None
self.read_scroll_scale()
elif delay > 0:
self.timeout_id = GLib.timeout_add(delay, self.state_loop, None, 0)
else:
self.read_scroll_scale()