Start work on allowing half-stars for custom rating columns

Still have to implement support in the bulk metadata editors and the
content server.
This commit is contained in:
Kovid Goyal 2016-09-04 16:47:35 +05:30
parent a8f7c27e40
commit baddd6c298
12 changed files with 238 additions and 83 deletions

View File

@ -4,24 +4,25 @@ FullName: calibre Symbols
FamilyName: calibre Symbols
Weight: Medium
Copyright: Created by Kovid Goyal with FontForge 2.0 (http://fontforge.sf.net)
UComments: "2012-2-27: Created."
UComments: "2012-2-27: Created."
Version: 001.000
ItalicAngle: 0
UnderlinePosition: -100
UnderlineWidth: 50
Ascent: 800
Descent: 200
InvalidEm: 0
LayerCount: 2
Layer: 0 0 "Back" 1
Layer: 1 0 "Fore" 0
NeedsXUIDChange: 1
Layer: 0 0 "Back" 1
Layer: 1 0 "Fore" 0
XUID: [1021 913 325894820 11538708]
StyleMap: 0x0000
FSType: 0
OS2Version: 0
OS2_WeightWidthSlopeOnly: 0
OS2_UseTypoMetrics: 1
CreationTime: 1330331997
ModificationTime: 1330487767
ModificationTime: 1472969125
OS2TypoAscent: 0
OS2TypoAOffset: 1
OS2TypoDescent: 0
@ -44,10 +45,10 @@ DisplaySize: -24
AntiAlias: 1
FitToEm: 1
WidthSeparation: 150
WinInfo: 9600 75 22
WinInfo: 0 152 34
BeginPrivate: 0
EndPrivate
BeginChars: 1114112 3
BeginChars: 1114112 4
StartChar: uni2605
Encoding: 9733 9733 0
@ -91,7 +92,7 @@ SplineSet
485.545 635.457 493.518 604.173 506.689 547.357 c 2
551.923 352.862 l 1
EndSplineSet
Validated: 524289
Validated: 1
EndChar
StartChar: zero
@ -148,5 +149,35 @@ SplineSet
EndSplineSet
Validated: 1
EndChar
StartChar: onehalf
Encoding: 189 189 3
Width: 979
VWidth: -26
Flags: WO
LayerCount: 2
Fore
SplineSet
466.134 74.71 m 1
320.554 -51.8184 l 2
274.802 -91.5547 249.758 -112.902 245.426 -115.866 c 0
241.092 -118.828 236.846 -120.31 232.688 -120.31 c 0
227.835 -120.31 223.415 -118.306 219.429 -114.297 c 0
215.442 -110.289 213.449 -105.844 213.449 -100.965 c 0
213.449 -97.8281 223.329 -71.3379 243.087 -21.4932 c 2
322.115 180.323 l 1
152.618 289.598 l 2
104.783 320.271 79.2217 337.176 75.9297 340.313 c 0
72.6357 343.45 70.9893 347.981 70.9893 353.907 c 0
70.9893 369.243 79.8291 376.912 97.5059 376.912 c 0
98.8926 376.912 123.155 374.82 170.296 370.638 c 2
379.825 352.862 l 1
427.14 555.201 l 2
439.271 607.834 446.811 636.764 449.757 641.992 c 0
452.702 647.221 458.162 649.834 466.134 649.834 c 4
474.454 649.834 466.134 74.71 466.134 74.71 c 1
EndSplineSet
Validated: 524321
EndChar
EndChars
EndSplineFont

Binary file not shown.

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python2
#!/usr/bin/env python2
# vim:fileencoding=utf-8
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
@ -393,3 +394,14 @@ def check_doi(doi):
return doi_check.group()
return None
def rating_to_stars(value, allow_half_star=False, star=u'', half=u'½'):
r = max(0, min(int(value), 10))
if allow_half_star:
ans = u'' * (r // 2)
if r % 2:
ans += u'½'
else:
ans = u'' * int(r/2.0)
return ans

View File

@ -106,11 +106,16 @@ def mi_to_html(mi, field_list=None, default_author_link=None, use_roman_numbers=
elif metadata['datatype'] == 'rating':
val = getattr(mi, field)
if val:
val = val/2.0
if disp.get('allow_half_stars'):
val = max(0, min(int(val), 10))
star_string = u'\u2605' * (val // 2) + (u'\u00bd' if val % 2 else '')
else:
val = max(0, min(int(val/2.0), 5))
star_string = u'\u2605' * val
ans.append((field,
u'<td class="title">%s</td><td class="rating value" '
'style=\'font-family:"%s"\'>%s</td>'%(
name, rating_font, u'\u2605'*int(val))))
name, rating_font, star_string)))
elif metadata['datatype'] == 'composite':
val = getattr(mi, field)
if val:

View File

@ -21,6 +21,7 @@ from calibre.utils.config import tweaks
from calibre.utils.icu import sort_key
from calibre.library.comments import comments_to_html
from calibre.gui2.library.delegates import ClearingDoubleSpinBox, ClearingSpinBox
from calibre.gui2.widgets2 import RatingEditor
class Base(object):
@ -158,27 +159,18 @@ class Float(Int):
val = self.widgets[1].minimum()
self.widgets[1].setValue(val)
class Rating(Int):
class Rating(Base):
def setup_ui(self, parent):
Int.setup_ui(self, parent)
w = self.widgets[1]
w.setRange(0, 5)
w.setSuffix(' '+_('star(s)'))
w.setSpecialValueText(_('Not rated'))
allow_half_stars = self.col_metadata['display'].get('allow_half_stars', False)
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent), RatingEditor(parent=parent, is_half_star=allow_half_stars)]
def setter(self, val):
if val is None:
val = 0
self.widgets[1].setValue(int(round(val/2.)))
val = max(0, min(int(val or 0), 10))
self.widgets[1].rating_value = val
def getter(self):
val = self.widgets[1].value()
if val == 0:
val = None
else:
val *= 2
return val
return self.widgets[1].rating_value or None
class DateTimeEdit(QDateTimeEdit):

