├── debian ├── compat ├── hazama.docs ├── copyright ├── readme.md ├── changelog ├── rules └── control ├── res ├── check.png ├── clock.png ├── lock.png ├── tag.png ├── search.png ├── appicon-24.png ├── appicon-48.png ├── calendar.png ├── check-big.png ├── menu │ ├── bold.png │ ├── italic.png │ ├── random.png │ ├── bold-mega.png │ ├── highlight.png │ ├── random-big.png │ ├── strikeout.png │ ├── underline.png │ ├── italic-mega.png │ ├── list-delete.png │ ├── highlight-mega.png │ ├── list-delete-big.png │ ├── strikeout-mega.png │ └── underline-mega.png ├── search-big.png ├── search-clr.png ├── toolbar │ ├── new.png │ ├── sort.png │ ├── config.png │ ├── delete.png │ ├── heatmap.png │ ├── new-big.png │ ├── new-mega.png │ ├── sort-big.png │ ├── sort-mega.png │ ├── tag-list.png │ ├── config-big.png │ ├── config-mega.png │ ├── delete-big.png │ ├── delete-mega.png │ ├── heatmap-big.png │ ├── heatmap-mega.png │ ├── tag-list-big.png │ ├── update-mark.png │ ├── tag-list-mega.png │ ├── heatmap.svg │ ├── sort.svg │ ├── new.svg │ ├── delete.svg │ └── tag-list.svg ├── appicon │ ├── appicon.ico │ ├── appicon-16.svg │ ├── appicon-48.svg │ └── appicon-64.svg ├── search-clr-big.png ├── heatmap │ ├── arrow-left.png │ ├── arrow-right.png │ ├── arrow-left-big.png │ └── arrow-right-big.png ├── colorful-white.qss ├── colorful-yellow.qss ├── readme.md ├── default.qss ├── res.qrc └── colorful.qss ├── utils ├── mactype.ini ├── windows-taskbar-icon-fix.reg ├── tests.py ├── test_pyside_mem_leak.py ├── setupfreeze.py ├── hazama.exe.manifest ├── python_dpi_unaware.exe.manifest └── Microsoft.VC140.CRT.manifest ├── hazama.py ├── .gitignore ├── hazama ├── util.py ├── mactype.py ├── __init__.py ├── config.py ├── ui │ ├── editor.ui │ ├── diarymodel.py │ ├── editor.py │ ├── taglist.py │ ├── heatmap.py │ ├── mainwindow.ui │ ├── __init__.py │ └── customwidgets.py ├── updater.py └── diarybook.py ├── translation └── lupdateguide ├── .travis.yml ├── README.md └── setup.py /debian/compat: -------------------------------------------------------------------------------- 1 | 7 2 | -------------------------------------------------------------------------------- /debian/hazama.docs: -------------------------------------------------------------------------------- 1 | README.md -------------------------------------------------------------------------------- /res/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/check.png -------------------------------------------------------------------------------- /res/clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/clock.png -------------------------------------------------------------------------------- /res/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/lock.png -------------------------------------------------------------------------------- /res/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/tag.png -------------------------------------------------------------------------------- /res/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/search.png -------------------------------------------------------------------------------- /res/appicon-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/appicon-24.png -------------------------------------------------------------------------------- /res/appicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/appicon-48.png -------------------------------------------------------------------------------- /res/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/calendar.png -------------------------------------------------------------------------------- /res/check-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/check-big.png -------------------------------------------------------------------------------- /res/menu/bold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/bold.png -------------------------------------------------------------------------------- /res/search-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/search-big.png -------------------------------------------------------------------------------- /res/search-clr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/search-clr.png -------------------------------------------------------------------------------- /utils/mactype.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/utils/mactype.ini -------------------------------------------------------------------------------- /res/menu/italic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/italic.png -------------------------------------------------------------------------------- /res/menu/random.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/random.png -------------------------------------------------------------------------------- /res/toolbar/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/new.png -------------------------------------------------------------------------------- /res/toolbar/sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/sort.png -------------------------------------------------------------------------------- /res/appicon/appicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/appicon/appicon.ico -------------------------------------------------------------------------------- /res/menu/bold-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/bold-mega.png -------------------------------------------------------------------------------- /res/menu/highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/highlight.png -------------------------------------------------------------------------------- /res/menu/random-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/random-big.png -------------------------------------------------------------------------------- /res/menu/strikeout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/strikeout.png -------------------------------------------------------------------------------- /res/menu/underline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/underline.png -------------------------------------------------------------------------------- /res/search-clr-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/search-clr-big.png -------------------------------------------------------------------------------- /res/toolbar/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/config.png -------------------------------------------------------------------------------- /res/toolbar/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/delete.png -------------------------------------------------------------------------------- /res/toolbar/heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/heatmap.png -------------------------------------------------------------------------------- /res/toolbar/new-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/new-big.png -------------------------------------------------------------------------------- /res/menu/italic-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/italic-mega.png -------------------------------------------------------------------------------- /res/menu/list-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/list-delete.png -------------------------------------------------------------------------------- /res/toolbar/new-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/new-mega.png -------------------------------------------------------------------------------- /res/toolbar/sort-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/sort-big.png -------------------------------------------------------------------------------- /res/toolbar/sort-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/sort-mega.png -------------------------------------------------------------------------------- /res/toolbar/tag-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/tag-list.png -------------------------------------------------------------------------------- /res/heatmap/arrow-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/heatmap/arrow-left.png -------------------------------------------------------------------------------- /res/heatmap/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/heatmap/arrow-right.png -------------------------------------------------------------------------------- /res/menu/highlight-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/highlight-mega.png -------------------------------------------------------------------------------- /res/menu/list-delete-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/list-delete-big.png -------------------------------------------------------------------------------- /res/menu/strikeout-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/strikeout-mega.png -------------------------------------------------------------------------------- /res/menu/underline-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/menu/underline-mega.png -------------------------------------------------------------------------------- /res/toolbar/config-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/config-big.png -------------------------------------------------------------------------------- /res/toolbar/config-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/config-mega.png -------------------------------------------------------------------------------- /res/toolbar/delete-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/delete-big.png -------------------------------------------------------------------------------- /res/toolbar/delete-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/delete-mega.png -------------------------------------------------------------------------------- /res/toolbar/heatmap-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/heatmap-big.png -------------------------------------------------------------------------------- /res/toolbar/heatmap-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/heatmap-mega.png -------------------------------------------------------------------------------- /res/toolbar/tag-list-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/tag-list-big.png -------------------------------------------------------------------------------- /res/toolbar/update-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/update-mark.png -------------------------------------------------------------------------------- /res/heatmap/arrow-left-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/heatmap/arrow-left-big.png -------------------------------------------------------------------------------- /res/toolbar/tag-list-mega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/toolbar/tag-list-mega.png -------------------------------------------------------------------------------- /res/heatmap/arrow-right-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/res/heatmap/arrow-right-big.png -------------------------------------------------------------------------------- /utils/windows-taskbar-icon-fix.reg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krrr/Hazama/HEAD/utils/windows-taskbar-icon-fix.reg -------------------------------------------------------------------------------- /hazama.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # used to run without installing; embedded into Windows EXE 3 | import sys 4 | from hazama import main_entry 5 | 6 | sys.exit(main_entry()) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.qm 3 | hazama/nikkichou.db 4 | hazama/custom.qss 5 | hazama/config.ini 6 | build 7 | .idea 8 | Hazama.iml 9 | hazama/test.py 10 | hazama/ui/*_ui.py 11 | hazama/ui/res_rc.py 12 | -------------------------------------------------------------------------------- /res/colorful-white.qss: -------------------------------------------------------------------------------- 1 | Editor { background: #F0F0F0 } 2 | 3 | QFrame#DiaryListItem { 4 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 5 | stop:0 #F8F8F8, 6 | stop:1 #EDEDED); 7 | } 8 | -------------------------------------------------------------------------------- /res/colorful-yellow.qss: -------------------------------------------------------------------------------- 1 | QWidget#editor { background: #EEEBD0 } 2 | 3 | QFrame#DiaryListItem { 4 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 5 | stop:0 #F2F0DE, 6 | stop:1 #EBE8C9); 7 | } 8 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: Hazama 3 | Source: https://github.com/krrr/Hazama 4 | 5 | Files: * 6 | Copyright: 2017 Yuu Mousou (krrr) 7 | License: GPL-2+ 8 | see /usr/share/common-licenses/GPL-2 -------------------------------------------------------------------------------- /debian/readme.md: -------------------------------------------------------------------------------- 1 | Debian packaging related files 2 | ================================= 3 | Tutorial: http://ghantoos.org/2008/10/19/creating-a-deb-package-from-a-python-setuppy/ 4 | 5 | edit changelog file and run `debuild` 6 | 7 | TravisCI auto build: `git tag xxx && push --tags` after pushed the final commit -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | hazama (1.0.3) trusty; urgency=medium 2 | 3 | * 1.0.3 4 | 5 | -- Yuu Mousou (krrr) Sat, 04 Mar 2017 18:20:04 +0800 6 | 7 | 8 | hazama (1.0.1) trusty; urgency=medium 9 | 10 | * Init deb package 11 | 12 | -- Yuu Mousou (krrr) Sat, 04 Feb 2017 12:20:04 +0800 13 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ --with python3 --buildsystem=pybuild 5 | 6 | 7 | override_dh_auto_build: 8 | echo "Skip build because install will call build" 9 | 10 | 11 | override_dh_auto_install: 12 | python3 setup.py install --root=$(CURDIR)/debian/hazama --install-layout=deb 13 | find $(CURDIR)/debian/hazama -name "__pycache__" | xargs rm -rf -------------------------------------------------------------------------------- /hazama/util.py: -------------------------------------------------------------------------------- 1 | """Common util codes.""" 2 | from math import fabs, floor, copysign 3 | from collections import OrderedDict 4 | 5 | 6 | def my_fround(x): 7 | """Similar to built-in round, but numbers like 1.5 8 | will be rounded to smaller one (1.0).""" 9 | x = float(x) 10 | absx = fabs(x) 11 | y = floor(x) 12 | if absx - y > 0.5: 13 | y += 1.0 14 | return copysign(y, x) 15 | -------------------------------------------------------------------------------- /translation/lupdateguide: -------------------------------------------------------------------------------- 1 | # This is a Qt project file only used by pyside-lupdate 2 | 3 | SOURCES = ../hazama/ui/__init__.py \ 4 | ../hazama/ui/customwidgets.py \ 5 | ../hazama/ui/customobjects.py \ 6 | ../hazama/ui/configdialog.py \ 7 | ../hazama/ui/configdialog_ui.py \ 8 | ../hazama/ui/editor.py \ 9 | ../hazama/ui/editor_ui.py \ 10 | ../hazama/ui/mainwindow.py \ 11 | ../hazama/ui/mainwindow_ui.py \ 12 | ../hazama/ui/taglist.py \ 13 | ../hazama/ui/diarylist.py 14 | TRANSLATIONS = zh_CN.ts \ 15 | ja.ts 16 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: hazama 2 | Section: utils 3 | Maintainer: Yuu Mousou (krrr) 4 | Priority: optional 5 | Build-Depends: python3-setuptools, python3, debhelper (>= 9), python3-pyside, pyside-tools, qt4-linguist-tools 6 | Standards-Version: 3.9.5 7 | X-Python3-Version: >= 3.3 8 | 9 | 10 | 11 | Package: hazama 12 | Homepage: https://krrr.github.io/hazama 13 | Architecture: all 14 | Depends: ${misc:Depends}, ${python3:Depends}, python3-pyside 15 | Description: simple cross-platform diary application 16 | Hazama is a GUI application for keeping diary, simple and highly customizable. 17 | It's developed in Python3 with PySide. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false # faster 3 | dist: trusty 4 | python: 5 | - "3.5" 6 | 7 | branches: 8 | except: 9 | - dev 10 | 11 | # install dependencies 12 | addons: 13 | apt: 14 | packages: 15 | - python3-pyside 16 | - python3-setuptools 17 | - pyside-tools 18 | - qt4-linguist-tools 19 | - dpkg-dev 20 | - debhelper 21 | - devscripts 22 | - fakeroot 23 | - cdbs 24 | 25 | install: true 26 | 27 | script: 28 | - debuild -uc -us # it will call setup.py build 29 | - /usr/bin/python3 utils/tests.py # system python is also 3.5 30 | 31 | deploy: 32 | provider: releases 33 | api_key: ${github_api_token} 34 | file: "../hazama*.deb" 35 | file_glob: true 36 | skip_cleanup: true 37 | on: 38 | tags: true 39 | -------------------------------------------------------------------------------- /utils/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | sys.path.append(os.getcwd()) 5 | from hazama.ui.customobjects import NTextDocument 6 | 7 | 8 | class NTextDocumentFormatsTest(unittest.TestCase): 9 | def test_overlap(self): 10 | test_str = 'This is something, string, string.\nparagraph, paragraph!\n method?' 11 | test_fmt = [(0, 2, 1), (0, 2, 2), (0, 10, 3), (5, 15, 4), (34, 3, 5)] 12 | # output formats may be duplicated, because Qt store format this way 13 | true_result = [ 14 | (0, 2, 1), (0, 2, 2), 15 | (0, 2, 3), (2, 3, 3), (5, 5, 3), # from (0, 10, 3), broken into three parts 16 | (5, 5, 4), (10, 10, 4), # from (5, 15, 4) 17 | (35, 2, 5) # 34 is \n 18 | ] 19 | doc = NTextDocument() 20 | doc.setText(test_str, test_fmt) 21 | result = NTextDocument.getFormats(doc) 22 | self.assertEqual(true_result, result) 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() -------------------------------------------------------------------------------- /utils/test_pyside_mem_leak.py: -------------------------------------------------------------------------------- 1 | """Do not pass parent to some object that isn't sub-class of QWidget 2 | when not necessary. 3 | 4 | Detail: Some widget never release its non widget child, such as 5 | ItemDelegate (with parent set to the view) set by QListView.setItemDelegate 6 | and replaced by other delegate later. 7 | Call deleteLater manually will solve this partially. 8 | 9 | PyQt4 has the same problem. But it use less memory than PySide. (Tested on 10 | Windows, 5MB lesser)""" 11 | from PySide.QtCore import * 12 | from PySide.QtGui import * 13 | 14 | 15 | class DummyDelegate(QAbstractItemDelegate): 16 | def paint(self, *args, **kwargs): 17 | pass 18 | 19 | def sizeHint(self, *args, **kwargs): 20 | return QSize() 21 | 22 | 23 | app = QApplication(['a']) 24 | list = QListView() 25 | for _ in range(100000): 26 | d = DummyDelegate() # here, with list it takes 100+MB, and 16MB if pass None 27 | list.setItemDelegate(d) 28 | list.setItemDelegate(None) 29 | del d 30 | 31 | 32 | print('app_exec') 33 | app.exec_() 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hazama 2 | ====== 3 | Hazama is a GUI application for keeping diary, simple and highly customizable. It's developed in Python3 with PySide. 4 | 5 | [Project Site](https://krrr.github.io/hazama) | [Help](https://github.com/krrr/Hazama/wiki) | [Downloads](https://github.com/krrr/Hazama/releases) 6 | 7 | Build & Install 8 | --- 9 | * Run: `./setup.py build_qt && ./hazama.py` 10 | * Install: `./setup.py build_qt && ./setup.py install && hazama` 11 | * Build EXE: `./setup.py build_qt && setup.py build_exe && build\hazama.exe` 12 | 13 | Requirements (Arch Linux ver; AUR also available): python3, python-pyside, python-pyside-tools 14 | 15 | Requirements (Ubuntu ver): python3, python3-pyside, pyside-tools, qt4-linguist-tools 16 | 17 | _EXE packaging requires cx_Freeze_ 18 | 19 | Wheels for PySide/PyQt 20 | --- 21 | see `hazama/ui/customobjects.py` and `hazama/ui/customwidgets.py` 22 | * `QSSHighlighter`: simple QSS syntax highlighter 23 | * `DragScrollMixin`: handle Drag & Scroll 24 | * `MultiSortFilterProxyModel` 25 | * `DateTimeDialog` 26 | * `HeatMap`: a heat map that looks like a calendar 27 | -------------------------------------------------------------------------------- /hazama/mactype.py: -------------------------------------------------------------------------------- 1 | """Qt4's FreeType plugin is unusable on Windows, so use this hack. MacType will hook 2 | Windows text rendering functions and use FreeType to render them.""" 3 | import ctypes 4 | from hazama import config 5 | from os import path 6 | 7 | 8 | dllPath = path.join(config.appPath, r'lib\MacType.dll') 9 | configPath = path.join(config.appPath, r'lib\mactype.ini') 10 | _handle = _dll = None 11 | 12 | 13 | def isUsable(): 14 | return config.isWin and path.isfile(dllPath) 15 | 16 | 17 | def enable(): 18 | global _handle, _dll 19 | if not isUsable(): 20 | return False 21 | # it will hook windows rendering functions on loading 22 | _handle = ctypes.windll.kernel32.LoadLibraryW(dllPath) 23 | if _handle == 0: 24 | return False 25 | _dll = ctypes.WinDLL(None, handle=_handle) 26 | return True 27 | 28 | 29 | def isEnabled(): 30 | return bool(_dll) 31 | 32 | 33 | def fromConfig(s): 34 | # Qt will cache many glyphs, I don't know how to clear the cache. 35 | # So this function is actually useless 36 | try: 37 | _dll.ReloadConfigStr(s) 38 | except AttributeError: # this function is unofficial 39 | pass 40 | 41 | 42 | def disable(): 43 | global _handle, _dll 44 | if _dll is None: 45 | return 46 | ctypes.windll.kernel32.FreeLibrary(_handle) 47 | _handle = _dll = None 48 | -------------------------------------------------------------------------------- /utils/setupfreeze.py: -------------------------------------------------------------------------------- 1 | """Only used to generate frozen binary because of limitation of cx_Freeze. 2 | 5.0 bugs: 3 | 1. sqlite3.dll not copied 4 | 2. will search dlls in lib directory (undocumented) so custom initScript is unnecessary 5 | 3. include many unused modules (specify them in options to reduce size ~0.7MB) 6 | """ 7 | import sys 8 | import os 9 | import cx_Freeze 10 | from os import path 11 | from glob import glob 12 | from cx_Freeze import setup, Executable 13 | 14 | if not cx_Freeze.version.startswith('5'): 15 | print('cx_Freeze 5.0 or higher required') 16 | sys.exit(-1) 17 | 18 | 19 | sys.path[0] = os.getcwd() # this script will be called by ../setup.py 20 | import hazama 21 | 22 | main_exe = Executable('hazama.py', 23 | base='Win32GUI', 24 | icon='res/appicon/appicon.ico') 25 | 26 | setup( 27 | name='Hazama', 28 | author=hazama.__author__, 29 | version=hazama.__version__, 30 | description='Hazama', 31 | options={'build_exe': { 32 | 'includes': ['PySide.QtCore', 'PySide.QtGui', 'hazama'], 33 | 'excludes': ['PySide.QtNetwork', 'PySide.QtXml', 34 | 'win32evtlogutil', 'win32evtlog', 'plistlib', 'pyreadline', 35 | 'pydoc', 'unittest', 'doctest', 'inspect'], 36 | 'zip_include_packages': ['*'], 37 | 'zip_exclude_packages': [], 38 | }}, 39 | executables=[main_exe]) 40 | -------------------------------------------------------------------------------- /utils/hazama.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /utils/python_dpi_unaware.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | false 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /res/readme.md: -------------------------------------------------------------------------------- 1 | ### Filename suffixes for HiDPI 2 | * -big means 1.5x size 3 | * -mega means 2x size 4 | 5 | ### Sources 6 | * clock.png, lock.png, format icons are from Keyamoon-IcoMoon 7 | * random.png, tag-list.png, sort.png, search-clr.png, 8 | * search.png, new.png, delete.png, config.png, arrow-left.png, arrow-right.png are from SweetiePlus 9 | * some icons are modified from SweetiePlus. 10 | 11 | 12 | ### SweetiePlus Readme 13 | ``` 14 | Author Joseph North 15 | Email north@sublink.ca 16 | Website http://sublink.ca/ 17 | Copyright (C) 2010 Joseph North. All rights reserved. 18 | 19 | A set of clean icons for use in web applications or program interfaces. 20 | 21 | New and updated icons can be found on the Sublink Icons page at http://sublink.ca/icons/ 22 | 23 | Feel free to email me with any comments, questions, or requests you might have. I'd love to hear what kind of uses my icons are serving. 24 | 25 | 26 | Sponsors 27 | -------- 28 | 29 | The fine folks over at SerNet sponsored 40 icons for their Verinice 30 | application. 31 | 32 | http://v.de/ 33 | 34 | 35 | Change Log 36 | ---------- 37 | 38 | 2010-12-02 Verinice icons added 39 | 2010-11-16 First Release 40 | 41 | 42 | Licence 43 | ------- 44 | 45 | Creative Commons Attribution-ShareAlike 3.0 Unported 46 | 47 | You are free: 48 | 49 | * to Share - to copy, distribute and transmit the work 50 | * to Remix - to adapt the work 51 | 52 | Under the following conditions: 53 | 54 | * Attribution. 55 | You must attribute the work in the manner specified by 56 | the author or licensor (but not in any way that suggests 57 | that they endorse you or your use of the work). 58 | 59 | * Share Alike. 60 | If you alter, transform, or build upon this work, you 61 | may distribute the resulting work only under the same, 62 | similar or a compatible license. 63 | 64 | * For any reuse or distribution, you must make clear to 65 | others the license terms of this work. 66 | 67 | * Any of the above conditions can be waived if you get 68 | permission from the copyright holder. 69 | 70 | Nothing in this license impairs or restricts the author's moral rights. 71 | 72 | Your fair use and other rights are in no way affected by the above. 73 | 74 | 75 | Attribution 76 | ----------- 77 | 78 | No link is required on your site or application. In projects that are 79 | redistributed, like templates and opensource software, please keep the 80 | readme.txt file with the icons. Lastly, don't claim the icons as your 81 | own work. 82 | ``` 83 | -------------------------------------------------------------------------------- /res/default.qss: -------------------------------------------------------------------------------- 1 | /**** ToolBar ****/ 2 | QMainWindow > QToolBar { 3 | background: #f2f1e7; 4 | border-bottom: 1dip solid #b53d00; 5 | padding: 1dip; 6 | spacing: 0px 7 | } 8 | QMainWindow > QToolBar[titleBarBgType="win"] { 9 | /* Windows 8 always have bright color */ 10 | background: transparent; 11 | /* Windows below 10 will add thin border to parts except toolbar (if system theme has this) */ 12 | border: none; 13 | } 14 | QMainWindow > QToolBar[titleBarBgType="win10"] { 15 | /* Windows 10 may have dark theme color */ 16 | background: rgba(255, 255, 255, 80); 17 | } 18 | QMainWindow > QToolBar[titleBarBgType="other"] { 19 | background: none; 20 | } 21 | 22 | QLabel#countLabel { color: #909090 } 23 | 24 | QMainWindow > QToolBar[extendTitleBar="true"] #countLabel { color: #444 } 25 | 26 | QPushButton#searchBoxBtn { border: 0px } 27 | /**** End ToolBar ****/ 28 | 29 | /**** Editor ****/ 30 | Editor { background: #e8f5ff } 31 | Editor > #textEditor { 32 | selection-color: black; 33 | selection-background-color: #e0f2ff; 34 | } 35 | Editor > #dtBtn { color: #333 } 36 | 37 | QFrame#bottomArea[bgType="win10"] { 38 | background: rgba(255, 255, 255, 80); 39 | } 40 | /**** End Editor ****/ 41 | 42 | /**** Lists ****/ 43 | DiaryList { 44 | background: #f2f1e7; 45 | border: 0px; 46 | padding-top: 1dip; 47 | } 48 | QScrollBar#diaryListSB { qproperty-annotateColor: #db8a60 } 49 | 50 | TagList { 51 | background: #eab68a; 52 | border: 0px; 53 | } 54 | QLineEdit#tagListEdit { border: none } 55 | 56 | QMainWindow QSplitter::handle { background: #b53d00 } 57 | /**** End Lists ****/ 58 | 59 | /**** HeatMap ****/ 60 | HeatMap { background: white } 61 | 62 | QFrame#heatMapBar { 63 | border-bottom: 1dip solid lightgray; 64 | padding: 0px 4dip 0px 4dip; 65 | background: gray; 66 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 67 | stop:0 #f2f1e7, 68 | stop:0.8 #f2f1e7, 69 | stop:1 rgb(233,231,214)); 70 | } 71 | 72 | QPushButton#heatMapBtn { 73 | font-size: 18pt; 74 | color: #777; 75 | border: none; 76 | } 77 | QPushButton#heatMapBtn:hover { color: #999 } 78 | 79 | HeatMap QToolButton { border: 1dip solid gray } 80 | HeatMap QToolButton:hover { background: rgba(0, 0, 0, 33) } 81 | HeatMap QToolButton:pressed { background: rgba(0, 0, 0, 55) } 82 | 83 | QGraphicsView#heatMapView { 84 | qproperty-cellBorderColor: darkgray; 85 | color: black; 86 | background: transparent; 87 | margin: 3dip; 88 | border: none; 89 | } 90 | 91 | QGraphicsView#heatMapSample { 92 | background: transparent; 93 | border: none; 94 | } 95 | /**** End HeatMap ****/ 96 | 97 | -------------------------------------------------------------------------------- /res/res.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | appicon-24.png 4 | appicon-48.png 5 | search.png 6 | search-big.png 7 | search-clr.png 8 | search-clr-big.png 9 | clock.png 10 | check.png 11 | check-big.png 12 | calendar.png 13 | tag.png 14 | lock.png 15 | 16 | default.qss 17 | colorful.qss 18 | colorful-yellow.qss 19 | colorful-white.qss 20 | 21 | 22 | 23 | toolbar/tag-list.png 24 | toolbar/tag-list-big.png 25 | toolbar/tag-list-mega.png 26 | toolbar/new.png 27 | toolbar/new-big.png 28 | toolbar/new-mega.png 29 | toolbar/delete.png 30 | toolbar/delete-big.png 31 | toolbar/delete-mega.png 32 | toolbar/sort.png 33 | toolbar/sort-big.png 34 | toolbar/sort-mega.png 35 | toolbar/heatmap.png 36 | toolbar/heatmap-big.png 37 | toolbar/heatmap-mega.png 38 | toolbar/config.png 39 | toolbar/config-big.png 40 | toolbar/config-mega.png 41 | toolbar/update-mark.png 42 | 43 | 44 | 45 | heatmap/arrow-left.png 46 | heatmap/arrow-left-big.png 47 | heatmap/arrow-right.png 48 | heatmap/arrow-right-big.png 49 | 50 | 51 | 52 | menu/list-delete.png 53 | menu/list-delete-big.png 54 | menu/random.png 55 | menu/random-big.png 56 | menu/highlight.png 57 | menu/highlight-mega.png 58 | menu/strikeout.png 59 | menu/strikeout-mega.png 60 | menu/underline.png 61 | menu/underline-mega.png 62 | menu/italic.png 63 | menu/italic-mega.png 64 | menu/bold.png 65 | menu/bold-mega.png 66 | 67 | 68 | 69 | ../build/lang/zh_CN.qm 70 | ../build/lang/qt_zh_CN.qm 71 | 72 | 73 | ../build/lang/ja.qm 74 | ../build/lang/qt_ja.qm 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /utils/Microsoft.VC140.CRT.manifest: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /hazama/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017 krrr 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | __version__ = '1.0.3' 13 | __desc__ = 'A simple cross-platform diary application' 14 | __author__ = 'krrr' 15 | 16 | 17 | # ---- Project Coding Guide ---- 18 | # 1. Don't use lambda as slot, it may cause segfault (obj destroyed but signal not disconnected). 19 | # 2. Class definition order: __init__, Qt's methods, methods, slots 20 | 21 | # ---- Notes ---- 22 | # 1. Only slots methods which is auto connected need Slot decorator 23 | # 2. StyleSheets using dynamic property require (un)polish to take effect 24 | # 3. Subclass of QWidget refuse to paint QSS background. Use QFrame or set WA_StyledBackground 25 | # 4. Inner class will break qt linguist! 26 | 27 | def main_entry(): 28 | import time 29 | start_time = time.clock() 30 | import logging 31 | import sys 32 | from hazama import config 33 | 34 | config.changeCWD() 35 | config.init() 36 | 37 | level = logging.DEBUG if config.settings['Main'].getboolean('debug') else logging.INFO 38 | logging.basicConfig(format='%(levelname)s: %(message)s', level=level) 39 | logging.info('Hazama v%s (%s, Py%d.%d.%d)', __version__, sys.platform, *sys.version_info[:3]) 40 | logging.info(str(config.db)) 41 | 42 | from hazama import ui, diarybook, updater 43 | app = ui.init() 44 | from hazama.ui.mainwindow import MainWindow 45 | 46 | w = MainWindow() 47 | w.show() 48 | logging.debug('startup took %.2f sec', time.clock()-start_time) 49 | 50 | if config.settings['Font'].getboolean('enhanceRender'): 51 | from hazama import mactype 52 | mactype.enable() 53 | 54 | if config.settings['Main'].getboolean('backup'): 55 | try: 56 | diarybook.backup() 57 | except OSError as e: 58 | from hazama.ui import showErrors 59 | showErrors('cantFile', str(e)) # message not correct here, ignore it... 60 | 61 | if config.settings['Update'].getboolean('needClean'): 62 | updater.cleanBackup() 63 | config.settings['Update']['needClean'] = str(False) 64 | 65 | app.aboutToQuit.connect(onAboutToQuit) 66 | ret = app.exec_() 67 | del w # force close all child window of MainWindow 68 | 69 | # segfault might happen if not wait for them 70 | for i in [updater.checkUpdateTask, updater.installUpdateTask]: 71 | if i is not None: 72 | logging.debug('waiting for %s to exit', i) 73 | i.wait() 74 | return ret 75 | 76 | 77 | def onAboutToQuit(): 78 | """Save settings before being terminated by OS (such as logging off on 79 | Windows).""" 80 | from hazama import config 81 | config.db.disconnect() 82 | config.saveSettings() 83 | -------------------------------------------------------------------------------- /res/colorful.qss: -------------------------------------------------------------------------------- 1 | /* This file contains base of theme colorful and "green" color scheme, and other 2 | color scheme QSS should override this. */ 3 | 4 | QMainWindow QSplitter::handle { background: #979A9B } 5 | TagList { 6 | padding-top: 2dip; 7 | background: #E7EFF5; 8 | } 9 | DiaryList { 10 | padding: 0px; 11 | background: #FAFAFA; 12 | } 13 | QScrollBar#diaryListSB { qproperty-annotateColor: #7092be } 14 | QLineEdit#tagListEdit { 15 | border: none; 16 | margin: 1dip; 17 | margin-right: 3dip; 18 | } 19 | 20 | SearchBox { background: #FAFAFA } 21 | 22 | /* ToolBar part */ 23 | QMainWindow > QToolBar { 24 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 25 | stop:0 #EBF2F7, 26 | stop:1 #E2EAF0); 27 | } 28 | QMainWindow > QToolBar { border-bottom: 1dip solid #979A9B } 29 | QLabel#countLabel {color: #616D78} 30 | 31 | /* Editor part */ 32 | Editor > #textEditor { /* use system selection-color */ 33 | selection-color: none; 34 | selection-background-color: none; 35 | } 36 | Editor { 37 | background: #E3EBC7; 38 | } 39 | Editor > #tagEditor, #titleEditor, #textEditor { 40 | background: rgba(255, 255, 255, 155); 41 | } 42 | Editor > #tagEditor:focus, #titleEditor:focus, #textEditor:focus { 43 | background: rgba(255, 255, 255, 200); 44 | } 45 | 46 | /* DiaryListItem part */ 47 | QFrame#DiaryListItem { 48 | padding: 2dip; 49 | border-bottom: 1dip solid #979A9B; 50 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 51 | stop:0 #EEF2DE, 52 | stop:1 #E3EBC7); 53 | } 54 | QFrame#DiaryListItem * { 55 | color: #3F474E; 56 | } 57 | QFrame#DiaryListItem[selected="true"][active="true"] { 58 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 59 | stop:0 #DDF1F1, 60 | stop:1 #C5E2E3); 61 | } 62 | QFrame#DiaryListItem[selected="true"][active="false"] { 63 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 64 | stop:0 #EBEBEB, 65 | stop:1 #D9D9D9); 66 | } 67 | QFrame#DiaryListItem > #DiaryListItemText { 68 | margin: 2dip 2dip 2dip 4dip; /* top, right, bottom, left */ 69 | } 70 | QFrame#DiaryListItem > QToolButton { /* icons */ 71 | margin: 0px; 72 | margin-right: 1dip; 73 | border: none; 74 | } 75 | 76 | /* TagListItem part */ 77 | QFrame#TagListItem { 78 | padding: 4dip 4dip 4dip 7dip; 79 | background: transparent; 80 | } 81 | QFrame#TagListItem * { 82 | color: #363D43; 83 | } 84 | QFrame#TagListItem[selected="true"] { 85 | border-left: 3dip solid #7092BE; 86 | border-top: 1dip solid #979A9B; 87 | border-bottom: 1dip solid #979A9B; 88 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 89 | stop:0 white, 90 | stop:1 transparent); 91 | } 92 | QLabel#TagListItemCount { 93 | background: rgba(0,0,0,22); 94 | border-radius: 4dip; 95 | border: 1px solid rgba(0,0,0,33); 96 | } 97 | 98 | /* HeatMap part */ 99 | HeatMap { 100 | background: #FAFAFA; 101 | } 102 | 103 | QFrame#heatMapBar { 104 | border-bottom: 1dip solid #979A9B; 105 | background: qlineargradient(x1:0, y1:0, x2:0, y2:1, 106 | stop:0 #EBF2F7, 107 | stop:1 #E2EAF0); 108 | } 109 | 110 | QPushButton#heatMapBtn { color: #616D78 } 111 | QPushButton#heatMapBtn:hover { color: #818F9C } 112 | 113 | HeatMap QToolButton { border: 1dip solid gray } 114 | HeatMap QToolButton:hover { background: rgba(255, 255, 255, 120) } 115 | HeatMap QToolButton:pressed { background: rgba(255, 255, 255, 180) } 116 | 117 | QGraphicsView#heatMapView { 118 | qproperty-cellBorderColor: #979A9B; 119 | qproperty-cellColor0: white; 120 | qproperty-cellColor1: #CCdff6; 121 | qproperty-cellColor2: #7092be; 122 | qproperty-cellColor3: #3c4e65; 123 | color: #3F474E; 124 | } 125 | -------------------------------------------------------------------------------- /hazama/config.py: -------------------------------------------------------------------------------- 1 | """Setup database & settings and share them between modules""" 2 | import sys 3 | import os 4 | from configparser import ConfigParser, ParsingError 5 | from os import path 6 | from hazama import diarybook 7 | 8 | 9 | # constants 10 | SOCKET_TIMEOUT = 8 11 | CUSTOM_STYLESHEET_DELIMIT = '/**** BEGIN CUSTOM STYLESHEET ****/' 12 | 13 | # for default settings 14 | isWin = hasattr(sys, 'getwindowsversion') 15 | winVer = sys.getwindowsversion() if isWin else None 16 | isWinVistaOrLater = isWin and winVer >= (6, 0) 17 | isWin7 = isWin and winVer[:2] == (6, 1) 18 | isWin7OrLater = isWin and winVer >= (6, 1) 19 | isWin8 = isWin and winVer[:2] == (6, 2) 20 | isWin8OrLater = isWin and winVer >= (6, 2) 21 | # isWin10OrLater requires manifest file on old Py versions (<= 3.4) 22 | isWin10 = isWin and winVer >= (10, 0) 23 | 24 | settings = ConfigParser() 25 | # set default values. some values have no defaults, such as windowGeo and tagListWidth 26 | settings.update({ 27 | 'Main': {'debug': False, 28 | 'backup': True, 29 | 'dbPath': 'nikkichou.db', 30 | 'tagListCount': True, 31 | 'previewLines': 4, 32 | 'listSortBy': 'datetime', 33 | 'listReverse': True, 34 | 'listAnnotated': True, 35 | 'tagListVisible': False, 36 | 'extendTitleBarBg': isWin8OrLater, # Win8 has no aero glass 37 | 'theme': 'colorful' if isWinVistaOrLater else '1px-rect'}, 38 | 'Editor': {'autoIndent': False, 39 | 'titleFocus': False, 40 | 'autoReadOnly': True, 41 | 'tabIndent': True}, 42 | 'Font': {'enhanceRender': False}, # for default fonts, see Font.load 43 | 'Update': {'autoCheck': False, 44 | 'newestIgnoredVer': '0.0.0', 45 | 'needClean': False}, 46 | 'ThemeColorful': {'colorScheme': 'green'} 47 | }) 48 | 49 | db = diarybook.DiaryBook() 50 | 51 | # set application path (used to load language file) 52 | if hasattr(sys, 'frozen'): 53 | appPath = path.dirname(sys.argv[0]) 54 | else: 55 | appPath = path.dirname(__file__) 56 | 57 | 58 | def changeCWD(): 59 | # user will not care about CWD because this is GUI application? 60 | if '-portable' in sys.argv or path.isfile(path.join(appPath, 'config.ini')): 61 | os.chdir(appPath) 62 | else: 63 | if sys.platform == 'win32': 64 | p = path.join(os.environ['APPDATA'], 'Hazama') 65 | else: 66 | cfg_path = path.join(os.environ['HOME'], '.config') 67 | if not path.isdir(cfg_path): os.mkdir(cfg_path) 68 | p = path.join(cfg_path, 'Hazama') 69 | if not path.isdir(p): os.mkdir(p) 70 | os.chdir(p) 71 | 72 | 73 | def saveSettings(): 74 | try: 75 | with open('config.ini', 'w', encoding='utf-8') as f: 76 | settings.write(f) 77 | except OSError: 78 | from hazama import ui 79 | ui.showErrors('cantFile', 'config.ini') 80 | 81 | 82 | def init(): 83 | """Load config.ini under CWD, initialize settings and diary book.""" 84 | try: 85 | # utf-8 with BOM will kill ConfigParser 86 | with open('config.ini', encoding='utf-8-sig') as f: 87 | settings.read_file(f) 88 | except ParsingError: 89 | from hazama import ui 90 | ui.showErrors('fileCorrupted', 'config.ini', exit_=True) 91 | except FileNotFoundError: 92 | pass 93 | 94 | try: 95 | db.connect(settings['Main']['dbPath']) 96 | except diarybook.DatabaseError as e: 97 | from hazama import ui 98 | if str(e).startswith('unable to open'): 99 | ui.showErrors('cantFile', settings['Main']['dbPath'], exit_=True) 100 | else: 101 | ui.showErrors('dbError', str(e), exit_=True) 102 | except diarybook.DatabaseLockedError: 103 | from hazama import ui 104 | ui.showErrors('dbLocked', exit_=True) 105 | -------------------------------------------------------------------------------- /res/appicon/appicon-16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 33 | 37 | 42 | 48 | 52 | 58 | 59 | 60 | 95 | 101 | 102 | 104 | 105 | 107 | image/svg+xml 108 | 110 | 111 | 112 | 113 | 114 | 120 | 123 | 129 | 135 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /res/appicon/appicon-48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 33 | 37 | 42 | 48 | 52 | 58 | 59 | 60 | 95 | 105 | 106 | 108 | 109 | 111 | image/svg+xml 112 | 114 | 115 | 116 | 117 | 118 | 124 | 127 | 133 | 139 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /hazama/ui/editor.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | editor 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | 15 | 0 16 | 17 | 18 | 0 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 0 31 | 32 | 33 | 0 34 | 35 | 36 | 4 37 | 38 | 39 | 0 40 | 41 | 42 | 0 43 | 44 | 45 | 46 | 47 | 0 48 | 49 | 50 | 20 51 | 52 | 53 | 20 54 | 55 | 56 | 57 | 58 | Tags separated by space 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 9 68 | 69 | 70 | 71 | 72 | Click to edit 73 | 74 | 75 | 76 | :/clock.png:/clock.png 77 | 78 | 79 | true 80 | 81 | 82 | 83 | 84 | 85 | 86 | PointingHandCursor 87 | 88 | 89 | Click to turn off read-only mode 90 | 91 | 92 | 93 | :/lock.png:/lock.png 94 | 95 | 96 | true 97 | 98 | 99 | 100 | 101 | 102 | 103 | Qt::Horizontal 104 | 105 | 106 | 107 | 40 108 | 20 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 0 118 | 0 119 | 120 | 121 | 122 | Qt::Horizontal 123 | 124 | 125 | QDialogButtonBox::Cancel|QDialogButtonBox::Save 126 | 127 | 128 | false 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | NTextEdit 142 | QTextEdit 143 |
hazama.ui.customwidgets
144 |
145 | 146 | NLineEditMouse 147 | QLineEdit 148 |
hazama.ui.customwidgets
149 |
150 |
151 | 152 | 153 | 154 | 155 | 156 | box 157 | accepted() 158 | editor 159 | close() 160 | 161 | 162 | 247 163 | 272 164 | 165 | 166 | 199 167 | 149 168 | 169 | 170 | 171 | 172 | box 173 | rejected() 174 | editor 175 | closeNoSave() 176 | 177 | 178 | 247 179 | 272 180 | 181 | 182 | 199 183 | 149 184 | 185 | 186 | 187 | 188 | 189 | closeNoSave() 190 | updateTagEditorFont() 191 | 192 |
193 | -------------------------------------------------------------------------------- /hazama/ui/diarymodel.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from PySide.QtCore import QAbstractTableModel, QModelIndex, Qt 4 | from PySide.QtGui import qApp 5 | from hazama.config import db, settings 6 | from hazama.diarybook import diary2dict, dict2diary 7 | 8 | 9 | class DiaryModel(QAbstractTableModel): 10 | """In memory copy of diary database. Specially optimized for loading from database. 11 | All attempts to reduce memory usage were failed, because setLayoutMode didn't work, 12 | and the view will still issue a huge amount of queries. 13 | Table structure: id | datetime | text | title | tags | formats | len(text) 14 | """ 15 | ROW_WIDTH = 7 16 | ID, DATETIME, TEXT, TITLE, TAGS, FORMATS, LENGTH = range(ROW_WIDTH) 17 | 18 | def __init__(self, parent=None): 19 | super().__init__(parent) 20 | self._lst = [] 21 | self._yearFirstsArgs = None 22 | self._yearFirsts = None 23 | 24 | def rowCount(self, parent=None): 25 | return len(self._lst) 26 | 27 | def columnCount(self, parent=None): 28 | return DiaryModel.ROW_WIDTH 29 | 30 | def data(self, index, role=Qt.DisplayRole): 31 | if role != Qt.DisplayRole: 32 | return 33 | d, col = self._lst[index.row()], index.column() 34 | if col == DiaryModel.LENGTH: 35 | return len(d[DiaryModel.TEXT]) 36 | else: 37 | return d[col] 38 | 39 | def setData(self, index, value, role=Qt.DisplayRole): 40 | self._lst[index.row()][index.column()] = value 41 | self.dataChanged.emit(index, index) 42 | return True 43 | 44 | def removeRows(self, row, count, parent=None): 45 | self.beginRemoveRows(QModelIndex(), row, row+count-1) 46 | del self._lst[row:row+count] 47 | self.endRemoveRows() 48 | return True 49 | 50 | def insertRows(self, row, count, parent=None): 51 | self.beginInsertRows(QModelIndex(), row, row+count-1) 52 | for i in range(row, row+count): 53 | self._lst.insert(i, list(db.EMPTY_DIARY)) 54 | self.endInsertRows() 55 | return True 56 | 57 | def saveDiary(self, dic): 58 | assert isinstance(dic, dict) 59 | realId = db.save(dic) 60 | # write to model 61 | diary = dict2diary(dic, as_list=True) 62 | if diary[self.ID] == -1: # new diary 63 | row = self.rowCount() 64 | self.insertRow(row) 65 | diary[self.ID] = realId 66 | else: 67 | row = self.getRowById(diary[self.ID]) 68 | if diary[self.TAGS] is None: # tags not changed 69 | diary[self.TAGS] = self._lst[row][self.TAGS] 70 | self._lst[row] = diary 71 | self.dataChanged.emit(self.index(row, 0), self.index(row, DiaryModel.ROW_WIDTH-1)) 72 | return row 73 | 74 | def loadFromDb(self): 75 | """Load diaries from database. It will repeatedly call qApp.processEvents 76 | while loading data, making UI still responsive if the amount of data 77 | is big. It also delay informing views to update, this avoid unnecessary 78 | layout operation.""" 79 | start_time = time.clock() 80 | sortBy = settings['Main']['listSortBy'] 81 | reverse = settings['Main'].getboolean('listReverse') 82 | self._yearFirstsArgs = (sortBy, reverse) 83 | iterator = db.sorted(sortBy, reverse) 84 | 85 | firstChunk = True 86 | rest = len(db) 87 | yearFirsts = {} 88 | yearBefore = None 89 | while rest > 0: # process COUNT items and inform the view in every iteration 90 | if firstChunk: 91 | firstChunk = False 92 | count = min(35, rest) 93 | else: 94 | count = min(300, rest) 95 | 96 | nextRow = len(self._lst) 97 | self.beginInsertRows(QModelIndex(), nextRow, nextRow+count-1) 98 | for i in range(count): 99 | d = list(next(iterator)) 100 | 101 | # save year firsts 102 | year = d[1][:4] 103 | if year != yearBefore and nextRow+i > 0: 104 | yearFirsts[int(yearBefore)] = nextRow+i-1 if reverse else nextRow+i 105 | yearBefore = year 106 | # end saving year firsts 107 | 108 | self._lst.append(d) 109 | if i % 15 == 0: 110 | qApp.processEvents() 111 | self.endInsertRows() 112 | rest -= count # count may become minus 113 | 114 | pairs = yearFirsts.items() if sortBy == 'datetime' else () 115 | self._yearFirsts = tuple(sorted(pairs, reverse=reverse)) 116 | logging.debug('loadFromDb took %.2f sec', time.clock()-start_time) 117 | 118 | def getYearFirsts(self): 119 | """Get (year: row) pairs. row is the row of the first diary of each year (excluding 120 | the year at last of the diary list). This is calculated in loadFromDb.""" 121 | # access model using QModelIndex is slow, and user rarely changes 122 | # sortBy, so calculate it once when loading 123 | sortBy = settings['Main']['listSortBy'] 124 | reverse = settings['Main'].getboolean('listReverse') 125 | return self._yearFirsts if (sortBy, reverse) == self._yearFirstsArgs else () 126 | 127 | def getRowById(self, id_): 128 | # user tends to modify newer diaries? 129 | for idx, d in enumerate(reversed(self._lst)): 130 | if d[DiaryModel.ID] == id_: 131 | return len(self._lst) - 1 - idx 132 | raise KeyError 133 | 134 | def getDiaryDictByRow(self, row): 135 | return diary2dict(self._lst[row]) 136 | 137 | def clear(self): 138 | self.beginRemoveRows(QModelIndex(), 0, self.rowCount()) 139 | self._lst.clear() 140 | self.endRemoveRows() 141 | 142 | def getAll(self): 143 | # using data() is slow, because it will create many index objects 144 | for i in self._lst: 145 | yield tuple(i) + (len(i[DiaryModel.TEXT]),) 146 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import shutil 5 | import hazama 6 | from os.path import join as pjoin 7 | from glob import glob 8 | from setuptools import setup 9 | from distutils.sysconfig import get_python_lib 10 | from distutils.core import Command 11 | from distutils.errors import DistutilsExecError 12 | from distutils.spawn import find_executable, spawn 13 | from distutils.command.build import build 14 | from setuptools.command.install import install 15 | from distutils.command.clean import clean 16 | 17 | 18 | class CustomBuild(build): 19 | sub_commands = [('build_qt', lambda self: True)] + build.sub_commands 20 | 21 | 22 | class CustomInstall(install): 23 | _desktop_template = """ 24 | [Desktop Entry] 25 | Version={ver} 26 | Type=Application 27 | Name=Hazama 28 | GenericName=Hazama 29 | Comment=Writing diary 30 | Comment[ja]=日記を書く 31 | Comment[zh_CN]=写日记 32 | Icon=hazama 33 | Exec=hazama 34 | NoDisplay=false 35 | Categories=Qt;Utility; 36 | StartupNotify=false 37 | Terminal=false 38 | """ 39 | 40 | def run(self): 41 | super().run() 42 | if sys.platform == 'win32': 43 | return 44 | 45 | entry_dir = pjoin(self.root, 'usr/share/applications/') 46 | svg_dir = pjoin(self.root, 'usr/share/icons/hicolor/scalable/apps/') 47 | png_dir = pjoin(self.root, 'usr/share/icons/hicolor/48x48/apps/') 48 | for i in (entry_dir, svg_dir, png_dir): 49 | os.makedirs(i, exist_ok=True) 50 | 51 | with open(entry_dir + 'Hazama.desktop', 'w') as f: 52 | f.write(self._desktop_template.strip().format(ver=hazama.__version__)) 53 | f.write('\n') 54 | 55 | shutil.copy('res/appicon/appicon-64.svg', svg_dir + 'hazama.svg') 56 | shutil.copy('res/appicon-48.png', png_dir + 'hazama.png') 57 | 58 | 59 | class BuildQt(Command): 60 | description = 'build Qt files(.ts .ui .rc)' 61 | user_options = [('ts', 't', 'compile ts files only'), 62 | ('ui', 'u', 'compile ui files only'), 63 | ('rc', 'r', 'compile rc files only')] 64 | 65 | def initialize_options(self): 66 | # noinspection PyAttributeOutsideInit 67 | self.ts = self.ui = self.rc = False 68 | self.force = False 69 | 70 | def finalize_options(self): pass 71 | 72 | def run(self): 73 | methods = ('ts', 'ui', 'rc') 74 | opts = tuple(filter(lambda x: getattr(self, x), methods)) 75 | if opts: 76 | self.force = True 77 | else: 78 | opts = methods # run all methods if no options passed 79 | 80 | for i in opts: 81 | getattr(self, 'compile_'+i)() 82 | 83 | def compile_ui(self): 84 | for src in glob(pjoin('hazama', 'ui', '*.ui')): 85 | dst = src.replace('.ui', '_ui.py') 86 | if self.force or (not os.path.isfile(dst) or 87 | os.path.getmtime(src) > os.path.getmtime(dst)): 88 | spawn(['pyside-uic', '--from-imports', '-o', dst, '-x', src]) 89 | 90 | @staticmethod 91 | def compile_rc(): 92 | spawn(['pyside-rcc', '-py3', pjoin('res', 'res.qrc'), '-o', 93 | pjoin('hazama', 'ui', 'res_rc.py')]) 94 | 95 | @staticmethod 96 | def compile_ts(): 97 | lang_dir = pjoin('build', 'lang') 98 | if not os.path.isdir(lang_dir): 99 | os.makedirs(lang_dir) 100 | 101 | lres = find_executable('lrelease-qt4') or find_executable('lrelease') 102 | if not lres: 103 | raise DistutilsExecError('lrelease not found') 104 | 105 | trans = [os.path.basename(i).split('.')[0] for i in 106 | glob(pjoin('translation', '*.ts'))] 107 | for i in trans: 108 | spawn([lres, pjoin('translation', i+'.ts'), '-qm', pjoin(lang_dir, i+'.qm')]) 109 | 110 | # copy corresponding Qt translations to build/lang 111 | if sys.platform != 'win32': 112 | # linux have complete qt library, so don't include; ignore warnings when compile_rc 113 | return 114 | pyside_dir = pjoin(get_python_lib(), 'PySide') 115 | for i in trans: 116 | target = pjoin(lang_dir, 'qt_%s.qm' % i) 117 | if not os.path.isfile(target): 118 | print('copy to ' + target) 119 | shutil.copy(pjoin(pyside_dir, 'translations', 'qt_%s.qm' % i), target) 120 | 121 | 122 | class UpdateTranslations(Command): 123 | description = 'Update translation files' 124 | user_options = [] 125 | 126 | def initialize_options(self): pass 127 | 128 | def finalize_options(self): pass 129 | 130 | def run(self): 131 | spawn(['pyside-lupdate', pjoin('translation', 'lupdateguide')]) 132 | 133 | 134 | class BuildExe(Command): 135 | description = 'Call cx_Freeze to build EXE' 136 | user_options = [] 137 | 138 | initialize_options = finalize_options = lambda self: None 139 | 140 | def run(self): 141 | spawn([sys.executable, pjoin('utils', 'setupfreeze.py'), 'build_exe']) 142 | # remove duplicate python DLL 143 | try: 144 | dll_path = glob(pjoin('build', 'python*.dll'))[0] 145 | os.remove(pjoin('build', 'lib', os.path.basename(dll_path))) 146 | except IndexError: 147 | pass 148 | 149 | 150 | class Clean(clean): 151 | def run(self): 152 | super().run() 153 | for i in glob(pjoin('hazama', 'ui', '*_ui.py')): 154 | print('remove file: ' + i) 155 | os.remove(i) 156 | for i in ['build', pjoin('hazama', 'ui', 'res_rc.py')]: 157 | if os.path.isfile(i): 158 | print('remove file: ' + i) 159 | os.remove(i) 160 | elif os.path.isdir('build'): 161 | print('remove dir: ' + i) 162 | shutil.rmtree('build') 163 | 164 | 165 | # fix env variables for PySide tools 166 | if sys.platform == 'win32': 167 | os.environ['PATH'] += (';' + pjoin(sys.exec_prefix, 'Scripts') + 168 | ';' + pjoin(sys.exec_prefix, 'lib', 'site-packages', 'PySide')) 169 | 170 | 171 | # PySide installed by linux package manager will not recognized by setuptools, so requires not added. 172 | setup(name='Hazama', 173 | author='krrr', 174 | author_email='guogaishiwo@gmail.com', 175 | version=hazama.__version__, 176 | description=hazama.__desc__, 177 | url='https://krrr.github.io/hazama', 178 | packages=['hazama', 'hazama.ui'], 179 | cmdclass={'build': CustomBuild, 'build_qt': BuildQt, 'install': CustomInstall, 180 | 'update_ts': UpdateTranslations, 'build_exe': BuildExe, 'clean': Clean}, 181 | zip_safe=True, 182 | extras_require = {'packaging': ['cx_freeze']}, 183 | entry_points={'gui_scripts': ['hazama = hazama:main_entry']}) 184 | -------------------------------------------------------------------------------- /res/toolbar/heatmap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 59 | 69 | 79 | 82 | 86 | 90 | 94 | 95 | 96 | 118 | 121 | 122 | 124 | 125 | 127 | image/svg+xml 128 | 130 | 131 | 132 | 133 | 134 | 139 | 146 | 153 | 160 | 167 | 174 | 181 | 188 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /res/toolbar/sort.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 52 | 56 | 60 | 61 | 64 | 68 | 72 | 73 | 76 | 80 | 84 | 85 | 88 | 92 | 96 | 97 | 107 | 117 | 127 | 137 | 147 | 157 | 158 | 178 | 181 | 182 | 184 | 185 | 187 | image/svg+xml 188 | 190 | 191 | 192 | 193 | 194 | 199 | 206 | 213 | 220 | 227 | 234 | 241 | 242 | 243 | -------------------------------------------------------------------------------- /res/toolbar/new.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 52 | 53 | 56 | 60 | 64 | 65 | 75 | 78 | 82 | 86 | 87 | 97 | 107 | 110 | 114 | 118 | 119 | 129 | 139 | 140 | 165 | 168 | 172 | 173 | 175 | 176 | 178 | image/svg+xml 179 | 181 | 182 | 183 | 184 | 185 | 190 | 196 | 203 | 210 | 211 | 216 | 221 | 228 | 235 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /hazama/ui/editor.py: -------------------------------------------------------------------------------- 1 | import PySide.QtCore 2 | from PySide.QtGui import * 3 | from PySide.QtCore import * 4 | from hazama.ui.editor_ui import Ui_editor 5 | from hazama.ui.customobjects import TagCompleter, NGraphicsDropShadowEffect 6 | from hazama.ui.customwidgets import DateTimeDialog 7 | from hazama.ui import (font, datetimeTrans, currentDatetime, fullDatetimeFmt, 8 | saveWidgetGeo, restoreWidgetGeo, datetimeToQt, DB_DATETIME_FMT_QT, 9 | winDwmExtendWindowFrame, scaleRatio) 10 | from hazama.config import settings, db, isWin, isWin10, isWin8, isWin7 11 | 12 | # TODO: editor in the main window (no tabs) 13 | 14 | 15 | class Editor(QFrame, Ui_editor): 16 | """The widget that used to edit diary's body, title, tag and datetime. 17 | Signal closed: (diaryId, needSave) 18 | """ 19 | closed = Signal(int, bool) 20 | 21 | def __init__(self, diaryDict, parent=None): 22 | super().__init__(parent) 23 | self._saveOnClose = True 24 | self.setupUi(self) 25 | self.readOnly = self.datetime = self.id = self.timeModified = self.tagModified = None 26 | restoreWidgetGeo(self, settings['Editor'].get('windowGeo')) 27 | 28 | self.titleEditor.setFont(font.title) 29 | self.titleEditor.returnPressed.connect( 30 | lambda: None if self.readOnly else self.textEditor.setFocus()) 31 | self.textEditor.setFont(font.text) 32 | self.textEditor.setAutoIndent(settings['Editor'].getboolean('autoIndent')) 33 | self.textEditor.setTabChangesFocus(not settings['Editor'].getboolean('tabIndent')) 34 | 35 | self.dtBtn.setFont(font.datetime) 36 | sz = max(font.datetime_m.ascent(), 12) 37 | self.dtBtn.setIconSize(QSize(sz, sz)) 38 | self.lockBtn.setIconSize(QSize(sz, sz)) 39 | self.lockBtn.clicked.connect(lambda: self.setReadOnly(False)) 40 | 41 | self.tagEditor.setTextMargins(QMargins(2, 0, 2, 0)) 42 | self.tagEditor.setCompleter(TagCompleter(list(db.get_tags()), self.tagEditor)) 43 | self.tagEditor.returnPressed.connect( 44 | lambda: None if self.readOnly else self.box.button(QDialogButtonBox.Save).setFocus()) 45 | 46 | if isWin10 and settings['Main'].getboolean('extendTitleBarBg'): 47 | self.bottomArea.setProperty('bgType', 'win10') 48 | 49 | # setup shortcuts 50 | # seems PySide has problem with QKeySequence.StandardKeys 51 | self.closeSaveSc = QShortcut(QKeySequence.Save, self, self.close) 52 | self.closeNoSaveSc = QShortcut(QKeySequence('Ctrl+W'), self, self.closeNoSave) 53 | self.quickCloseSc = QShortcut(QKeySequence('Esc'), self, self.closeNoSave) 54 | # Ctrl+Shift+Backtab doesn't work 55 | self.preSc = QShortcut(QKeySequence('Ctrl+Shift+Tab'), self) 56 | self.quickPreSc = QShortcut(QKeySequence('Left'), self) 57 | self.nextSc = QShortcut(QKeySequence('Ctrl+Tab'), self) 58 | self.quickNextSc = QShortcut(QKeySequence('Right'), self) 59 | 60 | self.fromDiaryDict(diaryDict) 61 | 62 | def showEvent(self, event): 63 | self._applyExtendTitleBarBg() 64 | if settings['Editor'].getboolean('titleFocus'): 65 | self.titleEditor.setCursorPosition(0) 66 | else: 67 | self.textEditor.setFocus() 68 | self.textEditor.moveCursor(QTextCursor.Start) 69 | 70 | # disable winEvent hack if PySide version doesn't support it 71 | if hasattr(PySide.QtCore, 'MSG') and hasattr(MSG, 'lParam'): 72 | def winEvent(self, msg): 73 | """See MainWindow.winEvent.""" 74 | if msg.message == 0x0084: 75 | pos = QPoint(msg.lParam & 0xFFFF, msg.lParam >> 16) 76 | widget = self.childAt(self.mapFromGlobal(pos)) 77 | if widget is self.bottomArea: 78 | return True, 2 79 | else: 80 | return False, 0 81 | else: 82 | return False, 0 83 | 84 | def closeEvent(self, event): 85 | """Normal close will save diary. For cancel operation, call closeNoSave.""" 86 | settings['Editor']['windowGeo'] = saveWidgetGeo(self) 87 | self.closed.emit(self.id, self.needSave() if self._saveOnClose else False) 88 | event.accept() 89 | 90 | def mousePressEvent(self, event): 91 | """Handle mouse back/forward button""" 92 | if event.button() == Qt.XButton1: # back 93 | self.preSc.activated.emit() 94 | event.accept() 95 | elif event.button() == Qt.XButton2: # forward 96 | self.nextSc.activated.emit() 97 | event.accept() 98 | else: 99 | super().mousePressEvent(event) 100 | 101 | def _applyExtendTitleBarBg(self): 102 | if isWin and settings['Main'].getboolean('extendTitleBarBg'): 103 | winDwmExtendWindowFrame(self.winId(), bottom=self.bottomArea.height()) 104 | self.setAttribute(Qt.WA_TranslucentBackground) 105 | 106 | if not isWin8: 107 | for i in (self.dtBtn, self.lockBtn): 108 | eff = NGraphicsDropShadowEffect(5 if isWin7 else 3, i) 109 | eff.setColor(QColor(Qt.white)) 110 | eff.setOffset(0, 0) 111 | eff.setBlurRadius((16 if isWin7 else 8) * scaleRatio) 112 | i.setGraphicsEffect(eff) 113 | 114 | def closeNoSave(self): 115 | self._saveOnClose = False 116 | self.close() 117 | 118 | def needSave(self): 119 | return (self.textEditor.document().isModified() or 120 | self.titleEditor.isModified() or self.timeModified or 121 | self.tagModified) 122 | 123 | def setReadOnly(self, readOnly): 124 | for i in [self.titleEditor, self.textEditor, self.tagEditor]: 125 | i.setReadOnly(readOnly) 126 | self.dtBtn.setCursor(Qt.ArrowCursor if readOnly else Qt.PointingHandCursor) 127 | self.box.setStandardButtons(self.box.Close if readOnly else 128 | self.box.Save | self.box.Cancel) 129 | self.box.button(self.box.Close if readOnly else self.box.Save).setDefault(True) 130 | self.lockBtn.setVisible(readOnly) 131 | self.titleEditor.setVisible(not readOnly or bool(self.titleEditor.text())) 132 | self.tagEditor.setVisible(not readOnly or bool(self.tagEditor.text())) 133 | for i in [self.quickCloseSc, self.quickPreSc, self.quickNextSc]: 134 | i.setEnabled(readOnly) 135 | self.readOnly = readOnly 136 | 137 | if isWin and settings['Main'].getboolean('extendTitleBarBg'): 138 | winDwmExtendWindowFrame(self.winId(), bottom=self.bottomArea.height()) 139 | 140 | def fromDiaryDict(self, dic): 141 | self.timeModified = self.tagModified = False 142 | self.id = dic['id'] 143 | self.datetime = dic.get('datetime') 144 | 145 | self.dtBtn.setText(datetimeTrans(self.datetime) if self.datetime else '') 146 | self.titleEditor.setText(dic.get('title', '')) 147 | self.tagEditor.setText(dic.get('tags', '')) 148 | self.textEditor.setRichText(dic.get('text', ''), dic.get('formats')) 149 | # if title is empty, use datetime instead. if no datetime (new), use "New Diary" 150 | t = (dic.get('title') or 151 | (datetimeTrans(self.datetime, stripTime=True) if 'datetime' in dic else None) or 152 | self.tr('New Diary')) 153 | self.setWindowTitle("%s - Hazama" % t) 154 | 155 | readOnly = (settings['Editor'].getboolean('autoReadOnly') and 156 | self.datetime is not None and 157 | datetimeToQt(self.datetime).daysTo(QDateTime.currentDateTime()) > 3) 158 | self.setReadOnly(readOnly) 159 | 160 | def toDiaryDict(self): 161 | text, formats = self.textEditor.getRichText() 162 | tags = self.tagEditor.text() 163 | if self.tagModified: # remove duplicate tags 164 | tags = ' '.join(set(tags.split())) 165 | return dict(id=self.id, datetime=self.datetime or currentDatetime(), 166 | text=text, formats=formats, title=self.titleEditor.text(), 167 | tags=tags) 168 | 169 | @Slot() 170 | def on_tagEditor_textEdited(self): 171 | # tagEditor.isModified() will be reset by completer. So this instead. 172 | self.tagModified = True 173 | 174 | @Slot() 175 | def on_dtBtn_clicked(self): 176 | """Show datetime edit dialog""" 177 | if self.readOnly: return 178 | dtStr = currentDatetime() if self.datetime is None else self.datetime 179 | newDt = DateTimeDialog.getDateTime(datetimeToQt(dtStr), fullDatetimeFmt, self) 180 | if newDt is not None: 181 | newDtStr = newDt.toString(DB_DATETIME_FMT_QT) 182 | if newDtStr != self.datetime: 183 | self.datetime = newDtStr 184 | self.dtBtn.setText(datetimeTrans(newDtStr)) 185 | self.timeModified = True 186 | -------------------------------------------------------------------------------- /hazama/updater.py: -------------------------------------------------------------------------------- 1 | """Using GitHub release API to check update and download new version. 2 | Download is Windows only. Old version will be renamed on-the-fly, 3 | and new version extracted (it's a hack, and doesn't work on XP; 4 | not sure whether it will work on Vista)""" 5 | import os 6 | import json 7 | import re 8 | import logging 9 | import hazama 10 | import zipfile 11 | import time 12 | from datetime import date, timedelta 13 | from urllib.request import urlopen, Request 14 | from collections import namedtuple 15 | from PySide.QtCore import QThread, Signal 16 | from hazama.config import appPath, settings, SOCKET_TIMEOUT 17 | 18 | 19 | _GITHUB_API_URL = 'https://api.github.com/repos/krrr/Hazama/releases/latest' 20 | 21 | UpdateInfo = namedtuple('Update', ['version', 'note', 'url', 'note_html']) 22 | 23 | 24 | def verToTuple(s): 25 | if s.startswith('v'): 26 | s = s[1:] 27 | return tuple(map(int, s.split('.'))) 28 | 29 | 30 | def _note2html(s): 31 | out = ['
    '] 32 | for l in s.split('\n'): 33 | if l[:2] in ['* ', '+ ', '- ']: 34 | out.append('
  • %s
  • ' % l[2:]) 35 | out.append('
