manuskript/manuskript/functions/__init__.py
Curtis Gedak 5f9ea3baa5 Fix crash when setting word Goal on new Text (scene) in Outline pane
See issue #561.

The problem appears to be a due to a combination of factors, such as:

- Python does not automatically convert an empty/blank variable to the
  integer zero (0)
- Default goal value is empty/blank for a new Text (scene)
- Asynchronous events can occur such that the change in the Outline
  pane of a new Text (scene) goal from empty/blank to a value is not
  saved to the data model prior to the update event in the Editor pane
  accessing the model value for the word count progress display.

Steps to Reproduce:

1. Start manuskript and create new project (no template).

2. Select **Outline** pane.

3. Click "Text Plus" icon to create a text (default name "New")

4. Select **Editor** pane.

5. Click on **New** to display empty text.

6. Select **Outline** pane.

7. Double-click the empty area on **New** line under title **Goal**,
  type in "300", and press **Enter**.

   Note that manuskript crashes with a segmentation fault.

Work around the crash by using the already existing manuskript
function toInt() which handles conversion of empty/blank values to
integer value zero (0).
2019-07-31 10:46:06 -06:00

404 lines
12 KiB
Python

#!/usr/bin/env python
#--!-- coding: utf8 --!--
import os
import re
from random import *
from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir
from PyQt5.QtCore import QUrl, QTimer
from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import qApp, QTextEdit
from manuskript.enums import Outline
# Used to detect multiple connections
AUC = Qt.AutoConnection | Qt.UniqueConnection
MW = None
def wordCount(text):
t = text.strip().replace(" ", "\n").split("\n")
t = [l for l in t if l]
return len(t)
def toInt(text):
if text:
try:
return int(text)
except ValueError:
pass
return 0
def toFloat(text):
if text:
return float(text)
else:
return 0.
def toString(text):
if text in [None, "None"]:
return ""
else:
return str(text)
def drawProgress(painter, rect, progress, radius=0):
from manuskript.ui import style as S
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(S.base)) # "#dddddd"
painter.drawRoundedRect(rect, radius, radius)
painter.setBrush(QBrush(colorFromProgress(progress)))
r2 = QRect(rect)
r2.setWidth(r2.width() * min(toInt(progress), 1))
# ^^^^^ Avoid crash - issue #561
painter.drawRoundedRect(r2, radius, radius)
def colorFromProgress(progress):
progress = toFloat(progress)
c1 = QColor(Qt.red)
c2 = QColor(Qt.blue)
c3 = QColor(Qt.darkGreen)
c4 = QColor("#FFA500")
if progress < 0.3:
return c1
elif progress < 0.8:
return c2
elif progress > 1.2:
return c4
else:
return c3
def mainWindow():
global MW
if not MW:
for i in qApp.topLevelWidgets():
if i.objectName() == "MainWindow":
MW = i
return MW
return None
else:
return MW
def iconColor(icon):
"""Returns a QRgb from a QIcon, assuming its all the same color"""
px = icon.pixmap(5, 5)
if px.width() != 0:
return QColor(QImage(px).pixel(2, 2))
else:
return QColor(Qt.transparent)
def iconFromColor(color):
px = QPixmap(32, 32)
px.fill(color)
return QIcon(px)
def iconFromColorString(string):
return iconFromColor(QColor(string))
def themeIcon(name):
"Returns an icon for the given name."
db = {
"character": "stock_people",
"characters": "stock_people",
"plot": "stock_shuffle",
"plots": "stock_shuffle",
"world": "emblem-web", #stock_timezone applications-internet
"outline": "gtk-index", #applications-versioncontrol
"label": "folder_color_picker",
"status": "applications-development",
"text": "view-text",
"card": "view-card",
"outline": "view-outline",
"tree": "view-list-tree",
"spelling": "tools-check-spelling"
}
if name in db:
return QIcon.fromTheme(db[name])
else:
return QIcon()
def randomColor(mix=None):
"""Generates a random color. If mix (QColor) is given, mixes the random color and mix."""
r = randint(0, 255)
g = randint(0, 255)
b = randint(0, 255)
if mix:
r = (r + mix.red()) / 2
g = (g + mix.green()) / 2
b = (b + mix.blue()) / 2
return QColor(r, g, b)
def mixColors(col1, col2, f=.5):
fromString = False
if type(col1) == str:
fromString = True
col1 = QColor(col1)
if type(col2) == str:
col2 = QColor(col2)
f2 = 1-f
r = col1.red() * f + col2.red() * f2
g = col1.green() * f + col2.green() * f2
b = col1.blue() * f + col2.blue() * f2
return QColor(r, g, b) if not fromString else QColor(r, g, b).name()
def outlineItemColors(item):
from manuskript.ui import style as S
"""Takes an OutlineItem and returns a dict of colors."""
colors = {}
mw = mainWindow()
# POV
colors["POV"] = QColor(Qt.transparent)
POV = item.data(Outline.POV)
if POV == "":
col = QColor(Qt.transparent)
else:
for i in range(mw.mdlCharacter.rowCount()):
if mw.mdlCharacter.ID(i) == POV:
colors["POV"] = iconColor(mw.mdlCharacter.icon(i))
# Label
lbl = item.data(Outline.label)
if lbl == "":
col = QColor(Qt.transparent)
else:
col = iconColor(mw.mdlLabels.item(toInt(lbl)).icon())
# if col == Qt.black:
# # Don't know why, but transparent is rendered as black
# col = QColor(Qt.transparent)
colors["Label"] = col
# Progress
pg = item.data(Outline.goalPercentage)
colors["Progress"] = colorFromProgress(pg)
# Compile
if item.compile() in [0, "0"]:
colors["Compile"] = mixColors(QColor(S.text), QColor(S.window))
else:
colors["Compile"] = QColor(Qt.transparent) # will use default
return colors
def colorifyPixmap(pixmap, color):
# FIXME: ugly
p = QPainter(pixmap)
p.setCompositionMode(p.CompositionMode_Overlay)
p.fillRect(pixmap.rect(), color)
return pixmap
def appPath(suffix=None):
p = os.path.realpath(os.path.join(os.path.split(__file__)[0], "../.."))
if suffix:
p = os.path.join(p, suffix)
return p
def writablePath(suffix=None):
if hasattr(QStandardPaths, "AppLocalDataLocation"):
p = QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)
else:
# Qt < 5.4
p = QStandardPaths.writableLocation(QStandardPaths.DataLocation)
if suffix:
p = os.path.join(p, suffix)
if not os.path.exists(p):
os.makedirs(p)
return p
def allPaths(suffix=None):
paths = []
# src directory
paths.append(appPath(suffix))
# user writable directory
paths.append(writablePath(suffix))
return paths
def tempFile(name):
"Returns a temp file."
return os.path.join(QDir.tempPath(), name)
def totalObjects():
return len(mainWindow().findChildren(QObject))
def printObjects():
print("Objects:", str(totalObjects()))
def findWidgetsOfClass(cls):
"""
Returns all widgets, children of MainWindow, whose class is cls.
@param cls: a class
@return: list of QWidgets
"""
return mainWindow().findChildren(cls, QRegExp())
def findBackground(filename):
"""
Returns the full path to a background file of name filename within resources folders.
"""
return findFirstFile(re.escape(filename), "resources/backgrounds")
def findFirstFile(regex, path="resources"):
"""
Returns full path of first file matching regular expression regex within folder path,
otherwise returns full path of last file in folder path.
"""
paths = allPaths(path)
for p in paths:
lst = os.listdir(p)
for l in lst:
if re.match(regex, l):
return os.path.join(p, l)
def customIcons():
"""
Returns a list of possible customIcons. String from theme.
"""
r = [
"text-plain",
"gnome-settings",
"applications-internet",
"applications-debugging",
"applications-development",
"system-help",
"info",
"dialog-question",
"dialog-warning",
"stock_timezone",
"stock_people",
"stock_shuffle",
"gtk-index",
"folder_color_picker",
"applications-versioncontrol",
"stock_home",
"stock_trash_empty",
"stock_trash_full",
"stock_yes",
"stock_no",
"stock_notes",
"stock_calendar",
"stock_mic",
'stock_score-lowest', 'stock_score-lower', 'stock_score-low', 'stock_score-normal', 'stock_score-high', 'stock_score-higher', 'stock_score-highest',
"stock_task",
"stock_refresh",
"application-community",
"applications-chat",
"application-menu",
"applications-education",
"applications-science",
"applications-puzzles",
"applications-roleplaying",
"applications-sports",
"applications-libraries",
"applications-publishing",
"applications-development",
"applications-games",
"applications-boardgames",
"applications-geography",
"applications-physics",
"package_multimedia",
"media-flash",
"media-optical",
"media-floppy",
"media-playback-start",
"media-playback-pause",
"media-playback-stop",
"media-playback-record",
"media-playback-start-rtl",
"media-eject",
"document-save",
"gohome",
'purple-folder', 'yellow-folder', 'red-folder', 'custom-folder', 'grey-folder', 'blue-folder', 'default-folder', 'pink-folder', 'orange-folder', 'green-folder', 'brown-folder',
'folder-home', 'folder-remote', 'folder-music', 'folder-saved-search', 'folder-projects', 'folder-sound', 'folder-publicshare', 'folder-pictures', 'folder-saved-search-alt', 'folder-tag',
'calendar-01', 'calendar-02', 'calendar-03', 'calendar-04', 'calendar-05', 'calendar-06', 'calendar-07', 'calendar-08', 'calendar-09', 'calendar-10',
'arrow-down', 'arrow-left', 'arrow-right', 'arrow-up', 'arrow-down-double', 'arrow-left-double', 'arrow-right-double', 'arrow-up-double',
'emblem-added', 'emblem-checked', 'emblem-downloads', 'emblem-dropbox-syncing', 'emblem-danger', 'emblem-development', 'emblem-dropbox-app', 'emblem-art', 'emblem-camera', 'emblem-dropbox-selsync', 'emblem-insync-des-error', 'emblem-insync-error', 'emblem-generic', 'emblem-favorites', 'emblem-error', 'emblem-dropbox-uptodate', 'emblem-marketing', 'emblem-money', 'emblem-music', 'emblem-noread', 'emblem-people', 'emblem-personal', 'emblem-sound', 'emblem-shared', 'emblem-sales', 'emblem-presentation', 'emblem-plan', 'emblem-system', 'emblem-urgent', 'emblem-videos', 'emblem-web',
'face-angel', 'face-clown', 'face-angry', 'face-cool', 'face-devilish', 'face-sick', 'face-sleeping', 'face-uncertain', 'face-monkey', 'face-ninja', 'face-pirate', 'face-glasses', 'face-in-love', 'face-confused',
'feed-marked-symbolic', 'feed-non-starred', 'feed-starred', 'feed-unmarked-symbolic',
'notification-new-symbolic',
]
return sorted(r)
def statusMessage(message, duration=5000, importance=1):
"""
Shows a message in MainWindow's status bar.
Importance: 0 = low, 1 = normal, 2 = important, 3 = critical.
"""
from manuskript.ui import style as S
MW.statusBar().hide()
MW.statusLabel.setText(message)
if importance == 0:
MW.statusLabel.setStyleSheet("color:{};".format(S.textLighter))
elif importance == 1:
MW.statusLabel.setStyleSheet("color:{};".format(S.textLight))
elif importance == 2:
MW.statusLabel.setStyleSheet("color:{}; font-weight: bold;".format(S.text))
elif importance == 3:
MW.statusLabel.setStyleSheet("color:red; font-weight: bold;")
MW.statusLabel.adjustSize()
g = MW.statusLabel.geometry()
# g.moveCenter(MW.mapFromGlobal(MW.geometry().center()))
s = MW.layout().spacing() / 2
g.setLeft(s)
g.moveBottom(MW.mapFromGlobal(MW.geometry().bottomLeft()).y() - s)
MW.statusLabel.setGeometry(g)
MW.statusLabel.show()
QTimer.singleShot(duration, MW.statusLabel.hide)
def openURL(url):
"""
Opens url (string) in browser using desktop default application.
"""
QDesktopServices.openUrl(QUrl(url))
def inspect():
"""
Debugging tool. Call it to see a stack of calls up to that point.
"""
import inspect, os
print("-----------------------")
for s in inspect.stack()[1:]:
print(" * {}:{} // {}".format(
os.path.basename(s.filename),
s.lineno,
s.function))
print(" " + "".join(s.code_context))
# Spellchecker loads writablePath from this file, so we need to load it after they get defined
from manuskript.functions.spellchecker import Spellchecker