View File

@ -12,10 +12,11 @@ from PyQt5.Qt import (Qt, QApplication, QStyle, QIcon, QDoubleSpinBox, QStyleOp
QAbstractTextDocumentLayout, QFont, QFontInfo, QDate, QDateTimeEdit, QDateTime,
QStyleOptionComboBox, QStyleOptionSpinBox, QLocale, QSize, QLineEdit)
from calibre.ebooks.metadata import rating_to_stars
from calibre.gui2 import UNDEFINED_QDATETIME, rating_font
from calibre.constants import iswindows
from calibre.gui2.widgets import EnLineEdit
from calibre.gui2.widgets2 import populate_standard_spinbox_context_menu
from calibre.gui2.widgets2 import populate_standard_spinbox_context_menu, RatingEditor
from calibre.gui2.complete2 import EditWithComplete
from calibre.utils.date import now, format_date, qt_to_dt, is_date_undefined
from calibre.utils.config import tweaks
@ -176,6 +177,7 @@ class RatingDelegate(QStyledItemDelegate, UpdateEditorGeometry): # {{{
def __init__(self, *args, **kwargs):
QStyledItemDelegate.__init__(self, *args, **kwargs)
self.is_half_star = kwargs.get('is_half_star', False)
self.table_widget = args[0]
self.rf = QFont(rating_font())
self.em = Qt.ElideMiddle
@ -184,33 +186,25 @@ class RatingDelegate(QStyledItemDelegate, UpdateEditorGeometry): # {{{
delta = 2
self.rf.setPointSize(QFontInfo(QApplication.font()).pointSize()+delta)
def createEditor(self, parent, option, index):
sb = QSpinBox(parent)
sb.setMinimum(0)
sb.setMaximum(5)
sb.setSuffix(' ' + _('stars'))
sb.setSpecialValueText(_('Not rated'))
return sb
def get_required_width(self, editor, style, fm):
val = editor.maximum()
text = editor.textFromValue(val) + editor.suffix()
srect = style.itemTextRect(fm, editor.geometry(), Qt.AlignLeft, False,
text + u'M')
return srect.width()
return editor.sizeHint().width()
def displayText(self, value, locale):
r = int(value)
if r < 0 or r > 5:
r = 0
return u'\u2605'*r
return rating_to_stars(value, self.is_half_star)
def createEditor(self, parent, option, index):
return RatingEditor(parent, is_half_star=self.is_half_star)
def setEditorData(self, editor, index):
if check_key_modifier(Qt.ControlModifier):
val = 0
else:
val = index.data(Qt.EditRole)
editor.setValue(val)
editor.rating_value = val
def setModelData(self, editor, model, index):
val = editor.rating_value
model.setData(index, val, Qt.EditRole)
def sizeHint(self, option, index):
option.font = self.rf
@ -224,6 +218,7 @@ class RatingDelegate(QStyledItemDelegate, UpdateEditorGeometry): # {{{
# }}}
class DateDelegate(QStyledItemDelegate, UpdateEditorGeometry): # {{{
def __init__(self, parent, tweak_name='gui_timestamp_display_format',

View File

@ -709,6 +709,7 @@ class BooksModel(QAbstractTableModel): # {{{
return img
def build_data_convertors(self):
rating_fields = {}
def renderer(field, decorator=False):
idfunc = self.db.id
@ -776,8 +777,9 @@ class BooksModel(QAbstractTableModel): # {{{
def func(idx):
return (QDateTime(as_local_time(fffunc(field_obj, idfunc(idx), default_value=UNDEFINED_DATE))))
elif dt == 'rating':
rating_fields[field] = m['display'].get('allow_half_stars', False)
def func(idx):
return (int(fffunc(field_obj, idfunc(idx), default_value=0)/2.0))
return int(fffunc(field_obj, idfunc(idx), default_value=0))
elif dt == 'series':
sidx_field = self.db.new_api.fields[field + '_index']
def func(idx):
@ -817,8 +819,20 @@ class BooksModel(QAbstractTableModel): # {{{
elif dt == 'bool':
self.dc_decorator[col] = renderer(col, 'bool')
tc = self.dc.copy()
def stars_tooltip(func, allow_half=True):
def f(idx):
ans = val = int(func(idx))
ans = str(val // 2)
if allow_half and val % 2:
ans += '.5'
return _('%s stars') % ans
return f
for f, allow_half in rating_fields.iteritems():
tc[f] = stars_tooltip(self.dc[f], allow_half)
# build a index column to data converter map, to remove the string lookup in the data loop
self.column_to_dc_map = [self.dc[col] for col in self.column_map]
self.column_to_tc_map = [tc[col] for col in self.column_map]
self.column_to_dc_decorator_map = [self.dc_decorator.get(col, None) for col in self.column_map]
def data(self, index, role):
@ -850,7 +864,9 @@ class BooksModel(QAbstractTableModel): # {{{
return None
self.icon_cache[id_][cache_index] = None
return self.column_to_dc_map[col](index.row())
elif role in (Qt.EditRole, Qt.ToolTipRole):
elif role == Qt.ToolTipRole:
return self.column_to_tc_map[col](index.row())
elif role == Qt.EditRole:
return self.column_to_dc_map[col](index.row())
elif role == Qt.BackgroundRole:
if self.id(index) in self.ids_to_highlight_set:
@ -996,9 +1012,7 @@ class BooksModel(QAbstractTableModel): # {{{
elif typ == 'bool':
val = value if value is None else bool(value)
elif typ == 'rating':
val = int(value)
val = 0 if val < 0 else 5 if val > 5 else val
val *= 2
val = max(0, min(int(value or 0), 10))
elif typ in ('int', 'float'):
if value == 0:
val = '0'
@ -1089,8 +1103,7 @@ class BooksModel(QAbstractTableModel): # {{{
id = self.db.id(row)
books_to_refresh = set([id])
if column == 'rating':
val = 0 if val < 0 else 5 if val > 5 else val
val *= 2
val = max(0, min(int(val or 0), 10))
self.db.set_rating(id, val)
elif column == 'series':
val = val.strip()

View File

@ -224,6 +224,7 @@ class BooksView(QTableView): # {{{
self.setWordWrap(False)
self.rating_delegate = RatingDelegate(self)
self.half_rating_delegate = RatingDelegate(self, is_half_star=True)
self.timestamp_delegate = DateDelegate(self)
self.pubdate_delegate = PubDateDelegate(self)
self.last_modified_delegate = DateDelegate(self,
@ -760,9 +761,9 @@ class BooksView(QTableView): # {{{
def database_changed(self, db):
db.data.add_marked_listener(self.marked_changed_listener)
for i in range(self.model().columnCount(None)):
if self.itemDelegateForColumn(i) in (self.rating_delegate,
self.timestamp_delegate, self.pubdate_delegate,
self.last_modified_delegate, self.languages_delegate):
if self.itemDelegateForColumn(i) in (
self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate,
self.last_modified_delegate, self.languages_delegate, self.half_rating_delegate):
self.setItemDelegateForColumn(i, self.itemDelegate())
cm = self.column_map
@ -799,7 +800,8 @@ class BooksView(QTableView): # {{{
elif cc['datatype'] == 'bool':
self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate)
elif cc['datatype'] == 'rating':
self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate)
d = self.half_rating_delegate if cc['display'].get('allow_half_stars', False) else self.rating_delegate
self.setItemDelegateForColumn(cm.index(colhead), d)
elif cc['datatype'] == 'composite':
self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate)
elif cc['datatype'] == 'enumeration':
@ -1126,6 +1128,7 @@ class DeviceBooksView(BooksView): # {{{
self.can_add_columns = False
self.resize_on_select = False
self.rating_delegate = None
self.half_rating_delegate = None
for i in range(10):
self.setItemDelegateForColumn(i, TextDelegate(self))
self.setDragDropMode(self.NoDragDrop)