') 36 | return '\n'.join(out) 37 | 38 | 39 | def textProgressBar(iteration, total, sep=' ', barLen=30): 40 | """ 41 | from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console 42 | """ 43 | filledLen = round(barLen * iteration / total) 44 | percent = 100 * (iteration / total) 45 | bar = '▇' * filledLen + '▁' * (barLen - filledLen) 46 | return '▕' + bar + '▏' + sep + '{:4.1f}'.format(percent) + '%' 47 | 48 | 49 | def _urlopenErrSimplify(e): 50 | e = str(e) 51 | if e.startswith(' 0: 146 | menu = QMenu() 147 | menu.addAction(QAction(self.tr('Rename'), menu, 148 | triggered=lambda: self.edit(index))) 149 | menu.exec_(event.globalPos()) 150 | menu.deleteLater() 151 | 152 | def commitData(self, editor): 153 | newName = editor.text() 154 | if editor.isModified() and newName and ' ' not in newName: 155 | # editor.oldText is set in delegate 156 | db.change_tag_name(editor.oldText, newName) 157 | logging.info('tag [%s] changed to [%s]', editor.oldText, newName) 158 | super().commitData(editor) 159 | self.tagNameModified.emit(newName) 160 | 161 | def load(self): 162 | logging.debug('load Tag List') 163 | QListWidgetItem(self.tr('All'), self) 164 | self.setCurrentRow(0) 165 | itemFlag = Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsEnabled 166 | if settings['Main'].getboolean('tagListCount'): 167 | for name, count in db.get_tags(count=True): 168 | item = QListWidgetItem(name, self) 169 | item.setFlags(itemFlag) 170 | item.setData(Qt.ToolTipRole, name) 171 | item.setData(Qt.UserRole, count) 172 | else: 173 | for name in db.get_tags(count=False): 174 | item = QListWidgetItem(name, self) 175 | item.setData(Qt.ToolTipRole, name) 176 | item.setFlags(itemFlag) 177 | 178 | def reload(self): 179 | if not self.isVisible(): 180 | return 181 | 182 | try: 183 | currentTag = self.currentItem().data(Qt.DisplayRole) 184 | except AttributeError: # no selection 185 | currentTag = None 186 | self.clear() 187 | self.load() 188 | if currentTag: 189 | try: 190 | item = self.findItems(currentTag, Qt.MatchFixedString)[0] 191 | except IndexError: 192 | item = self.item(0) 193 | self.setCurrentItem(item) 194 | 195 | def setupTheme(self): 196 | theme = settings['Main']['theme'] 197 | d = {'colorful': TagListDelegateColorful}.get(theme, TagListDelegate) 198 | self.setItemDelegate(d()) # do not pass parent under PySide... 199 | # force items to be laid again 200 | self.setSpacing(self.spacing()) 201 | 202 | def onCurrentItemChanged(self, currentItem): 203 | tag = currentItem.data(Qt.DisplayRole) if currentItem else '' 204 | # tag is '' if no selection 205 | self.currentTagChanged.emit('' if currentItem is self.item(0) else tag) 206 | -------------------------------------------------------------------------------- /res/toolbar/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 40 | 41 | 44 | 48 | 52 | 53 | 63 | 66 | 70 | 74 | 75 | 85 | 95 | 105 | 108 | 112 | 116 | 117 | 127 | 130 | 134 | 138 | 139 | 140 | 165 | 168 | 172 | 173 | 175 | 176 | 178 | image/svg+xml 179 | 181 | 182 | 183 | 184 | 185 | 191 | 197 | 204 | 211 | 218 | 225 | 232 | 239 | 240 | 246 | 253 | 260 | 261 | 262 | -------------------------------------------------------------------------------- /res/appicon/appicon-64.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 26 | 29 | 33 | 37 | 38 | 41 | 45 | 49 | 50 | 53 | 57 | 58 | 68 | 78 | 82 | 87 | 93 | 97 | 103 | 104 | 105 | 140 | 150 | 156 | 162 | 168 | 169 | 171 | 172 | 174 | image/svg+xml 175 | 177 | 178 | 179 | 180 | 181 | 187 | 189 | 195 | 201 | 207 | 213 | 219 | 225 | 231 | 237 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /hazama/diarybook.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import os 3 | import sys 4 | import shutil 5 | import logging 6 | from datetime import date, timedelta 7 | 8 | 9 | # template used to format txt file 10 | default_tpl = ''' 11 | ********{title}******** 12 | [Date: {datetime} Tags: {tags}]\n 13 | {text}\n\n\n\n''' 14 | 15 | sql_tag_with_count = ''' 16 | SELECT Tags.name, (SELECT COUNT(*) FROM Nikki_Tags 17 | WHERE Nikki_Tags.tagid=Tags.id) AS count FROM Tags''' 18 | 19 | sql_tag_names = 'SELECT name FROM tags WHERE id IN (SELECT tagid FROM nikki_tags WHERE nikkiid=?)' 20 | 21 | sql_diary_formats = 'SELECT start,length,type FROM TextFormat WHERE nikkiid=?' 22 | 23 | schema = ''' 24 | CREATE TABLE IF NOT EXISTS Tags 25 | (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); 26 | CREATE TABLE IF NOT EXISTS Nikki 27 | (id INTEGER PRIMARY KEY, datetime TEXT NOT NULL, 28 | text TEXT NOT NULL, title TEXT NOT NULL); 29 | CREATE TABLE IF NOT EXISTS Nikki_Tags 30 | (nikkiid INTEGER NOT NULL REFERENCES Nikki(id) ON DELETE CASCADE, 31 | tagid INTEGER NOT NULL, PRIMARY KEY(nikkiid, tagid)); 32 | CREATE TABLE IF NOT EXISTS TextFormat 33 | (nikkiid INTEGER NOT NULL REFERENCES Nikki(id) ON DELETE CASCADE, 34 | start INTEGER NOT NULL, length INTEGER NOT NULL, type INTEGER NOT NULL); 35 | CREATE TRIGGER IF NOT EXISTS autodeltag AFTER DELETE ON Nikki_Tags 36 | BEGIN DELETE FROM Tags WHERE (SELECT COUNT(*) FROM Nikki_Tags WHERE 37 | Nikki_Tags.tagid=Tags.id)==0; END; 38 | ''' 39 | 40 | DatabaseError = sqlite3.DatabaseError 41 | 42 | 43 | class DatabaseLockedError(Exception): pass 44 | 45 | 46 | class DiaryBook: 47 | """This class handles save/read/import/export on SQLite3 database. 48 | DiaryModel is just a copy/cache of this. 49 | 50 | Tables: 51 | Nikki: diary without format/tag fields. 52 | Nikki_Tags: mapping diary to tags 53 | Tags: tags' name 54 | TextFormat: rich text formats 55 | 56 | Columns meaning: 57 | ID: rowid from database, id of deleted row may be reused 58 | DATETIME: 59 | TEXT: 60 | TITLE: 61 | TAGS: space separated tags 62 | FORMATS: tuple of 3-tuples (start, len, type) 63 | """ 64 | ID, DATETIME, TEXT, TITLE, TAGS, FORMATS = range(6) 65 | EMPTY_DIARY = (-1, '', '', '', '', None) 66 | instance = None 67 | 68 | def __init__(self, db_path=None): 69 | self.path = self._conn = None 70 | self._commit = self._exe = None # shortcut, update after connect 71 | assert DiaryBook.instance is None 72 | DiaryBook.instance = self 73 | if db_path: 74 | self.connect(db_path) 75 | 76 | def __str__(self): 77 | return 'Diary Book (%s) with %s diaries' % (self.path, len(self)) 78 | 79 | def __len__(self): 80 | return self._exe('SELECT COUNT(id) FROM Nikki').fetchone()[0] 81 | 82 | def __iter__(self): 83 | return map(self._joined, self._exe('SELECT * FROM Nikki')) 84 | 85 | def __getitem__(self, id_): 86 | r = self._exe('SELECT * FROM Nikki WHERE id=?', (id_,)).fetchone() 87 | if r is None: 88 | raise KeyError 89 | return self._joined(r) 90 | 91 | def connect(self, db_path): 92 | self.path = db_path 93 | if self._conn: self.disconnect() 94 | self._conn = sqlite3.connect(db_path, timeout=0) 95 | self._commit, self._exe = self._conn.commit, self._conn.execute 96 | 97 | self._exe('PRAGMA foreign_keys = ON') 98 | 99 | # prevent other instance from visiting one database 100 | self._exe('PRAGMA locking_mode = EXCLUSIVE') 101 | if sys.version_info[:3] != (3, 6, 0): # this py version has bug in sqlite module 102 | try: 103 | self._exe('BEGIN EXCLUSIVE') # obtain lock by dummy transaction 104 | except sqlite3.OperationalError as e: 105 | if str(e).startswith('database is locked'): 106 | raise DatabaseLockedError 107 | else: 108 | raise 109 | self._conn.executescript(schema) # check schema 110 | 111 | def disconnect(self): 112 | self._conn.close() 113 | self._conn = self._exe = None 114 | 115 | def sorted(self, order, reverse=True): 116 | assert order in ['datetime', 'title', 'length'] 117 | order = order.replace('length', 'LENGTH(text)') 118 | cmd = 'SELECT * FROM Nikki ORDER BY ' + order + (' DESC' if reverse else '') 119 | return map(self._joined, self._exe(cmd)) 120 | 121 | def _joined(self, r): 122 | tags = ' '.join(i[0] for i in self._exe(sql_tag_names, (r[0],))) 123 | formats = tuple(self._exe(sql_diary_formats, (r[0],))) or None 124 | return r + (tags, formats) 125 | 126 | def export_txt(self, path, selected=None): 127 | """Export to TXT file using template (python string formatting). 128 | If selected contains diaries, then only export diaries in it.""" 129 | file = open(path, 'w', encoding='utf-8') 130 | try: 131 | with open('template.txt', encoding='utf-8') as f: 132 | tpl = f.read() 133 | tpl_type = 'custom' 134 | except OSError: 135 | tpl = default_tpl 136 | tpl_type = 'default' 137 | for d in (selected or self.sorted('datetime', False)): 138 | file.write(tpl.format(**diary2dict(d))) 139 | file.close() 140 | logging.info('exporting succeeded (template: %s)', tpl_type) 141 | 142 | def delete(self, id_): 143 | self._exe('DELETE FROM Nikki WHERE id = ?', (id_,)) 144 | logging.info('diary deleted (ID: %s)' % id_) 145 | # tag data will be deleted automatically by trigger 146 | self._commit() 147 | 148 | def get_tags(self, count=False): 149 | """Get all tags from database. If count is True then 150 | return two-tuples (name, count) generator.""" 151 | return tuple(self._exe(sql_tag_with_count) if count else 152 | (r[0] for r in self._exe('SELECT name FROM Tags'))) 153 | 154 | def _get_tag_id(self, name): 155 | """Get tag-id by name, because TagList doesn't store id (lazy).""" 156 | return self._exe('SELECT id FROM Tags WHERE name=?', (name,)).fetchone()[0] 157 | 158 | def change_tag_name(self, old, new): 159 | self._exe('UPDATE Tags SET name=? WHERE name=?', (new, old)) 160 | self._commit() 161 | 162 | def save(self, diary, batch=False): 163 | """Save diary. If tags is None then skip saving tags. 164 | :param diary: tuple or dict 165 | :param batch: commit will be skipped if True 166 | :return: the id of saved diary if batch is False, otherwise None 167 | """ 168 | diary = diary2dict(diary) 169 | id_ = diary['id'] 170 | new = id_ == -1 171 | 172 | if new: 173 | cur = self._exe('INSERT INTO Nikki VALUES(NULL, :datetime, ' 174 | ':text, :title)', diary) 175 | id_ = cur.lastrowid 176 | else: 177 | self._exe('UPDATE Nikki SET datetime=:datetime, text=:text, ' 178 | 'title=:title WHERE id=:id', diary) 179 | # formats processing 180 | if not new: # delete existing format information 181 | self._exe('DELETE FROM TextFormat WHERE nikkiid=?', (id_,)) 182 | for i in (diary['formats'] or []): 183 | self._exe('INSERT INTO TextFormat VALUES(?,?,?,?)', (id_,) + i) 184 | # tags processing 185 | if diary['tags'] is not None: 186 | if not new: # delete existing tags first 187 | self._exe('DELETE FROM Nikki_Tags WHERE nikkiid=?', (id_,)) 188 | for t in diary['tags'].split(): 189 | try: 190 | tag_id = self._get_tag_id(t) 191 | except TypeError: # tag not exists 192 | self._exe('INSERT INTO Tags VALUES(NULL,?)', (t,)) 193 | tag_id = self._get_tag_id(t) 194 | self._exe('INSERT INTO Nikki_Tags VALUES(?,?)', (id_, tag_id)) 195 | 196 | if not batch: 197 | self._commit() 198 | logging.info('diary saved (ID: %s)' % id_) 199 | return id_ 200 | 201 | def get_datetime_range(self): 202 | return self._exe('SELECT min(datetime), max(datetime) from Nikki').fetchone() 203 | 204 | 205 | def diary2dict(d): 206 | if isinstance(d, dict): 207 | return d 208 | return {'id': d[0], 'datetime': d[1], 'text': d[2], 'title': d[3], 209 | 'tags': d[4], 'formats': d[5]} 210 | 211 | 212 | def dict2diary(d, as_list=False): 213 | ret = (d['id'], d['datetime'], d['text'], d['title'], d['tags'], d['formats']) 214 | return list(ret) if as_list else ret 215 | 216 | 217 | def list_backups(): 218 | try: 219 | files = sorted(os.listdir('backup')) 220 | except FileNotFoundError: 221 | return [] 222 | fil = lambda x: len(x)>10 and x[4]==x[7]=='-' and x[10]=='_' 223 | return list(filter(fil, files)) 224 | 225 | 226 | def restore_backup(bk_name): 227 | logging.info('restore backup: %s', bk_name) 228 | db = DiaryBook.instance 229 | db.disconnect() 230 | bk_path = os.path.join('backup', bk_name) 231 | shutil.copyfile(bk_path, db.path) 232 | db.connect(db.path) 233 | 234 | 235 | def backup(): 236 | """Do daily backup and delete old backups if not did yet.""" 237 | db_path = DiaryBook.instance.path 238 | if not os.path.isdir('backup'): os.mkdir('backup') 239 | backups = list_backups() 240 | newest = backups[-1] if backups else '' 241 | if newest.split('_')[0] == str(date.today()): return 242 | 243 | shutil.copyfile(db_path, os.path.join( 244 | 'backup', str(date.today())+'_%d.db' % len(DiaryBook.instance))) 245 | logging.info('everyday backup succeeded') 246 | 247 | # delete old backups 248 | week_before = str(date.today() - timedelta(weeks=1)) 249 | for i in backups: 250 | if not i < week_before: break 251 | os.remove(os.path.join('backup', i)) 252 | -------------------------------------------------------------------------------- /hazama/ui/heatmap.py: -------------------------------------------------------------------------------- 1 | from PySide.QtGui import * 2 | from PySide.QtCore import * 3 | from itertools import chain 4 | from hazama.ui import scaleRatio, makeQIcon, NProperty 5 | 6 | # the default colors that represent heat of data, from cold to hot 7 | defCellColors = (QColor(255, 255, 255), QColor(255, 243, 208), 8 | QColor(255, 221, 117), QColor(255, 202, 40)) 9 | 10 | 11 | class HeatMap(QWidget): 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | layout = QVBoxLayout(self) 15 | layout.setSpacing(0) 16 | layout.setContentsMargins(0, 0, 0, 0) 17 | self.bar = QFrame(self, objectName='heatMapBar') 18 | barLayout = QHBoxLayout(self.bar) 19 | barLayout.setContentsMargins(0, 0, scaleRatio, 0) 20 | barLayout.setSpacing(3) 21 | # setup buttons and menu 22 | self.view = HeatMapView(self, font=self.font(), objectName='heatMapView') 23 | self.yearBtn = QPushButton(str(self.view.year), self, 24 | objectName='heatMapBtn') 25 | self.yearBtn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) 26 | self.yearBtn.setFocusPolicy(Qt.TabFocus) 27 | self.yearBtn.setFont(self.font()) 28 | self.yearBtn.clicked.connect(self.yearBtnAct) 29 | self.yearMenu = QMenu(self, objectName='heatMapMenu') 30 | self._yearActGroup = QActionGroup(self.yearMenu) 31 | self.setupYearMenu() 32 | sz = QSize(16, 16) * scaleRatio 33 | ico = makeQIcon(':/heatmap/arrow-left.png', scaled2x=True) 34 | preBtn = QToolButton(self, icon=ico, clicked=self.yearPre, iconSize=sz) 35 | ico = makeQIcon(':/heatmap/arrow-right.png', scaled2x=True) 36 | nextBtn = QToolButton(self, icon=ico, clicked=self.yearNext, iconSize=sz) 37 | # setup color sample 38 | self.sample = ColorSampleView(self, cellLen=11) 39 | # without following size will be bigger than fixed, why? 40 | self.sample.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) 41 | self.sample.setFixedSize(200, 14*scaleRatio) 42 | barLayout.addWidget(preBtn) 43 | barLayout.addWidget(nextBtn) 44 | barLayout.addSpacing(200 - preBtn.sizeHint().width()*2 - barLayout.spacing()) 45 | barLayout.addStretch() 46 | barLayout.addWidget(self.yearBtn) 47 | barLayout.addStretch() 48 | barLayout.addWidget(self.sample) 49 | layout.addWidget(self.bar) 50 | layout.addWidget(self.view) 51 | # setup shortcuts 52 | self.preSc = QShortcut(QKeySequence(Qt.Key_Left), self, self.yearPre) 53 | self.nextSc = QShortcut(QKeySequence(Qt.Key_Right), self, self.yearNext) 54 | self.pre5Sc = QShortcut(QKeySequence(Qt.Key_Up), self, self.yearPre5) 55 | self.next5Sc = QShortcut(QKeySequence(Qt.Key_Down), self, self.yearNext5) 56 | 57 | def showEvent(self, event): 58 | # must call setupMap after style polished 59 | self.view.setupMap() 60 | self.sample.setColors([getattr(self.view, 'cellColor%d' % i) for i in range(4)]) 61 | self.sample.setupMap() 62 | 63 | def setupYearMenu(self): 64 | group, menu, curtYear = self._yearActGroup, self.yearMenu, self.view.year 65 | menu.clear() 66 | for y in chain([curtYear-10, curtYear-7], range(curtYear-4, curtYear)): 67 | menu.addAction(QAction(str(y), group, triggered=self.yearMenuAct)) 68 | curtYearAc = QAction(str(curtYear), group) 69 | curtYearAc.setDisabled(True) 70 | curtYearAc.setCheckable(True) 71 | curtYearAc.setChecked(True) 72 | menu.addAction(curtYearAc) 73 | for y in chain(range(curtYear+1, curtYear+5), [curtYear+7, curtYear+10]): 74 | menu.addAction(QAction(str(y), group, triggered=self.yearMenuAct)) 75 | 76 | def setColorFunc(self, f): 77 | """Set function that determine each cell's background color. 78 | The function will be called with args: data, cellColors 79 | cellColors is something like defCellColors.""" 80 | self.view.cellColorFunc = f 81 | 82 | def setDataFunc(self, f): 83 | """Set function that determine each cell's data. 84 | The function will be called with args: year, month, day""" 85 | self.view.dataFunc = f 86 | 87 | def _moveYear(self, offset): 88 | self.view.year += offset 89 | self.yearBtn.setText(str(self.view.year)) 90 | self.setupYearMenu() 91 | 92 | def yearPre(self): self._moveYear(-1) 93 | 94 | def yearNext(self): self._moveYear(1) 95 | 96 | def yearPre5(self): self._moveYear(-5) 97 | 98 | def yearNext5(self): self._moveYear(5) 99 | 100 | def yearMenuAct(self): 101 | yearStr = self.sender().text() 102 | self.view.year = int(yearStr) 103 | self.yearBtn.setText(yearStr) 104 | self.setupYearMenu() 105 | 106 | def yearBtnAct(self): 107 | """Popup menu manually to avoid indicator in YearButton""" 108 | self.yearMenu.exec_(self.yearBtn.mapToGlobal( 109 | QPoint(0, self.yearBtn.height()))) 110 | 111 | 112 | class HeatMapView(QGraphicsView): 113 | cellColorFunc = lambda *args: Qt.white # dummy 114 | dataFunc = lambda *args: 0 # dummy 115 | cellLen = 9 116 | cellSpacing = 2 117 | monthSpacingX = 14 118 | monthSpacingY = 20 119 | nameFontPx = 9 # month name 120 | 121 | def __init__(self, *args, **kwargs): 122 | super().__init__(*args, **kwargs) 123 | self._year = QDate.currentDate().year() 124 | self._cellBorderColor = Qt.lightGray 125 | for idx, c in enumerate(defCellColors): 126 | setattr(self, '_cellColor%d' % idx, c) 127 | 128 | self.scene = QGraphicsScene(self) 129 | f = self.font() 130 | f.setPixelSize(self.nameFontPx) 131 | self.nameH = QFontMetrics(f).height() 132 | self.setFont(f) 133 | # short names, for convenience 134 | self._cd = cellDis = self.cellLen + self.cellSpacing 135 | self._mdx = monthDisX = cellDis * 6 + self.cellLen + self.monthSpacingX 136 | self._mdy = monthDisY = cellDis * 4 + self.cellLen + self.monthSpacingY 137 | self.scene.setSceneRect(0, 0, monthDisX*3-self.monthSpacingX, 138 | monthDisY*4-self.monthSpacingY+self.nameH) 139 | self.setScene(self.scene) 140 | 141 | def setupMap(self): 142 | locale, date, font, nameH = QLocale(), QDate(), self.font(), self.nameH 143 | cellDis, monthDisX, monthDisY = self._cd, self._mdx, self._mdy 144 | cellColors = tuple(getattr(self, 'cellColor%d' % i) for i in range(4)) 145 | for m in range(12): 146 | date.setDate(self.year, m+1, 1) 147 | # cells. 7 days per row, index of row: (d//7) 148 | monthItems = [QGraphicsRectItem(cellDis*d-(d//7)*cellDis*7, cellDis*(d//7), 149 | self.cellLen, self.cellLen) 150 | for d in range(date.daysInMonth())] 151 | for (d, item) in enumerate(monthItems, 1): 152 | date.setDate(self.year, m+1, d) 153 | if date <= QDate.currentDate(): 154 | item.setPen(QPen(self.cellBorderColor)) 155 | data = self.dataFunc(self.year, m+1, d) 156 | if data > 0: 157 | item.setBrush(self.cellColorFunc(data, cellColors)) 158 | item.setToolTip('%d (%s)' % (data, locale.toString(date))) 159 | else: 160 | p = QPen(Qt.gray) 161 | p.setStyle(Qt.DotLine) 162 | item.setPen(p) 163 | monthGroup = self.scene.createItemGroup(monthItems) 164 | # 3 months per line 165 | x, y = monthDisX*m-(m//3)*monthDisX*3, monthDisY*(m//3) 166 | monthGroup.setPos(x, y+nameH) 167 | # month name 168 | monthText = self.scene.addSimpleText(locale.toString(date, 'MMM'), font) 169 | color = self.palette().color(QPalette.WindowText) 170 | monthText.setPen(color) # both brush and pen will make text bolder than normal one 171 | monthText.setBrush(color) 172 | nameW = monthText.boundingRect().width() 173 | monthText.setPos(x+(monthDisX-self.monthSpacingX-nameW)/2, y) 174 | 175 | def resizeEvent(self, event): 176 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio) 177 | 178 | def setYear(self, year): 179 | self._year = year 180 | self.scene.clear() 181 | self.setupMap() 182 | 183 | year = property(lambda self: self._year, setYear) 184 | cellBorderColor = NProperty(QColor, '_cellBorderColor') 185 | cellColor0 = NProperty(QColor, '_cellColor0') 186 | cellColor1 = NProperty(QColor, '_cellColor1') 187 | cellColor2 = NProperty(QColor, '_cellColor2') 188 | cellColor3 = NProperty(QColor, '_cellColor3') 189 | 190 | 191 | class ColorSampleView(QGraphicsView): 192 | def __init__(self, parent=None, cellLen=None): 193 | super().__init__(parent, objectName='heatMapSample', alignment=Qt.AlignRight) 194 | self._colors = defCellColors 195 | self.cellLen = cellLen if cellLen else 9 196 | self._descriptions = ('',) * 4 197 | 198 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 199 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 200 | self.scene = QGraphicsScene(self) 201 | self.scene.setSceneRect(0, 0, self.cellLen*len(self._colors), self.cellLen) 202 | self.setScene(self.scene) 203 | 204 | def setupMap(self): 205 | for index, c in enumerate(self._colors): 206 | item = QGraphicsRectItem(self.cellLen*index, 0, self.cellLen, self.cellLen) 207 | item.setToolTip(self._descriptions[index]) 208 | item.setPen(QPen(Qt.darkGray)) 209 | item.setBrush(c) 210 | self.scene.addItem(item) 211 | 212 | def resizeEvent(self, event): 213 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio) 214 | 215 | def setColors(self, colors): 216 | """Set colors to display, arg colors is a list of QColor""" 217 | self._colors = tuple(colors) 218 | 219 | def setDescriptions(self, seq): 220 | if len(seq) != len(self._colors): 221 | raise ValueError("The amount of description doesn't match color's") 222 | self._descriptions = tuple(seq) 223 | 224 | 225 | if __name__ == '__main__': 226 | from hazama.ui import init 227 | app = init() 228 | scaleRatio = 1 229 | v = HeatMap() 230 | v.resize(500, 600) 231 | v.show() 232 | app.exec_() 233 | -------------------------------------------------------------------------------- /res/toolbar/tag-list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 52 | 56 | 57 | 60 | 64 | 68 | 69 | 72 | 76 | 80 | 81 | 84 | 88 | 92 | 93 | 96 | 100 | 104 | 105 | 115 | 125 | 135 | 145 | 155 | 164 | 165 | 184 | 187 | 188 | 190 | 191 | 193 | image/svg+xml 194 | 196 | 197 | 198 | 199 | 200 | 205 | 211 | 217 | 223 | 229 | 235 | 241 | 249 | 257 | 265 | 273 | 281 | 282 | 283 | -------------------------------------------------------------------------------- /hazama/ui/mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | mainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 571 10 | 484 11 | 12 | 13 | 14 | 15 | 400 16 | 450 17 | 18 | 19 | 20 | Hazama 21 | 22 | 23 | 24 | 25 | 0 26 | 27 | 28 | 0 29 | 30 | 31 | 32 | 33 | Qt::Horizontal 34 | 35 | 36 | 1 37 | 38 | 39 | false 40 | 41 | 42 | 43 | 44 | 1 45 | 0 46 | 47 | 48 | 49 | 50 | 75 51 | 0 52 | 53 | 54 | 55 | Qt::TabFocus 56 | 57 | 58 | Qt::ScrollBarAlwaysOff 59 | 60 | 61 | Qt::ScrollBarAlwaysOff 62 | 63 | 64 | QAbstractItemView::EditKeyPressed 65 | 66 | 67 | QAbstractItemView::ScrollPerPixel 68 | 69 | 70 | 71 | 72 | 73 | 4 74 | 0 75 | 76 | 77 | 78 | 79 | 320 80 | 0 81 | 82 | 83 | 84 | Qt::ScrollBarAlwaysOff 85 | 86 | 87 | QAbstractItemView::ExtendedSelection 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | Qt::NoContextMenu 97 | 98 | 99 | false 100 | 101 | 102 | 103 | 24 104 | 24 105 | 106 | 107 | 108 | false 109 | 110 | 111 | TopToolBarArea 112 | 113 | 114 | false 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | true 126 | 127 | 128 | 129 | :/toolbar/tag-list.png:/toolbar/tag-list.png 130 | 131 | 132 | Tag List 133 | 134 | 135 | F9 136 | 137 | 138 | 139 | 140 | 141 | :/toolbar/new.png:/toolbar/new.png 142 | 143 | 144 | New 145 | 146 | 147 | Ctrl+N 148 | 149 | 150 | 151 | 152 | 153 | :/toolbar/delete.png:/toolbar/delete.png 154 | 155 | 156 | Delete 157 | 158 | 159 | Del 160 | 161 | 162 | Qt::WidgetShortcut 163 | 164 | 165 | 166 | 167 | 168 | :/toolbar/sort.png:/toolbar/sort.png 169 | 170 | 171 | Sort By 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | :/toolbar/config.png:/toolbar/config.png 181 | 182 | 183 | Settings 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | :/toolbar/heatmap.png:/toolbar/heatmap.png 193 | 194 | 195 | Heat Map 196 | 197 | 198 | Ctrl+M 199 | 200 | 201 | 202 | 203 | 204 | DiaryList 205 | QListView 206 |
hazama.ui.diarylist
207 | 208 | countChanged() 209 | startLoading() 210 | setFilterByTag(QString) 211 | reload() 212 | refreshFilteredTags(QString) 213 | 214 |
215 | 216 | TagList 217 | QListWidget 218 |
hazama.ui.taglist
219 | 220 | currentTagChanged(QString) 221 | tagNameModified(QString) 222 | reload() 223 | 224 |
225 | 226 | NSplitter 227 | QSplitter 228 |
hazama.ui.customobjects
229 | 1 230 |
231 |
232 | 233 | 234 | 235 | 236 | 237 | creAct 238 | triggered() 239 | mainWindow 240 | startEditorNew() 241 | 242 | 243 | -1 244 | -1 245 | 246 | 247 | 285 248 | 241 249 | 250 | 251 | 252 | 253 | delAct 254 | triggered() 255 | mainWindow 256 | deleteDiary() 257 | 258 | 259 | -1 260 | -1 261 | 262 | 263 | 285 264 | 241 265 | 266 | 267 | 268 | 269 | tListAct 270 | triggered(bool) 271 | mainWindow 272 | toggleTagList() 273 | 274 | 275 | -1 276 | -1 277 | 278 | 279 | 254 280 | 165 281 | 282 | 283 | 284 | 285 | diaryList 286 | countChanged() 287 | mainWindow 288 | updateCountLabel() 289 | 290 | 291 | 305 292 | 241 293 | 294 | 295 | 254 296 | 224 297 | 298 | 299 | 300 | 301 | tagList 302 | currentTagChanged(QString) 303 | diaryList 304 | setFilterByTag(QString) 305 | 306 | 307 | 50 308 | 241 309 | 310 | 311 | 305 312 | 241 313 | 314 | 315 | 316 | 317 | tagList 318 | tagNameModified(QString) 319 | diaryList 320 | refreshFilteredTags(QString) 321 | 322 | 323 | 50 324 | 241 325 | 326 | 327 | 305 328 | 241 329 | 330 | 331 | 332 | 333 | diaryList 334 | startLoading() 335 | mainWindow 336 | updateCountLabelOnLoad() 337 | 338 | 339 | 305 340 | 241 341 | 342 | 343 | 254 344 | 224 345 | 346 | 347 | 348 | 349 | diaryList 350 | activated(QModelIndex) 351 | mainWindow 352 | startEditor(QModelIndex) 353 | 354 | 355 | 342 356 | 258 357 | 358 | 359 | 285 360 | 241 361 | 362 | 363 | 364 | 365 | 366 | toggleTagList() 367 | updateCountLabel() 368 | updateCountLabelOnLoad() 369 | startEditorNew() 370 | startEditor(QModelIndex) 371 | deleteDiary() 372 | 373 |
374 | -------------------------------------------------------------------------------- /hazama/ui/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import os 4 | import time 5 | import logging 6 | import PySide 7 | import hazama.ui.res_rc # load resources, let showErrors have icons 8 | from PySide.QtGui import * 9 | from PySide.QtCore import * 10 | from hazama.util import my_fround 11 | from hazama.config import (settings, appPath, isWin, isWinVistaOrLater, isWin8OrLater, 12 | CUSTOM_STYLESHEET_DELIMIT) 13 | 14 | 15 | # qApp global var is None before entering event loop, use QApplication.instance() instead 16 | 17 | locale = sysLocale = None 18 | # datetimeFmt may not contain time part (by default) 19 | dateFmt = datetimeFmt = fullDatetimeFmt = None 20 | font = None 21 | scaleRatio = None 22 | _trans = _transQt = None # Translator, just used to keep reference 23 | 24 | # CONSTANTS 25 | DB_DATETIME_FMT_QT = 'yyyy-MM-dd HH:mm' 26 | 27 | TRANSLATIONS = ('en', 'zh_CN', 'ja_JP') 28 | TRANS_DISPLAY_NAMES = ('English', '简体中文', '日本語') 29 | 30 | THEMES = ('1px-rect', 'colorful') 31 | THEME_COLORFUL_SCHEMES = ('green', 'yellow', 'white') 32 | 33 | 34 | def datetimeToQt(s): 35 | return locale.toDateTime(s, DB_DATETIME_FMT_QT) 36 | 37 | 38 | def datetimeTrans(s, stripTime=False): 39 | """Localize datetime in database format.""" 40 | dt = QDateTime.fromString(s, DB_DATETIME_FMT_QT) 41 | return locale.toString(dt, dateFmt if stripTime else datetimeFmt) 42 | 43 | 44 | def currentDatetime(): 45 | """Return current datetime in database format.""" 46 | return time.strftime('%Y-%m-%d %H:%M') 47 | 48 | 49 | def readRcTextFile(path): 50 | """Read whole text file from qt resources system.""" 51 | assert path.startswith(':/') 52 | f = QFile(path) 53 | if not f.open(QFile.ReadOnly | QFile.Text): 54 | raise FileNotFoundError 55 | text = str(f.readAll()) 56 | f.close() 57 | return text 58 | 59 | 60 | def readRcFile(path): 61 | """Read whole binary file from qt resources system.""" 62 | assert path.startswith(':/') 63 | f = QFile(path) 64 | if not f.open(QFile.ReadOnly): 65 | raise FileNotFoundError 66 | ret = f.readAll().data() 67 | f.close() 68 | return ret 69 | 70 | 71 | def setTranslationLocale(): 72 | global locale, sysLocale, _trans, _transQt 73 | 74 | sysLocale = QLocale.system() 75 | 76 | lang = settings['Main'].get('lang') 77 | if not lang: 78 | if sysLocale.name() in TRANSLATIONS: 79 | lang = settings['Main']['lang'] = sysLocale.name() 80 | else: 81 | lang = settings['Main']['lang'] = 'en' 82 | 83 | if lang == 'en' or sysLocale.name() == lang: # use system's locale 84 | # lang=='en' because user is likely to use English if his lang is not supported 85 | locale = sysLocale 86 | else: # override system's locale 87 | locale = QLocale(lang) 88 | QLocale.setDefault(locale) 89 | logging.info('set translation ' + lang) 90 | 91 | _trans = QTranslator() 92 | _transQt = QTranslator() 93 | if lang != 'en': 94 | try: 95 | _trans.load(readRcFile(':/trans.qm')) 96 | except FileNotFoundError: 97 | logging.warning('failed to load translation for locale %s' % locale.name()) 98 | 99 | try: 100 | _transQt.load(readRcFile(':/trans_qt.qm')) 101 | except FileNotFoundError: 102 | _transQt.load('qt_' + locale.name(), QLibraryInfo.location(QLibraryInfo.TranslationsPath)) 103 | # install empty trans equals removeTranslator; for restart-less config changing 104 | for i in (_trans, _transQt): 105 | QApplication.instance().installTranslator(i) 106 | 107 | global dateFmt, datetimeFmt, fullDatetimeFmt 108 | timeFmt = settings['Main'].get('timeFormat') 109 | dateFmt = settings['Main'].get('dateFormat') or locale.dateFormat() 110 | datetimeFmt = (dateFmt + ' ' + timeFmt) if timeFmt else dateFmt 111 | # use hh:mm because locale.timeFormat will include seconds 112 | fullDatetimeFmt = dateFmt + ' ' + (timeFmt or 'hh:mm') 113 | 114 | 115 | def showErrors(type_, *args, exit_=False): 116 | """Show variety of error dialogs.""" 117 | app = QApplication.instance() 118 | if not app: 119 | app = init() 120 | {'dbError': lambda hint='': QMessageBox.critical( 121 | None, 122 | app.translate('Errors', 'Diary book inaccessible'), 123 | app.translate('Errors', 'Diary book seems corrupted. You may have to ' 124 | 'recover it from backups.\n\nSQLite3: %s') % hint), 125 | 'dbLocked': lambda: QMessageBox.warning( 126 | None, 127 | app.translate('Errors', 'Multiple access error'), 128 | app.translate('Errors', 'This diary book is already open.')), 129 | 'cantFile': lambda filename: QMessageBox.critical( 130 | None, 131 | app.translate('Errors', 'File inaccessible'), 132 | app.translate('Errors', 'Failed to access %s') % filename), 133 | 'fileCorrupted': lambda filename: QMessageBox.critical( 134 | None, 135 | app.translate('Errors', 'File corrupted'), 136 | app.translate('Errors', '%s is corrupted, please delete or fix it.') % filename) 137 | }[type_](*args) 138 | 139 | if exit_: 140 | sys.exit(-1) 141 | 142 | 143 | def setStdEditMenuIcons(menu): 144 | """Add system theme icons to QLineEdit and QTextEdit context-menu. 145 | :param menu: QMenu generated by createStandardContextMenu 146 | """ 147 | acts = menu.actions() 148 | if len(acts) < 9: return 149 | (undo, redo, __, cut, copy, paste, delete, __, sel, *__) = acts 150 | undo.setIcon(QIcon.fromTheme('edit-undo')) 151 | redo.setIcon(QIcon.fromTheme('edit-redo')) 152 | cut.setIcon(QIcon.fromTheme('edit-cut')) 153 | copy.setIcon(QIcon.fromTheme('edit-copy')) 154 | paste.setIcon(QIcon.fromTheme('edit-paste')) 155 | delete.setIcon(QIcon.fromTheme('edit-delete')) 156 | sel.setIcon(QIcon.fromTheme('edit-select-all')) 157 | 158 | 159 | _qssPixelSub = re.compile(r'\b\d+dip', re.ASCII) 160 | _originSetSsMethod = None 161 | 162 | 163 | def setStyleSheetPatched(ss): 164 | # support custom device independent pixel (dip), like px in CSS 165 | # 1dip @96DPI == 1px 166 | # 1dip @ 144DPI == 1px (for hair lines) 167 | qa = QApplication.instance().originStyleSheet = ss 168 | _originSetSsMethod(_qssPixelSub.sub( 169 | lambda px: '%dpx' % int(my_fround(px.group()[:-3]) * scaleRatio), 170 | ss)) 171 | 172 | 173 | def loadStyleSheet(): 174 | """If -stylesheet not in sys.argv, append custom.qss(if any) to default one and 175 | load it. Otherwise load the one in sys.argv""" 176 | if '-stylesheet' in sys.argv: 177 | return logging.info('override default StyleSheet by command line arg') 178 | 179 | ss = [readRcTextFile(':/default.qss')] 180 | # append theme part 181 | if settings['Main']['theme'] == 'colorful': 182 | ss.append(readRcTextFile(':/colorful.qss')) 183 | scheme = settings['ThemeColorful']['colorScheme'] 184 | if scheme != 'green': 185 | ss.append(readRcTextFile(':/colorful-%s.qss' % scheme)) 186 | # load custom 187 | ss.append(CUSTOM_STYLESHEET_DELIMIT) 188 | if os.path.isfile('custom.qss'): 189 | logging.info('set custom StyleSheet') 190 | with open('custom.qss', encoding='utf-8') as f: 191 | ss.append(f.read()) 192 | 193 | QApplication.instance().setStyleSheet(''.join(ss)) 194 | 195 | 196 | def winDwmExtendWindowFrame(hwnd, left=0, right=0, top=0, bottom=0): 197 | """Extend background of title bar to toolbar. Only available on Windows 198 | because it depends on DWM.""" 199 | if not isDwmUsable(): return 200 | from ctypes import (c_int, byref, pythonapi, c_void_p, c_char_p, py_object, 201 | windll, Structure) 202 | 203 | # define prototypes & structures 204 | class Margin(Structure): 205 | _fields_ = [('left', c_int), ('right', c_int), 206 | ('top', c_int), ('bottom', c_int)] 207 | if not isinstance(hwnd, int): 208 | pythonapi.PyCapsule_GetPointer.restype = c_void_p 209 | pythonapi.PyCapsule_GetPointer.argtypes = [py_object, c_char_p] 210 | # winId is PyCapsule object, which storing HWND 211 | hwnd = pythonapi.PyCapsule_GetPointer(hwnd, None) 212 | margin = Margin(left, right, top, bottom) 213 | windll.dwmapi.DwmExtendFrameIntoClientArea(hwnd, byref(margin)) 214 | 215 | return True 216 | 217 | 218 | def isDwmUsable(): 219 | """Check whether winDwmExtendWindowFrame usable.""" 220 | if not isWin: 221 | return False 222 | if isWin8OrLater: 223 | # windows 8 or later always have DWM composition enabled, but API used below depends 224 | # on manifest file (we doesn't have it) 225 | return True 226 | elif not isWinVistaOrLater: 227 | return False 228 | else: 229 | from ctypes import byref, windll, c_bool 230 | 231 | b = c_bool() 232 | ret = windll.dwmapi.DwmIsCompositionEnabled(byref(b)) 233 | return ret == 0 and b.value 234 | 235 | 236 | def fixWidgetSizeOnHiDpi(widget): 237 | """Simply resize current size according to DPI. Should be called after setupUi.""" 238 | if scaleRatio > 1: 239 | widget.resize(widget.size() * scaleRatio) 240 | widget.setMinimumSize(widget.minimumSize() * scaleRatio) # prevent over sizing after resize 241 | 242 | 243 | def saveWidgetGeo(widget): 244 | return '%s,%s' % (widget.saveGeometry().toHex(), scaleRatio) 245 | 246 | 247 | def restoreWidgetGeo(widget, geoStr): 248 | if not geoStr or geoStr.count(',') != 1: 249 | return fixWidgetSizeOnHiDpi(widget) 250 | 251 | a, b = geoStr.split(',') 252 | success = widget.restoreGeometry(QByteArray.fromHex(a)) 253 | ratio = scaleRatio / float(b) 254 | if success and abs(ratio - 1) > 0.01: 255 | widget.move(widget.pos() * ratio) 256 | widget.resize(widget.size() * ratio) 257 | 258 | 259 | def makeQIcon(*filenames, scaled2x=False): 260 | """A Shortcut to construct a QIcon which has multiple images. Try to add all sizes 261 | (xx.png & xx-big.png & xx-mega.png) when only one filename supplied.""" 262 | ico = QIcon() 263 | if len(filenames) == 1: 264 | fname = filenames[0] 265 | assert '.' in fname 266 | b, ext = fname.rsplit('.') 267 | 268 | ico.addFile(fname) 269 | ico.addFile(b + '-big.' + ext) # fails silently when file not exist 270 | if scaled2x and scaleRatio > 1.5: 271 | origin = QPixmap(fname) 272 | ico.addPixmap(origin.scaled(origin.size() * 2)) 273 | else: 274 | ico.addFile(b + '-mega.' + ext) 275 | else: 276 | for i in filenames: 277 | ico.addFile(i) 278 | return ico 279 | 280 | 281 | def markIcon(ico, size, markFName): 282 | sz = size * scaleRatio 283 | origin = ico.pixmap(sz) 284 | painter = QPainter(origin) 285 | painter.drawPixmap(0, 0, QPixmap(markFName).scaled(sz)) 286 | painter.end() # this should be called at destruction, but... critical error everywhere? 287 | ico.addPixmap(origin) 288 | 289 | 290 | def refreshStyle(widget): 291 | widget.style().unpolish(widget) 292 | widget.style().polish(widget) 293 | 294 | 295 | class Fonts: 296 | """Manage all fonts used in application""" 297 | preferredFonts = { # unlike CSS, if font family name is localized then it's English name will be ignored 298 | 'zh_CN': ('Microsoft YaHei', '微软雅黑', 299 | 'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei', '文泉驿正黑', 300 | 'Noto Sans CJK SC', 'Source Han Sans CN Normal', '思源黑体'), 301 | 'ja_JP': ('Yu Gothic Medium', '游ゴシック Medium', 'Meiryo', 'メイリオ', 'Noto Sans CJK JP'), 302 | 'zh_TW': ('Microsoft JhengHei', '微软正黑体', 'Noto Sans CJK TC')} 303 | 304 | def __init__(self): 305 | # all fonts have userSet attribute 306 | self.title = QFont() 307 | self.datetime = QFont() 308 | self.text = QFont() 309 | self.default = None 310 | self.default_m = self.title_m = self.datetime_m = self.text_m = None 311 | 312 | def load(self): 313 | self.default = QApplication.instance().font() 314 | saved = settings['Font'].get('default') 315 | preferred = None if saved else self.getPreferredFont() 316 | if saved: 317 | self.default.fromString(saved) 318 | elif preferred: 319 | self.default = preferred 320 | self.default.userSet = bool(saved) 321 | logging.debug('app font: %s @%dpt' % (self.default.family(), self.default.pointSize())) 322 | QApplication.instance().setFont(self.default) 323 | 324 | for i in ('title', 'datetime', 'text'): 325 | f = getattr(self, i) 326 | f.fromString(settings['Font'].get(i)) 327 | f.userSet = True 328 | if not f.exactMatch(): 329 | # document says f.family() == '' will use app font, but it not work on Linux 330 | # userSet attr is for this 331 | f.setFamily(self.default.family()) 332 | f.userSet = False 333 | 334 | for i in ('title', 'datetime', 'text', 'default'): 335 | # passing None as 2nd arg to QFontMetrics make difference on high DPI 336 | setattr(self, i+'_m', QFontMetrics(getattr(self, i), None)) 337 | 338 | @classmethod 339 | def getPreferredFont(cls): 340 | """Return preferred font according to language and platform.""" 341 | # 1. get sans-serif CJ fonts that looks good on HiDPI 342 | # 2. fix when app font doesn't match system's, this will cause incorrect lineSpacing ( 343 | # an attempt to use QFontDatabase to auto get right font was failed) 344 | if isWin and scaleRatio == 1 and settings['Main']['theme'] == '1px-rect': 345 | # old theme looks fine with default bitmap fonts only on normal DPI (SimSun) 346 | return None 347 | 348 | lst = cls.preferredFonts.get(locale.name() if locale.language() != sysLocale.language() else 349 | sysLocale.name()) 350 | if not lst: 351 | return None 352 | f = QApplication.instance().font() 353 | for i in lst: 354 | f.setFamily(i) 355 | if f.exactMatch(): 356 | return f 357 | return None 358 | 359 | 360 | def NProperty(type_, var): 361 | """Replace Property of PySide so that boilerplate getter/setter code is not needed.""" 362 | return Property(type_, 363 | lambda self: getattr(self, var), 364 | lambda self, to: setattr(self, var, to)) 365 | 366 | 367 | def init(): 368 | logging.debug('PySide ver: %s (lib path: %s)', PySide.__version__, 369 | QLibraryInfo.location(QLibraryInfo.LibrariesPath)) 370 | app = QApplication(sys.argv) 371 | app.setWindowIcon(makeQIcon(':/appicon-24.png', ':/appicon-48.png')) 372 | 373 | global scaleRatio 374 | scaleRatio = app.desktop().logicalDpiX() / 96 # when will x != y happen? 375 | logging.debug('DPI scale ratio %s' % scaleRatio) 376 | 377 | setTranslationLocale() 378 | global font 379 | font = Fonts() 380 | font.load() 381 | 382 | global _originSetSsMethod 383 | _originSetSsMethod = app.setStyleSheet 384 | app.setStyleSheet = setStyleSheetPatched 385 | loadStyleSheet() 386 | return app 387 | -------------------------------------------------------------------------------- /hazama/ui/customwidgets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from PySide.QtCore import * 3 | from PySide.QtGui import * 4 | from hazama.ui import setStdEditMenuIcons, makeQIcon, fixWidgetSizeOnHiDpi 5 | from hazama.ui.customobjects import TextFormatter, NTextDocument 6 | 7 | 8 | class QLineEditWithMenuIcon(QLineEdit): 9 | """A QLineEdit with system theme icons in context-menu""" 10 | def contextMenuEvent(self, event): 11 | menu = self.createStandardContextMenu() 12 | setStdEditMenuIcons(menu) 13 | menu.exec_(event.globalPos()) 14 | menu.deleteLater() 15 | 16 | 17 | class MultiLineElideLabel(QFrame): 18 | ElideMark = '\u2026' 19 | """Qt Widget version of QML text.maximumLineCount.""" 20 | 21 | def __init__(self, *args, **kwargs): 22 | self._forceHeightHint = kwargs.pop('forceHeightHint', False) 23 | super().__init__(*args, **kwargs) 24 | self._maximumLineCount = 4 25 | self._layout = QTextLayout() 26 | self._layout.setCacheEnabled(True) 27 | self._text = None 28 | self._elideMarkWidth = None 29 | self._elideMarkPos = None 30 | self._heightHint = 0 31 | self._lineHeight = 0 32 | self._realHeight = 0 33 | self._updateSize() 34 | 35 | def resizeEvent(self, event): 36 | self._setupTextLayout() 37 | super().resizeEvent(event) 38 | 39 | def setFont(self, f): 40 | super().setFont(f) 41 | self._updateSize() 42 | self._setupTextLayout() 43 | 44 | def sizeHint(self): 45 | __, top, __, bottom = self.getContentsMargins() 46 | return QSize(-1, self._realHeight + top + bottom) 47 | 48 | def paintEvent(self, event): 49 | painter = QPainter(self) 50 | painter.translate(self.contentsRect().topLeft()) 51 | 52 | self._layout.draw(painter, QPoint()) 53 | 54 | if self._elideMarkPos is not None: 55 | painter.drawText(self._elideMarkPos, self.ElideMark) 56 | 57 | def _updateSize(self): 58 | # use height because leading is not included 59 | # this make realHeight equals heightHint even if font fallback happen 60 | self._lineHeight = self.fontMetrics().height() 61 | self._heightHint = self._lineHeight * self._maximumLineCount 62 | self._elideMarkWidth = self.fontMetrics().width(self.ElideMark) 63 | if self._forceHeightHint: 64 | self._realHeight = self._heightHint 65 | 66 | def setText(self, text): 67 | self._text = text.replace('\n', '\u2028') 68 | self._setupTextLayout() 69 | 70 | def _setupTextLayout(self): 71 | layout = self._layout 72 | layout.clearLayout() 73 | layout.setFont(self.font()) 74 | 75 | if not self._text or self._maximumLineCount == 0: 76 | if self._realHeight != 0 and not self._forceHeightHint: 77 | self.updateGeometry() 78 | self._realHeight = 0 79 | return 80 | 81 | lineWidthLimit = self.contentsRect().width() 82 | layout.setText(self._text) 83 | 84 | height = 0 85 | visibleTextLen = 0 86 | linesLeft = self._maximumLineCount 87 | self._elideMarkPos = None 88 | 89 | layout.beginLayout() 90 | while True: 91 | line = layout.createLine() 92 | if not line.isValid(): 93 | break # call methods of invalid one will segfault 94 | 95 | line.setLineWidth(lineWidthLimit) 96 | visibleTextLen += line.textLength() 97 | line.setPosition(QPointF(0, height)) 98 | height += line.height() 99 | 100 | linesLeft -= 1 101 | if linesLeft == 0: 102 | if visibleTextLen < len(self._text): 103 | # ignore right to left text 104 | line.setLineWidth(lineWidthLimit - self._elideMarkWidth) 105 | self._elideMarkPos = QPoint(line.naturalTextWidth(), 106 | height-line.height()+self.fontMetrics().ascent()) 107 | 108 | break 109 | layout.endLayout() 110 | height = int(height) 111 | if height != self._realHeight and not self._forceHeightHint: 112 | self.updateGeometry() 113 | self._realHeight = height 114 | 115 | def setMaximumLineCount(self, lines): 116 | """0 means unlimited.""" 117 | if lines == self._maximumLineCount: 118 | return 119 | self._maximumLineCount = lines 120 | self._setupTextLayout() 121 | self._updateSize() 122 | 123 | 124 | class NTextEdit(QTextEdit, TextFormatter): 125 | """The widget used to edit diary contents in Editor window.""" 126 | # spaces that auto-indent can recognize 127 | SPACE_KINDS = (' ', '\u3000') # full width space U+3000 128 | 129 | def __init__(self, *args, **kwargs): 130 | super().__init__(*args, **kwargs) 131 | self._doc = NTextDocument(self) 132 | self.setDocument(self._doc) 133 | # remove highlight color's alpha to avoid alpha loss in copy&paste. 134 | # NTextDocument should use this color too. 135 | hl, bg = self.HlColor, self.palette().base().color() 136 | fac = hl.alpha() / 255 137 | self.HlColor = QColor(round(hl.red()*fac + bg.red()*(1-fac)), 138 | round(hl.green()*fac + bg.green()*(1-fac)), 139 | round(hl.blue()*fac + bg.blue()*(1-fac))) 140 | 141 | self.autoIndent = False 142 | # used by tab indent shortcut 143 | if QLocale().language() in (QLocale.Chinese, QLocale.Japanese): 144 | self._indent = '  ' # 2 full width spaces 145 | else: 146 | self._indent = ' ' # 4 spaces 147 | # setup format menu 148 | onHLAct = lambda: super(NTextEdit, self).setHL(self.hlAct.isChecked()) 149 | onBDAct = lambda: super(NTextEdit, self).setBD(self.bdAct.isChecked()) 150 | onSOAct = lambda: super(NTextEdit, self).setSO(self.soAct.isChecked()) 151 | onULAct = lambda: super(NTextEdit, self).setUL(self.ulAct.isChecked()) 152 | onItaAct = lambda: super(NTextEdit, self).setIta(self.itaAct.isChecked()) 153 | 154 | self.fmtMenu = QMenu(self.tr('Format'), self) 155 | # shortcuts of format actions only used to display shortcut-hint in menu 156 | self.hlAct = QAction(makeQIcon(':/menu/highlight.png'), self.tr('Highlight'), 157 | self, triggered=onHLAct, 158 | shortcut=QKeySequence('Ctrl+H')) 159 | self.bdAct = QAction(makeQIcon(':/menu/bold.png'), self.tr('Bold'), 160 | self, triggered=onBDAct, 161 | shortcut=QKeySequence.Bold) 162 | self.soAct = QAction(makeQIcon(':/menu/strikeout.png'), self.tr('Strike out'), 163 | self, triggered=onSOAct, 164 | shortcut=QKeySequence('Ctrl+T')) 165 | self.ulAct = QAction(makeQIcon(':/menu/underline.png'), self.tr('Underline'), 166 | self, triggered=onULAct, 167 | shortcut=QKeySequence.Underline) 168 | self.itaAct = QAction(makeQIcon(':/menu/italic.png'), self.tr('Italic'), 169 | self, triggered=onItaAct, 170 | shortcut=QKeySequence.Italic) 171 | self.clrAct = QAction(self.tr('Clear format'), self, 172 | shortcut=QKeySequence('Ctrl+D'), 173 | triggered=self.clearFormat) 174 | self.acts = (self.hlAct, self.bdAct, self.soAct, self.ulAct, 175 | self.itaAct) # excluding uncheckable clrAct 176 | for a in self.acts: 177 | self.fmtMenu.addAction(a) 178 | a.setCheckable(True) 179 | self.fmtMenu.addSeparator() 180 | self.addAction(self.clrAct) 181 | self.fmtMenu.addAction(self.clrAct) 182 | self.key2act = { 183 | Qt.Key_H: self.hlAct, Qt.Key_B: self.bdAct, Qt.Key_T: self.soAct, 184 | Qt.Key_U: self.ulAct, Qt.Key_I: self.itaAct} 185 | 186 | def contextMenuEvent(self, event): 187 | menu = self.createStandardContextMenu() 188 | setStdEditMenuIcons(menu) 189 | 190 | if not self.isReadOnly(): 191 | if self.textCursor().hasSelection(): 192 | self._setFmtActs() 193 | self.fmtMenu.setEnabled(True) 194 | else: 195 | self.fmtMenu.setEnabled(False) 196 | before = menu.actions()[2] 197 | menu.insertSeparator(before) 198 | menu.insertMenu(before, self.fmtMenu) 199 | 200 | menu.exec_(event.globalPos()) 201 | menu.deleteLater() 202 | 203 | def keyPressEvent(self, event): 204 | if self.isReadOnly(): 205 | return super().keyPressEvent(event) 206 | 207 | if event.modifiers() == Qt.ControlModifier and event.key() in self.key2act: 208 | # set actions before calling format methods 209 | self._setFmtActs() 210 | self.key2act[event.key()].trigger() 211 | elif event.key() == Qt.Key_Tab: 212 | # will not receive event if tabChangesFocus is True 213 | self.textCursor().insertText(self._indent) 214 | elif event.key() == Qt.Key_Return and self.autoIndent: 215 | # auto-indent support 216 | para = self.textCursor().block().text() 217 | if len(para) > 0 and para[0] in NTextEdit.SPACE_KINDS: 218 | space, spaceCount = para[0], 1 219 | for c in para[1:]: 220 | if c != space: break 221 | spaceCount += 1 222 | super().keyPressEvent(event) 223 | self.textCursor().insertText(space * spaceCount) 224 | else: 225 | super().keyPressEvent(event) 226 | else: 227 | super().keyPressEvent(event) 228 | 229 | def insertFromMimeData(self, source): 230 | """Disable some unsupported types""" 231 | self.insertHtml(source.html() or source.text()) 232 | 233 | def setRichText(self, text, formats): 234 | self._doc.setHlColor(self.HlColor) 235 | self._doc.setText(text, formats) 236 | 237 | def setAutoIndent(self, enabled): 238 | assert isinstance(enabled, (bool, int)) 239 | self.autoIndent = enabled 240 | 241 | def getRichText(self): 242 | # self.document() will return QTextDocument, not NTextDocument 243 | return self.toPlainText(), self._doc.getFormats() 244 | 245 | def _setFmtActs(self): 246 | """Check formats in current selection and check or uncheck actions""" 247 | fmts = [QTextFormat.BackgroundBrush, QTextFormat.FontWeight, 248 | QTextFormat.FontStrikeOut, 249 | QTextFormat.TextUnderlineStyle, QTextFormat.FontItalic] 250 | 251 | cur = self.textCursor() 252 | start, end = cur.anchor(), cur.position() 253 | if start > end: 254 | start, end = end, start 255 | results = [True] * 5 256 | for pos in range(end, start, -1): 257 | cur.setPosition(pos) 258 | charFmt = cur.charFormat() 259 | for i, f in enumerate(fmts): 260 | if results[i] and not charFmt.hasProperty(f): 261 | results[i] = False 262 | if not any(results): break 263 | for i, c in enumerate(results): 264 | self.acts[i].setChecked(c) 265 | 266 | def clearFormat(self): 267 | fmt = QTextCharFormat() 268 | self.textCursor().setCharFormat(fmt) 269 | 270 | 271 | class NLineEditMouse(QLineEditWithMenuIcon): 272 | """QLineEdit that ignore mouse back/forward button, with menu icon.""" 273 | def mousePressEvent(self, event): 274 | if event.button() in (Qt.XButton1, Qt.XButton2): 275 | event.ignore() 276 | else: 277 | super().mousePressEvent(event) 278 | 279 | 280 | class NElideLabel(QLabel): 281 | elideMode = Qt.ElideRight 282 | 283 | def paintEvent(self, event): 284 | painter = QPainter(self) 285 | rect = self.contentsRect() 286 | t = self.fontMetrics().elidedText(self.text(), self.elideMode, rect.width()) 287 | painter.drawText(rect, self.alignment(), t) 288 | 289 | def minimumSizeHint(self): 290 | return QSize() # return invalid size 291 | 292 | 293 | class DateTimeDialog(QDialog): 294 | """A dialog that let user change datetime, just like QColorDialog.""" 295 | def __init__(self, dt, displayFmt, parent=None): 296 | super().__init__(parent, Qt.WindowTitleHint) 297 | self.format = displayFmt 298 | self.setWindowModality(Qt.WindowModal) 299 | self.setWindowTitle(self.tr('Edit datetime')) 300 | self.setMinimumWidth(100) 301 | self.verticalLayout = QVBoxLayout(self) 302 | self.dtEdit = QDateTimeEdit(dt, self) 303 | self.dtEdit.setDisplayFormat(displayFmt) 304 | self.verticalLayout.addWidget(self.dtEdit) 305 | self.btnBox = QDialogButtonBox(self) 306 | self.btnBox.setOrientation(Qt.Horizontal) 307 | self.btnBox.setStandardButtons(QDialogButtonBox.Ok | 308 | QDialogButtonBox.Cancel) 309 | self.verticalLayout.addWidget(self.btnBox) 310 | self.btnBox.accepted.connect(self.accept) 311 | self.btnBox.rejected.connect(self.reject) 312 | 313 | @classmethod 314 | def getDateTime(cls, dt, displayFmt, parent): 315 | """Show a model datetime dialog, let user change it. 316 | :param parent: parent widget 317 | :param dt: datetime to change 318 | :param displayFmt: the Qt datetime format that used to display 319 | :return: None if canceled else datetime""" 320 | dialog = cls(dt, displayFmt, parent) 321 | ret = dialog.exec_() 322 | dialog.deleteLater() 323 | return dialog.dtEdit.dateTime() if ret else None 324 | 325 | 326 | class FontSelectButton(QPushButton): 327 | """Select fonts with QFontDialog.""" 328 | PreviewText = 'AaBbYy@2017' 329 | 330 | def __init__(self, parent=None): 331 | super().__init__(parent) 332 | self._dialog = None 333 | self.userSet = None # whether the font is set by user 334 | self.configName = None 335 | self.resettable = False # display "reset to default" button in dialog 336 | self.clicked.connect(self._showDialog) 337 | 338 | def setFont(self, font_, userSet=True): 339 | """Set Font Button's text and font""" 340 | super().setFont(font_) 341 | self.userSet = userSet 342 | family = font_.family() if font_.exactMatch() else QFontInfo(font_).family() 343 | self.setText('%s %spt' % (family, font_.pointSize())) 344 | 345 | def _showDialog(self): 346 | dlg = self._dialog = QFontDialog(self) 347 | dlg.setCurrentFont(self.font()) 348 | fixWidgetSizeOnHiDpi(dlg) 349 | 350 | # set sample text and add button with some hack 351 | try: 352 | sample = dlg.findChildren(QLineEdit)[3] 353 | sample.setText(self.PreviewText) 354 | 355 | if self.resettable: 356 | box = dlg.findChildren(QDialogButtonBox)[0] 357 | box.addButton(QDialogButtonBox.RestoreDefaults) 358 | box.clicked.connect(self._onFontDialogBtnClicked) 359 | except Exception as e: 360 | logging.warning('failed to hack Qt font dialog: %s' % e) 361 | 362 | ret = dlg.exec_() 363 | if ret: 364 | self.setFont(dlg.selectedFont(), userSet=True) 365 | self._dialog = None 366 | 367 | def _onFontDialogBtnClicked(self, btn): 368 | if btn.parent().buttonRole(btn) == QDialogButtonBox.ResetRole: 369 | assert self.resettable 370 | self._dialog.reject() 371 | self.setFont(qApp.font(), userSet=False) 372 | --------------------------------------------------------------------------------