labelimgplusplus 2.0.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labelimgplusplus-2.0.0a0.dist-info/LICENSE +9 -0
- labelimgplusplus-2.0.0a0.dist-info/METADATA +282 -0
- labelimgplusplus-2.0.0a0.dist-info/RECORD +30 -0
- labelimgplusplus-2.0.0a0.dist-info/WHEEL +5 -0
- labelimgplusplus-2.0.0a0.dist-info/entry_points.txt +2 -0
- labelimgplusplus-2.0.0a0.dist-info/top_level.txt +1 -0
- libs/__init__.py +2 -0
- libs/canvas.py +748 -0
- libs/colorDialog.py +37 -0
- libs/combobox.py +33 -0
- libs/commands.py +328 -0
- libs/constants.py +26 -0
- libs/create_ml_io.py +135 -0
- libs/default_label_combobox.py +27 -0
- libs/galleryWidget.py +568 -0
- libs/hashableQListWidgetItem.py +28 -0
- libs/labelDialog.py +95 -0
- libs/labelFile.py +174 -0
- libs/lightWidget.py +33 -0
- libs/pascal_voc_io.py +171 -0
- libs/resources.py +4212 -0
- libs/settings.py +45 -0
- libs/shape.py +209 -0
- libs/stringBundle.py +78 -0
- libs/styles.py +82 -0
- libs/toolBar.py +275 -0
- libs/ustr.py +17 -0
- libs/utils.py +119 -0
- libs/yolo_io.py +143 -0
- libs/zoomWidget.py +26 -0
libs/settings.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pickle
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(object):
|
|
6
|
+
def __init__(self):
|
|
7
|
+
# Be default, the home will be in the same folder as labelImg
|
|
8
|
+
home = os.path.expanduser("~")
|
|
9
|
+
self.data = {}
|
|
10
|
+
self.path = os.path.join(home, '.labelImgSettings.pkl')
|
|
11
|
+
|
|
12
|
+
def __setitem__(self, key, value):
|
|
13
|
+
self.data[key] = value
|
|
14
|
+
|
|
15
|
+
def __getitem__(self, key):
|
|
16
|
+
return self.data[key]
|
|
17
|
+
|
|
18
|
+
def get(self, key, default=None):
|
|
19
|
+
if key in self.data:
|
|
20
|
+
return self.data[key]
|
|
21
|
+
return default
|
|
22
|
+
|
|
23
|
+
def save(self):
|
|
24
|
+
if self.path:
|
|
25
|
+
with open(self.path, 'wb') as f:
|
|
26
|
+
pickle.dump(self.data, f, pickle.HIGHEST_PROTOCOL)
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def load(self):
|
|
31
|
+
try:
|
|
32
|
+
if os.path.exists(self.path):
|
|
33
|
+
with open(self.path, 'rb') as f:
|
|
34
|
+
self.data = pickle.load(f)
|
|
35
|
+
return True
|
|
36
|
+
except:
|
|
37
|
+
print('Loading setting failed')
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def reset(self):
|
|
41
|
+
if os.path.exists(self.path):
|
|
42
|
+
os.remove(self.path)
|
|
43
|
+
print('Remove setting pkl file ${0}'.format(self.path))
|
|
44
|
+
self.data = {}
|
|
45
|
+
self.path = None
|
libs/shape.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from PyQt5.QtGui import *
|
|
7
|
+
from PyQt5.QtCore import *
|
|
8
|
+
except ImportError:
|
|
9
|
+
from PyQt4.QtGui import *
|
|
10
|
+
from PyQt4.QtCore import *
|
|
11
|
+
|
|
12
|
+
from libs.utils import distance
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
DEFAULT_LINE_COLOR = QColor(0, 255, 0, 128)
|
|
16
|
+
DEFAULT_FILL_COLOR = QColor(255, 0, 0, 128)
|
|
17
|
+
DEFAULT_SELECT_LINE_COLOR = QColor(255, 255, 255)
|
|
18
|
+
DEFAULT_SELECT_FILL_COLOR = QColor(0, 128, 255, 155)
|
|
19
|
+
DEFAULT_VERTEX_FILL_COLOR = QColor(0, 255, 0, 255)
|
|
20
|
+
DEFAULT_HVERTEX_FILL_COLOR = QColor(255, 0, 0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Shape(object):
|
|
24
|
+
P_SQUARE, P_ROUND = range(2)
|
|
25
|
+
|
|
26
|
+
MOVE_VERTEX, NEAR_VERTEX = range(2)
|
|
27
|
+
|
|
28
|
+
# The following class variables influence the drawing
|
|
29
|
+
# of _all_ shape objects.
|
|
30
|
+
line_color = DEFAULT_LINE_COLOR
|
|
31
|
+
fill_color = DEFAULT_FILL_COLOR
|
|
32
|
+
select_line_color = DEFAULT_SELECT_LINE_COLOR
|
|
33
|
+
select_fill_color = DEFAULT_SELECT_FILL_COLOR
|
|
34
|
+
vertex_fill_color = DEFAULT_VERTEX_FILL_COLOR
|
|
35
|
+
h_vertex_fill_color = DEFAULT_HVERTEX_FILL_COLOR
|
|
36
|
+
point_type = P_ROUND
|
|
37
|
+
point_size = 16
|
|
38
|
+
scale = 1.0
|
|
39
|
+
label_font_size = 8
|
|
40
|
+
|
|
41
|
+
def __init__(self, label=None, line_color=None, difficult=False, paint_label=False):
|
|
42
|
+
self.label = label
|
|
43
|
+
self.points = []
|
|
44
|
+
self.fill = False
|
|
45
|
+
self.selected = False
|
|
46
|
+
self.difficult = difficult
|
|
47
|
+
self.paint_label = paint_label
|
|
48
|
+
|
|
49
|
+
self._highlight_index = None
|
|
50
|
+
self._highlight_mode = self.NEAR_VERTEX
|
|
51
|
+
self._highlight_settings = {
|
|
52
|
+
self.NEAR_VERTEX: (4, self.P_ROUND),
|
|
53
|
+
self.MOVE_VERTEX: (1.5, self.P_SQUARE),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
self._closed = False
|
|
57
|
+
|
|
58
|
+
if line_color is not None:
|
|
59
|
+
# Override the class line_color attribute
|
|
60
|
+
# with an object attribute. Currently this
|
|
61
|
+
# is used for drawing the pending line a different color.
|
|
62
|
+
self.line_color = line_color
|
|
63
|
+
|
|
64
|
+
def close(self):
|
|
65
|
+
self._closed = True
|
|
66
|
+
|
|
67
|
+
def reach_max_points(self):
|
|
68
|
+
if len(self.points) >= 4:
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def add_point(self, point):
|
|
73
|
+
if not self.reach_max_points():
|
|
74
|
+
self.points.append(point)
|
|
75
|
+
|
|
76
|
+
def pop_point(self):
|
|
77
|
+
if self.points:
|
|
78
|
+
return self.points.pop()
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def is_closed(self):
|
|
82
|
+
return self._closed
|
|
83
|
+
|
|
84
|
+
def set_open(self):
|
|
85
|
+
self._closed = False
|
|
86
|
+
|
|
87
|
+
def paint(self, painter):
|
|
88
|
+
if self.points:
|
|
89
|
+
color = self.select_line_color if self.selected else self.line_color
|
|
90
|
+
pen = QPen(color)
|
|
91
|
+
# Try using integer sizes for smoother drawing(?)
|
|
92
|
+
pen.setWidth(max(1, int(round(2.0 / self.scale))))
|
|
93
|
+
painter.setPen(pen)
|
|
94
|
+
|
|
95
|
+
line_path = QPainterPath()
|
|
96
|
+
vertex_path = QPainterPath()
|
|
97
|
+
|
|
98
|
+
line_path.moveTo(self.points[0])
|
|
99
|
+
# Uncommenting the following line will draw 2 paths
|
|
100
|
+
# for the 1st vertex, and make it non-filled, which
|
|
101
|
+
# may be desirable.
|
|
102
|
+
# self.drawVertex(vertex_path, 0)
|
|
103
|
+
|
|
104
|
+
for i, p in enumerate(self.points):
|
|
105
|
+
line_path.lineTo(p)
|
|
106
|
+
self.draw_vertex(vertex_path, i)
|
|
107
|
+
if self.is_closed():
|
|
108
|
+
line_path.lineTo(self.points[0])
|
|
109
|
+
|
|
110
|
+
painter.drawPath(line_path)
|
|
111
|
+
painter.drawPath(vertex_path)
|
|
112
|
+
painter.fillPath(vertex_path, self.vertex_fill_color)
|
|
113
|
+
|
|
114
|
+
# Draw text at the top-left
|
|
115
|
+
if self.paint_label:
|
|
116
|
+
min_x = sys.maxsize
|
|
117
|
+
min_y = sys.maxsize
|
|
118
|
+
min_y_label = int(1.25 * self.label_font_size)
|
|
119
|
+
for point in self.points:
|
|
120
|
+
min_x = min(min_x, point.x())
|
|
121
|
+
min_y = min(min_y, point.y())
|
|
122
|
+
if min_x != sys.maxsize and min_y != sys.maxsize:
|
|
123
|
+
font = QFont()
|
|
124
|
+
font.setPointSize(self.label_font_size)
|
|
125
|
+
font.setBold(True)
|
|
126
|
+
painter.setFont(font)
|
|
127
|
+
if self.label is None:
|
|
128
|
+
self.label = ""
|
|
129
|
+
if min_y < min_y_label:
|
|
130
|
+
min_y += min_y_label
|
|
131
|
+
painter.drawText(int(min_x), int(min_y), self.label)
|
|
132
|
+
|
|
133
|
+
if self.fill:
|
|
134
|
+
color = self.select_fill_color if self.selected else self.fill_color
|
|
135
|
+
painter.fillPath(line_path, color)
|
|
136
|
+
|
|
137
|
+
def draw_vertex(self, path, i):
|
|
138
|
+
d = self.point_size / self.scale
|
|
139
|
+
shape = self.point_type
|
|
140
|
+
point = self.points[i]
|
|
141
|
+
if i == self._highlight_index:
|
|
142
|
+
size, shape = self._highlight_settings[self._highlight_mode]
|
|
143
|
+
d *= size
|
|
144
|
+
if self._highlight_index is not None:
|
|
145
|
+
self.vertex_fill_color = self.h_vertex_fill_color
|
|
146
|
+
else:
|
|
147
|
+
self.vertex_fill_color = Shape.vertex_fill_color
|
|
148
|
+
if shape == self.P_SQUARE:
|
|
149
|
+
path.addRect(point.x() - d / 2, point.y() - d / 2, d, d)
|
|
150
|
+
elif shape == self.P_ROUND:
|
|
151
|
+
path.addEllipse(point, d / 2.0, d / 2.0)
|
|
152
|
+
else:
|
|
153
|
+
assert False, "unsupported vertex shape"
|
|
154
|
+
|
|
155
|
+
def nearest_vertex(self, point, epsilon):
|
|
156
|
+
index = None
|
|
157
|
+
for i, p in enumerate(self.points):
|
|
158
|
+
dist = distance(p - point)
|
|
159
|
+
if dist <= epsilon:
|
|
160
|
+
index = i
|
|
161
|
+
epsilon = dist
|
|
162
|
+
return index
|
|
163
|
+
|
|
164
|
+
def contains_point(self, point):
|
|
165
|
+
return self.make_path().contains(point)
|
|
166
|
+
|
|
167
|
+
def make_path(self):
|
|
168
|
+
path = QPainterPath(self.points[0])
|
|
169
|
+
for p in self.points[1:]:
|
|
170
|
+
path.lineTo(p)
|
|
171
|
+
return path
|
|
172
|
+
|
|
173
|
+
def bounding_rect(self):
|
|
174
|
+
return self.make_path().boundingRect()
|
|
175
|
+
|
|
176
|
+
def move_by(self, offset):
|
|
177
|
+
self.points = [p + offset for p in self.points]
|
|
178
|
+
|
|
179
|
+
def move_vertex_by(self, i, offset):
|
|
180
|
+
self.points[i] = self.points[i] + offset
|
|
181
|
+
|
|
182
|
+
def highlight_vertex(self, i, action):
|
|
183
|
+
self._highlight_index = i
|
|
184
|
+
self._highlight_mode = action
|
|
185
|
+
|
|
186
|
+
def highlight_clear(self):
|
|
187
|
+
self._highlight_index = None
|
|
188
|
+
|
|
189
|
+
def copy(self):
|
|
190
|
+
shape = Shape("%s" % self.label)
|
|
191
|
+
shape.points = [p for p in self.points]
|
|
192
|
+
shape.fill = self.fill
|
|
193
|
+
shape.selected = self.selected
|
|
194
|
+
shape._closed = self._closed
|
|
195
|
+
if self.line_color != Shape.line_color:
|
|
196
|
+
shape.line_color = self.line_color
|
|
197
|
+
if self.fill_color != Shape.fill_color:
|
|
198
|
+
shape.fill_color = self.fill_color
|
|
199
|
+
shape.difficult = self.difficult
|
|
200
|
+
return shape
|
|
201
|
+
|
|
202
|
+
def __len__(self):
|
|
203
|
+
return len(self.points)
|
|
204
|
+
|
|
205
|
+
def __getitem__(self, key):
|
|
206
|
+
return self.points[key]
|
|
207
|
+
|
|
208
|
+
def __setitem__(self, key, value):
|
|
209
|
+
self.points[key] = value
|
libs/stringBundle.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
if items were added in files in the resources/strings folder,
|
|
5
|
+
then execute "pyrcc5 resources.qrc -o resources.py" in the root directory
|
|
6
|
+
and execute "pyrcc5 ../resources.qrc -o resources.py" in the libs directory
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import locale
|
|
12
|
+
from libs.ustr import ustr
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from PyQt5.QtCore import *
|
|
16
|
+
except ImportError:
|
|
17
|
+
if sys.version_info.major >= 3:
|
|
18
|
+
import sip
|
|
19
|
+
sip.setapi('QVariant', 2)
|
|
20
|
+
from PyQt4.QtCore import *
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StringBundle:
|
|
24
|
+
|
|
25
|
+
__create_key = object()
|
|
26
|
+
|
|
27
|
+
def __init__(self, create_key, locale_str):
|
|
28
|
+
assert(create_key == StringBundle.__create_key), "StringBundle must be created using StringBundle.getBundle"
|
|
29
|
+
self.id_to_message = {}
|
|
30
|
+
paths = self.__create_lookup_fallback_list(locale_str)
|
|
31
|
+
for path in paths:
|
|
32
|
+
self.__load_bundle(path)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get_bundle(cls, locale_str=None):
|
|
36
|
+
if locale_str is None:
|
|
37
|
+
try:
|
|
38
|
+
locale_str = locale.getdefaultlocale()[0] if locale.getdefaultlocale() and len(
|
|
39
|
+
locale.getdefaultlocale()) > 0 else os.getenv('LANG')
|
|
40
|
+
except:
|
|
41
|
+
print('Invalid locale')
|
|
42
|
+
locale_str = 'en'
|
|
43
|
+
|
|
44
|
+
return StringBundle(cls.__create_key, locale_str)
|
|
45
|
+
|
|
46
|
+
def get_string(self, string_id):
|
|
47
|
+
assert(string_id in self.id_to_message), "Missing string id : " + string_id
|
|
48
|
+
return self.id_to_message[string_id]
|
|
49
|
+
|
|
50
|
+
def __create_lookup_fallback_list(self, locale_str):
|
|
51
|
+
result_paths = []
|
|
52
|
+
base_path = ":/strings"
|
|
53
|
+
result_paths.append(base_path)
|
|
54
|
+
if locale_str is not None:
|
|
55
|
+
# Don't follow standard BCP47. Simple fallback
|
|
56
|
+
tags = re.split('[^a-zA-Z]', locale_str)
|
|
57
|
+
for tag in tags:
|
|
58
|
+
last_path = result_paths[-1]
|
|
59
|
+
result_paths.append(last_path + '-' + tag)
|
|
60
|
+
|
|
61
|
+
return result_paths
|
|
62
|
+
|
|
63
|
+
def __load_bundle(self, path):
|
|
64
|
+
PROP_SEPERATOR = '='
|
|
65
|
+
f = QFile(path)
|
|
66
|
+
if f.exists():
|
|
67
|
+
if f.open(QIODevice.ReadOnly | QFile.Text):
|
|
68
|
+
text = QTextStream(f)
|
|
69
|
+
text.setCodec("UTF-8")
|
|
70
|
+
|
|
71
|
+
while not text.atEnd():
|
|
72
|
+
line = ustr(text.readLine())
|
|
73
|
+
key_value = line.split(PROP_SEPERATOR)
|
|
74
|
+
key = key_value[0].strip()
|
|
75
|
+
value = PROP_SEPERATOR.join(key_value[1:]).strip().strip('"')
|
|
76
|
+
self.id_to_message[key] = value
|
|
77
|
+
|
|
78
|
+
f.close()
|
libs/styles.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# libs/styles.py
|
|
2
|
+
"""Modern stylesheet definitions for labelImg++."""
|
|
3
|
+
|
|
4
|
+
# Toolbar stylesheet with modern flat design
|
|
5
|
+
TOOLBAR_STYLE = """
|
|
6
|
+
QToolBar {
|
|
7
|
+
background: #f5f5f5;
|
|
8
|
+
border: none;
|
|
9
|
+
border-right: 1px solid #ddd;
|
|
10
|
+
spacing: 2px;
|
|
11
|
+
padding: 4px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
QToolBar::separator {
|
|
15
|
+
background: #ddd;
|
|
16
|
+
width: 1px;
|
|
17
|
+
height: 20px;
|
|
18
|
+
margin: 6px 4px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
QToolButton {
|
|
22
|
+
background: transparent;
|
|
23
|
+
border: none;
|
|
24
|
+
border-radius: 4px;
|
|
25
|
+
padding: 4px;
|
|
26
|
+
margin: 1px;
|
|
27
|
+
color: #000000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
QToolButton:hover {
|
|
31
|
+
background: #e0e0e0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
QToolButton:pressed {
|
|
35
|
+
background: #d0d0d0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
QToolButton:checked {
|
|
39
|
+
background: #cce5ff;
|
|
40
|
+
color: #004085;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
QToolButton:disabled {
|
|
44
|
+
color: #999999;
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Main window stylesheet
|
|
49
|
+
MAIN_WINDOW_STYLE = """
|
|
50
|
+
QMainWindow {
|
|
51
|
+
background: #ffffff;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
QDockWidget::title {
|
|
55
|
+
background: #f5f5f5;
|
|
56
|
+
padding: 6px;
|
|
57
|
+
border-bottom: 1px solid #ddd;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
QListWidget {
|
|
61
|
+
background: #ffffff;
|
|
62
|
+
border: 1px solid #ddd;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
QListWidget::item:selected {
|
|
66
|
+
background: #cce5ff;
|
|
67
|
+
color: #004085;
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# Status bar stylesheet
|
|
72
|
+
STATUS_BAR_STYLE = """
|
|
73
|
+
QStatusBar {
|
|
74
|
+
background: #f5f5f5;
|
|
75
|
+
border-top: 1px solid #ddd;
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_combined_style():
|
|
81
|
+
"""Return combined stylesheet for the application."""
|
|
82
|
+
return TOOLBAR_STYLE + MAIN_WINDOW_STYLE + STATUS_BAR_STYLE
|
libs/toolBar.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# libs/toolBar.py
|
|
2
|
+
"""Custom toolbar and button classes for labelImg++."""
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from PyQt5.QtGui import *
|
|
6
|
+
from PyQt5.QtCore import *
|
|
7
|
+
from PyQt5.QtWidgets import *
|
|
8
|
+
except ImportError:
|
|
9
|
+
from PyQt4.QtGui import *
|
|
10
|
+
from PyQt4.QtCore import *
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Base icon size for toolbar buttons (Feather icons are 24x24)
|
|
14
|
+
BASE_ICON_SIZE = 22
|
|
15
|
+
# Minimum and maximum icon sizes for scaling
|
|
16
|
+
MIN_ICON_SIZE = 16
|
|
17
|
+
MAX_ICON_SIZE = 48
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_dpi_scale_factor():
|
|
21
|
+
"""Get the DPI scale factor for the primary screen.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
float: Scale factor (1.0 for standard 96 DPI, higher for HiDPI displays)
|
|
25
|
+
"""
|
|
26
|
+
app = QApplication.instance()
|
|
27
|
+
if app is None:
|
|
28
|
+
return 1.0
|
|
29
|
+
|
|
30
|
+
# Try to get the primary screen
|
|
31
|
+
try:
|
|
32
|
+
screen = app.primaryScreen()
|
|
33
|
+
if screen:
|
|
34
|
+
# Get logical DPI (accounts for user scaling settings)
|
|
35
|
+
logical_dpi = screen.logicalDotsPerInch()
|
|
36
|
+
# Standard DPI is 96 on most systems
|
|
37
|
+
return logical_dpi / 96.0
|
|
38
|
+
except AttributeError:
|
|
39
|
+
# Qt4 fallback
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return 1.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def calculate_icon_size(base_size=BASE_ICON_SIZE):
|
|
46
|
+
"""Calculate appropriate icon size based on DPI.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
base_size: Base icon size at standard DPI
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
int: Scaled icon size clamped to min/max bounds
|
|
53
|
+
"""
|
|
54
|
+
scale = get_dpi_scale_factor()
|
|
55
|
+
scaled_size = int(base_size * scale)
|
|
56
|
+
return max(MIN_ICON_SIZE, min(MAX_ICON_SIZE, scaled_size))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolBar(QToolBar):
|
|
60
|
+
"""Custom toolbar with modern styling and DPI-aware icons."""
|
|
61
|
+
|
|
62
|
+
# Signal emitted when expanded state changes
|
|
63
|
+
expandedChanged = pyqtSignal(bool)
|
|
64
|
+
|
|
65
|
+
def __init__(self, title):
|
|
66
|
+
super(ToolBar, self).__init__(title)
|
|
67
|
+
layout = self.layout()
|
|
68
|
+
layout.setSpacing(0)
|
|
69
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
70
|
+
self.setContentsMargins(0, 0, 0, 0)
|
|
71
|
+
self._icon_size = calculate_icon_size()
|
|
72
|
+
self.setIconSize(QSize(self._icon_size, self._icon_size))
|
|
73
|
+
self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint)
|
|
74
|
+
|
|
75
|
+
# Track tool buttons for icon size updates
|
|
76
|
+
self._tool_buttons = []
|
|
77
|
+
|
|
78
|
+
# Expand/collapse state
|
|
79
|
+
self._expanded = False
|
|
80
|
+
self._collapsed_width = 85
|
|
81
|
+
self._expanded_width = 140
|
|
82
|
+
self._expand_btn = None
|
|
83
|
+
|
|
84
|
+
def addAction(self, action):
|
|
85
|
+
if isinstance(action, QWidgetAction):
|
|
86
|
+
return super(ToolBar, self).addAction(action)
|
|
87
|
+
btn = ToolButton(self._icon_size)
|
|
88
|
+
btn.setDefaultAction(action)
|
|
89
|
+
btn.setToolButtonStyle(self.toolButtonStyle())
|
|
90
|
+
self.addWidget(btn)
|
|
91
|
+
self._tool_buttons.append(btn)
|
|
92
|
+
return btn
|
|
93
|
+
|
|
94
|
+
def addWidget(self, widget):
|
|
95
|
+
"""Override to track widgets that support icon sizing."""
|
|
96
|
+
super(ToolBar, self).addWidget(widget)
|
|
97
|
+
if isinstance(widget, (ToolButton, DropdownToolButton)):
|
|
98
|
+
if widget not in self._tool_buttons:
|
|
99
|
+
self._tool_buttons.append(widget)
|
|
100
|
+
|
|
101
|
+
def update_icon_size(self, size=None):
|
|
102
|
+
"""Update icon size for toolbar and all buttons.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
size: New icon size, or None to recalculate from DPI
|
|
106
|
+
"""
|
|
107
|
+
if size is None:
|
|
108
|
+
size = calculate_icon_size()
|
|
109
|
+
|
|
110
|
+
self._icon_size = size
|
|
111
|
+
self.setIconSize(QSize(size, size))
|
|
112
|
+
|
|
113
|
+
# Update all tracked buttons
|
|
114
|
+
for btn in self._tool_buttons:
|
|
115
|
+
if hasattr(btn, 'update_icon_size'):
|
|
116
|
+
btn.update_icon_size(size)
|
|
117
|
+
else:
|
|
118
|
+
btn.setIconSize(QSize(size, size))
|
|
119
|
+
|
|
120
|
+
def showEvent(self, event):
|
|
121
|
+
"""Recalculate icon size when toolbar becomes visible."""
|
|
122
|
+
super(ToolBar, self).showEvent(event)
|
|
123
|
+
# Recalculate in case screen/DPI changed
|
|
124
|
+
new_size = calculate_icon_size()
|
|
125
|
+
if new_size != self._icon_size:
|
|
126
|
+
self.update_icon_size(new_size)
|
|
127
|
+
|
|
128
|
+
def add_expand_button(self):
|
|
129
|
+
"""Add expand/collapse toggle button at the bottom of toolbar."""
|
|
130
|
+
from libs.utils import new_icon
|
|
131
|
+
|
|
132
|
+
# Add spacer to push button to bottom
|
|
133
|
+
spacer = QWidget()
|
|
134
|
+
spacer.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
|
|
135
|
+
self.addWidget(spacer)
|
|
136
|
+
|
|
137
|
+
# Add separator
|
|
138
|
+
self.addSeparator()
|
|
139
|
+
|
|
140
|
+
# Create expand button
|
|
141
|
+
self._expand_btn = QToolButton()
|
|
142
|
+
self._expand_btn.setIcon(new_icon('chevron-down'))
|
|
143
|
+
self._expand_btn.setToolTip("Expand toolbar")
|
|
144
|
+
self._expand_btn.setIconSize(QSize(16, 16))
|
|
145
|
+
self._expand_btn.clicked.connect(self.toggle_expanded)
|
|
146
|
+
self._expand_btn.setStyleSheet("""
|
|
147
|
+
QToolButton {
|
|
148
|
+
border: none;
|
|
149
|
+
background: transparent;
|
|
150
|
+
padding: 4px;
|
|
151
|
+
}
|
|
152
|
+
QToolButton:hover {
|
|
153
|
+
background: #e0e0e0;
|
|
154
|
+
border-radius: 4px;
|
|
155
|
+
}
|
|
156
|
+
""")
|
|
157
|
+
self.addWidget(self._expand_btn)
|
|
158
|
+
|
|
159
|
+
# Set initial width
|
|
160
|
+
self.setFixedWidth(self._collapsed_width)
|
|
161
|
+
|
|
162
|
+
def toggle_expanded(self):
|
|
163
|
+
"""Toggle between expanded and collapsed state."""
|
|
164
|
+
from libs.utils import new_icon
|
|
165
|
+
|
|
166
|
+
self._expanded = not self._expanded
|
|
167
|
+
|
|
168
|
+
if self._expanded:
|
|
169
|
+
self._expand_btn.setIcon(new_icon('chevron-up'))
|
|
170
|
+
self._expand_btn.setToolTip("Collapse toolbar")
|
|
171
|
+
self.setFixedWidth(self._expanded_width)
|
|
172
|
+
else:
|
|
173
|
+
self._expand_btn.setIcon(new_icon('chevron-down'))
|
|
174
|
+
self._expand_btn.setToolTip("Expand toolbar")
|
|
175
|
+
self.setFixedWidth(self._collapsed_width)
|
|
176
|
+
|
|
177
|
+
self.expandedChanged.emit(self._expanded)
|
|
178
|
+
|
|
179
|
+
def set_expanded(self, expanded):
|
|
180
|
+
"""Set the expanded state programmatically."""
|
|
181
|
+
if expanded != self._expanded:
|
|
182
|
+
self.toggle_expanded()
|
|
183
|
+
|
|
184
|
+
def is_expanded(self):
|
|
185
|
+
"""Return current expanded state."""
|
|
186
|
+
return self._expanded
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ToolButton(QToolButton):
|
|
190
|
+
"""Custom toolbar button with DPI-aware sizing."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, icon_size=None):
|
|
193
|
+
super(ToolButton, self).__init__()
|
|
194
|
+
self._icon_size = icon_size or calculate_icon_size()
|
|
195
|
+
self.setIconSize(QSize(self._icon_size, self._icon_size))
|
|
196
|
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
|
|
197
|
+
|
|
198
|
+
def update_icon_size(self, size):
|
|
199
|
+
"""Update the icon size."""
|
|
200
|
+
self._icon_size = size
|
|
201
|
+
self.setIconSize(QSize(size, size))
|
|
202
|
+
self.updateGeometry()
|
|
203
|
+
|
|
204
|
+
def sizeHint(self):
|
|
205
|
+
hint = super(ToolButton, self).sizeHint()
|
|
206
|
+
# Calculate width based on actual text
|
|
207
|
+
fm = self.fontMetrics()
|
|
208
|
+
text = self.text()
|
|
209
|
+
text_width = fm.horizontalAdvance(text) if hasattr(fm, 'horizontalAdvance') else fm.width(text)
|
|
210
|
+
# Add padding for icon and margins
|
|
211
|
+
width = max(hint.width(), text_width + 20, 70)
|
|
212
|
+
height = max(hint.height(), self._icon_size + 30)
|
|
213
|
+
return QSize(width, height)
|
|
214
|
+
|
|
215
|
+
def minimumSizeHint(self):
|
|
216
|
+
# Use actual text width for minimum
|
|
217
|
+
fm = self.fontMetrics()
|
|
218
|
+
text = self.text()
|
|
219
|
+
text_width = fm.horizontalAdvance(text) if hasattr(fm, 'horizontalAdvance') else fm.width(text)
|
|
220
|
+
return QSize(max(text_width + 16, 65), self._icon_size + 24)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class DropdownToolButton(QToolButton):
|
|
224
|
+
"""Toolbar button with dropdown menu and DPI-aware sizing."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, text, icon=None, actions=None, icon_size=None):
|
|
227
|
+
super(DropdownToolButton, self).__init__()
|
|
228
|
+
self._icon_size = icon_size or calculate_icon_size()
|
|
229
|
+
self.setText(text)
|
|
230
|
+
if icon:
|
|
231
|
+
self.setIcon(icon)
|
|
232
|
+
self.setIconSize(QSize(self._icon_size, self._icon_size))
|
|
233
|
+
self.setPopupMode(QToolButton.InstantPopup)
|
|
234
|
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
|
|
235
|
+
|
|
236
|
+
# Create menu for dropdown actions
|
|
237
|
+
self.dropdown_menu = QMenu(self)
|
|
238
|
+
if actions:
|
|
239
|
+
for action in actions:
|
|
240
|
+
if action is None:
|
|
241
|
+
self.dropdown_menu.addSeparator()
|
|
242
|
+
else:
|
|
243
|
+
self.dropdown_menu.addAction(action)
|
|
244
|
+
self.setMenu(self.dropdown_menu)
|
|
245
|
+
|
|
246
|
+
def add_action(self, action):
|
|
247
|
+
"""Add an action to the dropdown menu."""
|
|
248
|
+
if action is None:
|
|
249
|
+
self.dropdown_menu.addSeparator()
|
|
250
|
+
else:
|
|
251
|
+
self.dropdown_menu.addAction(action)
|
|
252
|
+
|
|
253
|
+
def update_icon_size(self, size):
|
|
254
|
+
"""Update the icon size."""
|
|
255
|
+
self._icon_size = size
|
|
256
|
+
self.setIconSize(QSize(size, size))
|
|
257
|
+
self.updateGeometry()
|
|
258
|
+
|
|
259
|
+
def sizeHint(self):
|
|
260
|
+
hint = super(DropdownToolButton, self).sizeHint()
|
|
261
|
+
# Calculate width based on actual text
|
|
262
|
+
fm = self.fontMetrics()
|
|
263
|
+
text = self.text()
|
|
264
|
+
text_width = fm.horizontalAdvance(text) if hasattr(fm, 'horizontalAdvance') else fm.width(text)
|
|
265
|
+
# Add padding for icon, dropdown arrow, and margins
|
|
266
|
+
width = max(hint.width(), text_width + 30, 70)
|
|
267
|
+
height = max(hint.height(), self._icon_size + 30)
|
|
268
|
+
return QSize(width, height)
|
|
269
|
+
|
|
270
|
+
def minimumSizeHint(self):
|
|
271
|
+
# Use actual text width for minimum
|
|
272
|
+
fm = self.fontMetrics()
|
|
273
|
+
text = self.text()
|
|
274
|
+
text_width = fm.horizontalAdvance(text) if hasattr(fm, 'horizontalAdvance') else fm.width(text)
|
|
275
|
+
return QSize(max(text_width + 24, 65), self._icon_size + 24)
|
libs/ustr.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from libs.constants import DEFAULT_ENCODING
|
|
3
|
+
|
|
4
|
+
def ustr(x):
|
|
5
|
+
"""py2/py3 unicode helper"""
|
|
6
|
+
|
|
7
|
+
if sys.version_info < (3, 0, 0):
|
|
8
|
+
from PyQt4.QtCore import QString
|
|
9
|
+
if type(x) == str:
|
|
10
|
+
return x.decode(DEFAULT_ENCODING)
|
|
11
|
+
if type(x) == QString:
|
|
12
|
+
# https://blog.csdn.net/friendan/article/details/51088476
|
|
13
|
+
# https://blog.csdn.net/xxm524/article/details/74937308
|
|
14
|
+
return unicode(x.toUtf8(), DEFAULT_ENCODING, 'ignore')
|
|
15
|
+
return x
|
|
16
|
+
else:
|
|
17
|
+
return x
|