View File

@ -13,12 +13,12 @@ from datetime import date, datetime
from PyQt5.Qt import (
Qt, QDateTimeEdit, pyqtSignal, QMessageBox, QIcon, QToolButton, QWidget,
QLabel, QGridLayout, QApplication, QDoubleSpinBox, QListWidgetItem, QSize,
QPixmap, QDialog, QMenu, QSpinBox, QLineEdit, QSizePolicy, QKeySequence,
QPixmap, QDialog, QMenu, QLineEdit, QSizePolicy, QKeySequence,
QDialogButtonBox, QAction, QCalendarWidget, QDate, QDateTime, QUndoCommand,
QUndoStack, QVBoxLayout, QPlainTextEdit)
from calibre.gui2.widgets import EnLineEdit, FormatList as _FormatList, ImageView
from calibre.gui2.widgets2 import access_key, populate_standard_spinbox_context_menu, RightClickButton, Dialog
from calibre.gui2.widgets2 import access_key, populate_standard_spinbox_context_menu, RightClickButton, Dialog, RatingEditor
from calibre.utils.icu import sort_key
from calibre.utils.config import tweaks, prefs
from calibre.ebooks.metadata import (
@ -1231,7 +1231,7 @@ class CommentsEdit(Editor, ToMetadataMixin): # {{{
db.set_comment(id_, self.current_val, notify=False, commit=False)
# }}}
class RatingEdit(make_undoable(QSpinBox), ToMetadataMixin): # {{{
class RatingEdit(RatingEditor, ToMetadataMixin): # {{{
LABEL = _('&Rating:')
TOOLTIP = _('Rating of this book. 0-5 stars')
FIELD_NAME = 'rating'
@ -1240,40 +1240,26 @@ class RatingEdit(make_undoable(QSpinBox), ToMetadataMixin): # {{{
super(RatingEdit, self).__init__(parent)
self.setToolTip(self.TOOLTIP)
self.setWhatsThis(self.TOOLTIP)
self.setMaximum(5)
self.setSuffix(' ' + _('stars'))
self.setSpecialValueText(_('Not rated'))
@dynamic_property
def current_val(self):
def fget(self):
return self.value()
return self.rating_value
def fset(self, val):
if val is None:
val = 0
val = int(val)
if val < 0:
val = 0
if val > 5:
val = 5
self.set_spinbox_value(val)
self.rating_value = val
return property(fget=fget, fset=fset)
def initialize(self, db, id_):
val = db.rating(id_, index_is_id=True)
if val > 0:
val = int(val/2.)
else:
val = 0
self.current_val = val
self.original_val = self.current_val
def commit(self, db, id_):
db.set_rating(id_, 2*self.current_val, notify=False, commit=False)
db.set_rating(id_, self.current_val, notify=False, commit=False)
return True
def zero(self):
self.setValue(0)
self.setCurrentIndex(0)
# }}}

View File

@ -436,10 +436,7 @@ class MetadataSingleDialogBase(QDialog):
elif update_sorts and not mi.is_null('authors'):
self.author_sort.auto_generate()
if not mi.is_null('rating'):
try:
self.rating.set_value(mi.rating)
except:
pass
self.rating.set_value(mi.rating * 2)
if not mi.is_null('publisher'):
self.publisher.set_value(mi.publisher)
if not mi.is_null('tags'):

View File

@ -1,3 +1,6 @@
#!/usr/bin/env python2
# vim:fileencoding=UTF-8
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid at kovidgoyal.net>'
@ -168,6 +171,8 @@ class CreateCustomColumn(QDialog):
self.comments_heading_position.setCurrentIndex(idx)
idx = max(0, self.comments_type.findData(c['display'].get('interpret_as', 'html')))
self.comments_type.setCurrentIndex(idx)
elif ct == 'rating':
self.allow_half_stars.setChecked(bool(c['display'].get('allow_half_stars', False)))
self.datatype_changed()
if ct in ['text', 'composite', 'enumeration']:
self.use_decorations.setChecked(c['display'].get('use_decorations', False))
@ -350,6 +355,11 @@ class CreateCustomColumn(QDialog):
l.addWidget(ec), l.addWidget(la, 1, 1)
self.enum_label = add_row(_('&Values'), l)
# Rating allow half stars
self.allow_half_stars = ahs = QCheckBox(_('Allow half stars'))
ahs.setToolTip(_('Allow half star ratings, for example: ') + '<span style="font-family:calibre Symbols">★★★½</span>')
add_row(None, ahs)
# Composite display properties
l = QHBoxLayout()
self.composite_sort_by_label = la = QLabel(_("&Sort/search column by"))
@ -427,6 +437,7 @@ class CreateCustomColumn(QDialog):
self.comments_heading_position_label.setVisible(is_comments)
self.comments_type.setVisible(is_comments)
self.comments_type_label.setVisible(is_comments)
self.allow_half_stars.setVisible(col_type == 'rating')
def accept(self):
col = unicode(self.column_name_box.text()).strip()
@ -524,6 +535,8 @@ class CreateCustomColumn(QDialog):
elif col_type == 'comments':
display_dict['heading_position'] = type(u'')(self.comments_heading_position.currentData())
display_dict['interpret_as'] = type(u'')(self.comments_type.currentData())
elif col_type == 'rating':
display_dict['allow_half_stars'] = bool(self.allow_half_stars.isChecked())
if col_type in ['text', 'composite', 'enumeration'] and not is_multiple:
display_dict['use_decorations'] = self.use_decorations.checkState()

View File

@ -6,11 +6,16 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import weakref
from PyQt5.Qt import (
QPushButton, QPixmap, QIcon, QColor, Qt, QColorDialog, pyqtSignal,
QKeySequence, QToolButton, QDialog, QDialogButtonBox)
QKeySequence, QToolButton, QDialog, QDialogButtonBox, QComboBox, QFont,
QAbstractListModel, QModelIndex, QApplication, QStyledItemDelegate,
QUndoCommand, QUndoStack)
from calibre.gui2 import gprefs
from calibre.ebooks.metadata import rating_to_stars
from calibre.gui2 import gprefs, rating_font
from calibre.gui2.complete2 import LineEdit, EditWithComplete
from calibre.gui2.widgets import history
@ -180,3 +185,106 @@ class Dialog(QDialog):
def setup_ui(self):
raise NotImplementedError('You must implement this method in Dialog subclasses')
class RatingModel(QAbstractListModel):
def __init__(self, parent=None, is_half_star=False):
QAbstractListModel.__init__(self, parent)
self.is_half_star = is_half_star
self.rating_font = QFont(rating_font())
def rowCount(self, parent=QModelIndex()):
return 11 if self.is_half_star else 6
def data(self, index, role=Qt.DisplayRole):
if role == Qt.DisplayRole:
val = index.row() * (1 if self.is_half_star else 2)
return rating_to_stars(val, self.is_half_star) or _('Not rated')
if role == Qt.FontRole:
return QApplication.instance().font() if index.row() == 0 else self.rating_font
class UndoCommand(QUndoCommand):
def __init__(self, widget, val):
QUndoCommand.__init__(self)
self.widget = weakref.ref(widget)
self.undo_val = widget.rating_value
self.redo_val = val
def undo(self):
w = self.widget()
w.setCurrentIndex(self.undo_val)
def redo(self):
w = self.widget()
w.setCurrentIndex(self.redo_val)
class RatingEditor(QComboBox):
def __init__(self, parent=None, is_half_star=False):
QComboBox.__init__(self, parent)
self.undo_stack = QUndoStack(self)
self.undo, self.redo = self.undo_stack.undo, self.undo_stack.redo
self.allow_undo = False
self.is_half_star = is_half_star
self._model = RatingModel(is_half_star=is_half_star, parent=self)
self.setModel(self._model)
self.delegate = QStyledItemDelegate(self)
self.view().setItemDelegate(self.delegate)
self.view().setStyleSheet('QListView { background: palette(window) }\nQListView::item { padding: 6px }')
self.setMaxVisibleItems(self.count())
self.currentIndexChanged.connect(self.update_font)
def update_font(self):
if self.currentIndex() == 0:
self.setFont(QApplication.instance().font())
else:
self.setFont(self._model.rating_font)
def clear_to_undefined(self):
self.setCurrentIndex(0)
@property
def rating_value(self):
' An integer from 0 to 10 '
ans = self.currentIndex()
if not self.is_half_star:
ans *= 2
return ans
@rating_value.setter
def rating_value(self, val):
val = max(0, min(int(val or 0), 10))
if self.allow_undo:
cmd = UndoCommand(self, val)
self.undo_stack.push(cmd)
else:
self.undo_stack.clear()
if not self.is_half_star:
val //= 2
self.setCurrentIndex(val)
def keyPressEvent(self, ev):
if ev == QKeySequence.Undo:
self.undo()
return ev.accept()
if ev == QKeySequence.Redo:
self.redo()
return ev.accept()
k = ev.key()
num = {getattr(Qt, 'Key_%d'%i):i for i in range(6)}.get(k)
if num is None:
return QComboBox.keyPressEvent(self, ev)
ev.accept()
if self.is_half_star:
num *= 2
self.setCurrentIndex(num)
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application([])
app.load_builtin_fonts()
q = RatingEditor(is_half_star=True)
q.rating_value = 7
q.show()
app.exec_()