├── _templates
├── widgets
│ ├── panel-background.svg
│ ├── plasmoidheading.svg
│ └── background.svg
├── tabbar-solid.svg
├── tabbar-breeze.svg
└── dialogs
│ └── background.svg
├── widgets
├── pager.svgz
├── plasmoidheading.svg
└── tabbar.svg
├── test
├── .gitignore
├── metadata.desktop
├── colors
├── ReadMe.md
├── Changelog.md
├── removeSvgTransforms.py
├── dialogs
└── background.svg
└── desktoptheme.py
/_templates/widgets/panel-background.svg:
--------------------------------------------------------------------------------
1 | ../dialogs/background.svg
--------------------------------------------------------------------------------
/widgets/pager.svgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Zren/breeze-alphablack/HEAD/widgets/pager.svgz
--------------------------------------------------------------------------------
/test:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | rm ~/.cache/plasma-svgelements-breeze-alphablack_*
4 | rm ~/.cache/plasma_theme_breeze-alphablack_*.kcache
5 | killall plasmashell; kstart5 plasmashell
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /widgets/background.svg
2 | /widgets/background.svgz
3 | /widgets/panel-background.svg
4 | /widgets/panel-background.svgz
5 | /breeze-alphablack-v*.zip
6 | __pycache__
7 | /config.ini
8 |
--------------------------------------------------------------------------------
/metadata.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Comment=A semi-transparent black theme based on top of the default Breeze theme.
3 | Name=Breeze AlphaBlack
4 | X-KDE-PluginInfo-Author=KDE Visual Design Group + Zren
5 | X-KDE-PluginInfo-Category=Plasma Theme
6 | X-KDE-PluginInfo-Depends=
7 | X-KDE-PluginInfo-Email=zrenfire@gmail.com
8 | X-KDE-PluginInfo-EnabledByDefault=true
9 | X-KDE-PluginInfo-License=LGPL
10 | X-KDE-PluginInfo-Name=breeze-alphablack
11 | X-KDE-PluginInfo-Version=20
12 | X-KDE-PluginInfo-Website=https://github.com/Zren/breeze-alphablack
13 | X-Plasma-API=5.0
14 |
15 | X-KPackage-Dependencies=kns://plasmoids.knsrc/api.kde-look.org/1237963
16 |
17 | [ContrastEffect]
18 | enabled=true
19 | contrast=1.0
20 | intensity=1.0
21 | saturation=1.0
22 |
23 | [AdaptiveTransparency]
24 | enabled=true
25 |
--------------------------------------------------------------------------------
/colors:
--------------------------------------------------------------------------------
1 | [ColorEffects:Disabled]
2 | Color=56,56,56
3 | ColorAmount=0
4 | ColorEffect=0
5 | ContrastAmount=0.65
6 | ContrastEffect=1
7 | IntensityAmount=0.1
8 | IntensityEffect=2
9 |
10 | [ColorEffects:Inactive]
11 | ChangeSelectionColor=true
12 | Color=112,111,110
13 | ColorAmount=0.025
14 | ColorEffect=2
15 | ContrastAmount=0.1
16 | ContrastEffect=2
17 | Enable=false
18 | IntensityAmount=0
19 | IntensityEffect=0
20 |
21 | [Colors:Button]
22 | BackgroundAlternate=23,23,23
23 | BackgroundNormal=17,17,17
24 | DecorationFocus=61,174,230
25 | DecorationHover=61,174,230
26 | ForegroundActive=246,116,0
27 | ForegroundInactive=175,176,179
28 | ForegroundLink=61,174,230
29 | ForegroundNegative=237,21,21
30 | ForegroundNeutral=201,206,59
31 | ForegroundNormal=239,240,241
32 | ForegroundPositive=17,209,22
33 | ForegroundVisited=61,174,230
34 |
35 | [Colors:Header]
36 | BackgroundAlternate=23,23,23
37 | BackgroundNormal=0,0,0
38 | DecorationFocus=61,174,230
39 | DecorationHover=61,174,230
40 | ForegroundActive=246,116,0
41 | ForegroundInactive=175,176,179
42 | ForegroundLink=61,174,230
43 | ForegroundNegative=237,21,21
44 | ForegroundNeutral=201,206,59
45 | ForegroundNormal=239,240,241
46 | ForegroundPositive=17,209,22
47 | ForegroundVisited=61,174,230
48 |
49 | [Colors:Selection]
50 | BackgroundAlternate=48,138,183
51 | BackgroundNormal=61,174,230
52 | DecorationFocus=61,174,230
53 | DecorationHover=61,174,230
54 | ForegroundActive=246,116,0
55 | ForegroundInactive=146,204,230
56 | ForegroundLink=252,252,252
57 | ForegroundNegative=237,21,21
58 | ForegroundNeutral=201,206,59
59 | ForegroundNormal=252,252,252
60 | ForegroundPositive=17,209,22
61 | ForegroundVisited=252,252,252
62 |
63 | [Colors:Tooltip]
64 | BackgroundAlternate=59,64,69
65 | BackgroundNormal=49,54,59
66 | DecorationFocus=61,174,230
67 | DecorationHover=61,174,230
68 | ForegroundActive=246,116,0
69 | ForegroundInactive=175,176,179
70 | ForegroundLink=61,174,230
71 | ForegroundNegative=237,21,21
72 | ForegroundNeutral=201,206,59
73 | ForegroundNormal=239,240,241
74 | ForegroundPositive=17,209,22
75 | ForegroundVisited=61,174,230
76 |
77 | [Colors:View]
78 | BackgroundAlternate=23,23,23
79 | BackgroundNormal=0,0,0
80 | DecorationFocus=61,174,230
81 | DecorationHover=61,174,230
82 | ForegroundActive=246,116,0
83 | ForegroundInactive=175,176,179
84 | ForegroundLink=61,174,230
85 | ForegroundNegative=237,21,21
86 | ForegroundNeutral=201,206,59
87 | ForegroundNormal=239,240,241
88 | ForegroundPositive=17,209,22
89 | ForegroundVisited=61,174,230
90 |
91 | [Colors:Window]
92 | BackgroundAlternate=23,23,23
93 | BackgroundNormal=0,0,0
94 | DecorationFocus=61,174,230
95 | DecorationHover=61,174,230
96 | ForegroundActive=246,116,0
97 | ForegroundInactive=175,176,179
98 | ForegroundLink=61,174,230
99 | ForegroundNegative=237,21,21
100 | ForegroundNeutral=201,206,59
101 | ForegroundNormal=239,240,241
102 | ForegroundPositive=17,209,22
103 | ForegroundVisited=61,174,230
104 |
105 | [Colors:Complementary]
106 | BackgroundAlternate=23,23,23
107 | BackgroundNormal=17,17,17
108 | DecorationFocus=30,146,255
109 | DecorationHover=61,174,230
110 | ForegroundActive=246,116,0
111 | ForegroundInactive=175,176,179
112 | ForegroundLink=61,174,230
113 | ForegroundNegative=237,21,21
114 | ForegroundNeutral=201,206,59
115 | ForegroundNormal=239,240,241
116 | ForegroundPositive=17,209,22
117 | ForegroundVisited=61,174,230
118 |
119 | [General]
120 | ColorScheme=Breeze AlphaBlack
121 | Name=Breeze AlphaBlack
122 | shadeSortColumn=true
123 |
124 | [KDE]
125 | contrast=4
126 |
127 |
--------------------------------------------------------------------------------
/ReadMe.md:
--------------------------------------------------------------------------------
1 | # Breeze AlphaBlack
2 |
3 | Breeze Light theme with minor improvements and a black panel/titlebar.
4 |
5 | https://store.kde.org/p/1084931/
6 |
7 | ## Screenshots
8 |
9 | 
10 |
11 | ## Install
12 |
13 | * System Settings > Desktop Theme
14 | * Get New Themes...
15 | * Search for "Breeze AlphaBlack", install it, then apply it.
16 | * If you want the breeze window decorations in black, run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/setthemecolor.py 0,0,0`
17 |
18 | ## Customize Theme
19 |
20 | ### "AlphaBlack Control" Widget
21 |
22 | 
23 |
24 | v10 introduced an accompanying widget to easily change the theme's accent color, panel opacity, or change the taskbar to look a bit more like Windows 10.
25 |
26 | You can [download the widget here](https://store.kde.org/p/1237963/), or install it via the GUI:
27 |
28 | 1. Right click the Panel > Add Widgets
29 | 2. Get New Widgets > Download
30 | 3. Search for "AlphaBlack Control" and install it.
31 |
32 | After installing, the widget should appear in your system tray. If you lock your widgets, it will hide in the system tray popup.
33 |
34 |
35 | ### Command Line
36 |
37 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/desktoptheme.py`
38 | to see all commands in the help message.
39 |
40 | #### Panel Opacity
41 |
42 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/desktoptheme.py set panel.opacity 0.75`
43 | where `0.75` means the panel is 75% visible.
44 |
45 | You can also set the `dialog.opacity` for panel popups, and `widget.opacity` for desktop widgets.
46 |
47 |
48 | #### Panel Color + Window Decorations
49 |
50 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/desktoptheme.py set theme.accentColor 255,255,255`
51 | where `255,255,255` is your desired RGB color.
52 |
53 |
54 | #### Window Decorations
55 |
56 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/desktoptheme.py settitlebarcolor 255,255,255`
57 | where `255,255,255` is your desired RGB color.
58 |
59 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/desktoptheme.py resettitlebarcolors`
60 | to reapply the colors from your seleted color scheme.
61 |
62 | #### Task Manager Theme
63 |
64 | Run `python3 ~/.local/share/plasma/desktoptheme/breeze-alphablack/settasksvg.py outside`
65 | where `outside` draws the line on the edge of the screen like Windows 10, or `inside` like Breeze.
66 |
67 |
68 |
69 | ## Window Decorations
70 |
71 | To color the the default breeze window decorations (window titlebars). You can either:
72 |
73 | 1. Download a custom window decoration.
74 | 2. Use the set titlebar colors python script above.
75 | 3. Use the System Settings > Colors > Colors section to color the titlebar background/foreground. You will not be able to color the frame color (bottom/left/right of the window) from this menu.
76 | 4. Add/set the following values in your `~/.config/kdeglobals` file to set the borders to black (when using the breeze window decorations).
77 |
78 | ```ini
79 | [WM]
80 | activeBackground=0,0,0
81 | activeBlend=17,17,17
82 | activeFont=Noto Sans,10,-1,5,50,0,0,0,0,0
83 | activeForeground=239,240,241
84 | inactiveBackground=8,8,8
85 | inactiveBlend=75,71,67
86 | inactiveForeground=189,195,199
87 | frame=0,0,0
88 | inactiveFrame=8,8,8
89 | ```
90 |
91 | ## Misc
92 |
93 | * Wiki for Editing Desktop Themes
94 | https://techbase.kde.org/Development/Tutorials/Plasma5/ThemeDetails
95 | * Code that parses the `colors` file for use with the svg CSS colors.
96 | https://github.com/KDE/plasma-framework/blob/master/src/plasma/private/theme_p.cpp#L422
97 |
--------------------------------------------------------------------------------
/widgets/plasmoidheading.svg:
--------------------------------------------------------------------------------
1 |
81 |
--------------------------------------------------------------------------------
/_templates/widgets/plasmoidheading.svg:
--------------------------------------------------------------------------------
1 |
81 |
--------------------------------------------------------------------------------
/Changelog.md:
--------------------------------------------------------------------------------
1 | ## v20 - October 9 2021
2 |
3 | * Parse `[Sub][Sections]` in kdeglobals to fix `desktoptheme.py` commands.
4 | * Use dbus plasma script to quickly change theme faster.
5 |
6 | ## v19 - February 25 2021
7 |
8 | * Refactor `panel-background.svg` to reuse the `dialog/background.svg`.
9 | * Add thick margins to the panel svg to support Plasma 5.21's Margin Separator widgets. A 24px panel will have a 1px thick margin. A 30px panel will have a 4px thick margin. A 38px panel will have a 8px thick margin.
10 |
11 | ## v18 - December 11 2020
12 |
13 | * Fix pale popups caused by the Background Contrast Desktop Effect (Issue #21). Added `ContrastEffect` properties in `metadata.desktop` in an attempt to fix it, however the proper fix was to ship a pre-generated `dialogs/background.svg`.
14 | * Refactor `background.svg` to add `dialog.radius` which doesn't really work well.
15 | * Fix `resettitlebarcolors` mentioned in AlphaBlackControl's Issue 2.
16 | * Only use blue shifted DecorationFocus for Complementary colors (Issue #20)
17 | * Set Header color group to fix white `plasmoidheading.svg` in Plasma 5.20
18 |
19 | ## v17 - June 16 2020
20 |
21 | * Added `widgets/plasmoidheading.svg` to show the solid notification heading like Breeze. It's only displayed if the current theme has the svg, it does not inherit this svg.
22 | * Removed all deprecated `set___color.py` scripts. Use the `desktoptheme.py` subcommands.
23 |
24 | ## v16 - March 15 2020
25 |
26 | * Move `set___color.py` etc into `desktoptheme.py` as subcommands. Eg: `python3 desktoptheme.py set dialog.opacity 0.7`. The existing scripts are deprecated and will be removed in the next version.
27 | * Add ability to set dialog padding with `python3 desktoptheme.py set dialog.padding 2`. Note that you need to close a popup and reopen it to see the change in padding. You may need to run the command a few times for it to take effect.
28 |
29 | ## v15 - March 17 2019
30 |
31 | * Cleanup the desktop widget background svg coordinates (`widget/background.svg`).
32 | * Use Breeze's rectangle new rounded corner popup dialog svg from KDE Frameworks 5.56.
33 | * Fix the dialog popup color (when using a custom opacity) broken by the Qt 5.12.2 update. The default dialog popups and tooltips will be fixed in KDE Frameworks 5.56.2 or 5.57.0.
34 |
35 | ## v14 - December 15 2018
36 |
37 | * Use Breeze's rectangle corner popup svg. Somewhere along the line this theme ended up using a different svg with round corners for some reason.
38 | * Add `sethighlightcolor.py` to set the selection/highlight color.
39 |
40 | ## v13 - October 12 2018
41 |
42 | * Hide the panel shadow when the panel opacity is set below 30% (`0.3`).
43 | * Update the desktop widget background to use the same look as breeze.
44 | * Add `settextcolor.py` to change the panel/widget/titlebar text color.
45 | * Fix `resettitlebarcolor.py` not working for color schemes installed in the home directory (`~/.local/share/color-scheme/`. It previously only worked for color schemes in the root directory (`/usr/share/color-scheme/`). This fix also scans all color schemes when the color scheme filename is not the typical `{ColorSchemeName}.colors` (Eg: "Breeze Solarized Light" uses "BreezeSolarizedLight.colors").
46 | * Add `resettodefaults.py` script to revert all changes.
47 |
48 | ## v12 - July 26 2018
49 |
50 | * Add settitlebarcolor.py and resettitlebarcolor.py to easily reset the titlebar color. v3 of the widget has buttons that will run these scripts.
51 |
52 | ## v11 - June 24 2018
53 |
54 | * Remove the opache and transparent folders which don't appear to do anything (they aren't overriding breeze).
55 | * Add new templates + scripts to set the panel popups/dialog opacity, and the desktop widget opacity. The new scripts are setdialogopacity.py and setwidgetopacity.py. Both work the same as setpanelopacity.py.
56 |
57 | ## v10 - May 29 2018
58 |
59 | * Follow color scheme when compositor is off. Gets rid of white outline when compositor is off.
60 | * Add breeze's widgets/tabbar.svg, as Plasma doesn't allow it to "inherit" this svg. This fixes/adds the "popup is open" blue line.
61 | * Add alternative tasks.svg where the line is on the outside/edge of the screen line Windows 10. Use the config widget, or the settasksvg.py script, to change to it.
62 | * "Unzip" the svgz into svg, and cleanup the xml.
63 |
64 | ## v9 - February 20 2017
65 |
66 | * Fix panel opacity script so it can be run anywhere.
67 |
68 | ## v8 - January 21 2017
69 |
70 | * Add a script to set the panel opacity.
71 |
72 | ## v7
73 |
74 | * Fix the minimized tasks svg (showed nothing instead of looking like a "normal" task)
75 | * Use a hardcoded #f67400 orange for highlighted tasks instead of the NeutralText color from the color scheme.
76 |
77 | ## v6
78 |
79 | * Use the Breeze 5.8 tasks.svg (thinner line), but with minimized tasks looking the same as a normal task.
80 |
81 | ## v5
82 |
83 | * Add script to change the color.
84 |
85 |
86 | ## v3 - June 16 2016
87 |
88 | * Theme progressbar in tasks.
89 |
90 | ## v2 - June 15 2016
91 |
92 | * Got rid of the white line at the top of the panel
93 | * Got rid of the pink spot in the pager
94 |
--------------------------------------------------------------------------------
/_templates/tabbar-solid.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
582 |
--------------------------------------------------------------------------------
/removeSvgTransforms.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | import sys, os
4 | from bs4 import BeautifulSoup, NavigableString, Tag
5 | import re
6 | import math
7 |
8 | # s = '4.500'
9 | # while len(s) > 2:
10 | # if s[-1] == '0':
11 | # print(s, s[0:-1])
12 | # s = s[0:-1]
13 | # sys.exit(0)
14 |
15 | def bringToFront(arr, value):
16 | try:
17 | i = arr.index(value)
18 | arr.insert(0, arr.pop(i))
19 | except ValueError:
20 | pass # Not in the list
21 |
22 | def bringAllToFront(arr, keys):
23 | for key in reversed(keys):
24 | bringToFront(arr, key)
25 |
26 | def sortedAttrs(el):
27 | keys = el.attrs.keys()
28 | keys = sorted(keys)
29 | bringAllToFront(keys, [
30 | "id",
31 | "class",
32 | "x",
33 | "y",
34 | "width",
35 | "height",
36 | "style",
37 | ])
38 | for key in keys:
39 | yield key, el.attrs[key]
40 |
41 |
42 | def dent(indent):
43 | return " " * indent
44 |
45 | def nl(indent):
46 | return "\n" + dent(indent)
47 |
48 | def renderTag(el, indent=0):
49 | # s += (" " * indent)
50 | # print(dent(indent), el.name, type(el))
51 | if type(el) == Tag or type(el) == BeautifulSoup:
52 | if el.name == 'metadata':
53 | return "" # Skip it (it has annoying namespaces and we don't need it anyways)
54 |
55 | s = ""
56 | s += "<" + el.name
57 | for key, value in sortedAttrs(el):
58 | s += nl(indent+1) + " " + str(key) + "=\"" + str(value) + "\""
59 |
60 | childrenStr = ""
61 | childCount = 0
62 | for child in el.children:
63 | childStr = renderTag(child, indent+1)
64 | if len(childStr) > 0:
65 | childrenStr += nl(indent+1) + childStr
66 | childCount += 1
67 |
68 | if childCount == 0:
69 | s += " /"
70 | s += ">"
71 |
72 | s += childrenStr
73 | if childCount > 0:
74 | s += nl(indent) + "" + el.name + ">"
75 | return s
76 | elif type(el) == NavigableString:
77 | s = str(el)
78 | if s.strip() == '':
79 | return ""
80 | else:
81 | # print("LOG", s)
82 | return s
83 | else:
84 | print("ERROR", type(el), el)
85 | return ""
86 |
87 | def renderSvg(svg):
88 | s = '\n'
89 | s += renderTag(svg)
90 | s += '\n'
91 | return s
92 |
93 | def parseArgs(s):
94 | left = s.index('(') + 1
95 | right = s.index(')')
96 | s = s[left:right]
97 | # print(s)
98 | tokens = s.split(',')
99 | args = map(float, tokens)
100 | args = list(args)
101 | # print(args)
102 | return args
103 |
104 | def transformChildren(parent, func, *args):
105 | for el in parent.children:
106 | if type(el) == Tag:
107 | # print('\t', el)
108 | func(el, *args)
109 |
110 | def roundIfNeeded(a):
111 | # return a
112 | b = round(a)
113 | if a < b and b <= a + 0.01:
114 | return b # a ~= 0.99
115 | elif a - 0.01 <= b and b <= a:
116 | return b # a ~= 1.01
117 | else:
118 | return a
119 |
120 | def applyTranslate(el, dx, dy):
121 | # First check if the el has a tranlsate() that negates (dx,dy).
122 | transform = el.attrs.get('transform')
123 | if transform is not None and 'translate(' in transform:
124 | args = parseArgs(transform)
125 | dx2, dy2 = args
126 | if math.isclose(dx, -dx2) and math.isclose(dy, -dy2):
127 | del el.attrs['transform']
128 | return
129 |
130 | # If not, shift all coordinates of the el.
131 | if el.name == 'g':
132 | transformChildren(el, applyTranslate, dx, dy)
133 | elif el.name == 'path':
134 | applyMatrixToPath(el, 1, 0, 0, 1, dx, dy)
135 | elif el.name == 'rect':
136 | applyTranslateToRect(el, dx, dy)
137 | else:
138 | raise Exception("Cannot applyTranslate to <{}>".format(el.name))
139 |
140 | def formatNumber(x):
141 | if type(x) == float:
142 | s = str(x)
143 | a,b = s.split('.')
144 | if len(b) > 3: # significance >3
145 | s = "{:.3f}".format(x)
146 | while s[-1] == '0':
147 | # print(s, s[0:-1])
148 | s = s[0:-1]
149 | return s
150 | else:
151 | return s
152 | else:
153 | return str(x)
154 |
155 | def applyTranslateToRect(el, dx, dy):
156 | x = float(el["x"])
157 | y = float(el["y"])
158 |
159 | x += dx
160 | y += dy
161 | # print("\t\t({}, {}) => ({}, {})".format(el["x"], el["y"], x, y))
162 |
163 | x = formatNumber(roundIfNeeded(x))
164 | y = formatNumber(roundIfNeeded(y))
165 | # print("\t\t({}, {}) => ({}, {})".format(el["x"], el["y"], x, y))
166 |
167 | el["x"] = x
168 | el["y"] = y
169 |
170 |
171 | def applyMatrix(el, a, b, c, d, e, f):
172 | if el.name == 'rect':
173 | applyMatrixToRect(el, a, b, c, d, e, f)
174 | elif el.name == 'path':
175 | applyMatrixToPath(el, a, b, c, d, e, f)
176 | else:
177 | raise Exception("Cannot applyMatrix to <{}>".format(el.name))
178 |
179 | def applyMatrixToRect(el, a, b, c, d, e, f):
180 | # print("\t{}".format(el.parent['id']))
181 | x1 = float(el["x"])
182 | y1 = float(el["y"])
183 | w1 = float(el["width"])
184 | h1 = float(el["height"])
185 | x2 = x1 + w1
186 | y2 = y1 + h1
187 | # print("\t\t({}, {}, {}, {})".format(el["x"], el["y"], el["width"], el["height"]))
188 |
189 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
190 | x3, y3 = applyMatrixToPoint(x1, y1, a, b, c, d, e, f)
191 | x4, y4 = applyMatrixToPoint(x2, y2, a, b, c, d, e, f)
192 | w2 = x4 - x3
193 | h2 = y4 - y3
194 | # print("\t\t\t=> ({}, {}, {}, {})".format(x3, y3, w2, h2))
195 |
196 | if w2 < 0:
197 | x3 = x4
198 | # x3 += w2
199 | w2 *= -1
200 | if h2 < 0:
201 | y3 = y4
202 | # y3 += h2
203 | h2 *= -1
204 |
205 | x3 = roundIfNeeded(x3)
206 | y3 = roundIfNeeded(y3)
207 | w2 = roundIfNeeded(w2)
208 | h2 = roundIfNeeded(h2)
209 | # print("\t\t\t=> ({}, {}, {}, {})".format(x3, y3, w2, h2))
210 |
211 | el["x"] = formatNumber(x3)
212 | el["y"] = formatNumber(y3)
213 | el["width"] = formatNumber(w2)
214 | el["height"] = formatNumber(h2)
215 |
216 | def applyScaleToRect(el, sx, sy):
217 | applyMatrixToRect(el, sx, 0, 0, sy, 0, 0)
218 |
219 |
220 | # def applyMatrixToPoint(x1, y1, a, b, c, d, e, f):
221 | # x1 = float(x1)
222 | # y1 = float(y1)
223 |
224 | # # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
225 | # x2 = a*x1 + c*y1 + e
226 | # y2 = b*x1 + d*y1 + f
227 |
228 | # x2 = roundIfNeeded(x2)
229 | # y2 = roundIfNeeded(y2)
230 |
231 | # x2 = formatNumber(x2)
232 | # y2 = formatNumber(y2)
233 | # # print("\t\t({}, {}) => ({}, {})".format(x1, y1, x2, y2))
234 |
235 | # return x2, y2
236 |
237 | def applyMatrixToPoint(x1, y1, a, b, c, d, e, f):
238 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
239 | x2 = a*x1 + c*y1 + e
240 | y2 = b*x1 + d*y1 + f
241 | return x2, y2
242 |
243 | def applyMatrixToPath(el, a, b, c, d, e, f):
244 | # print("\t{}".format(el.parent['id']))
245 | # print("matrix({}, {}, {}, {}, {}, {})".format(a, b, c, d, e, f))
246 | path = el['d']
247 | path = path.replace(',', ' ')
248 | tokens = path.split(' ')
249 | out = []
250 | lastCommand = ''
251 | command = ''
252 |
253 | matrixIsTranslation = a == 1 and b == 0 and c == 0 and d == 1 # With (e == dx, f == dy)
254 |
255 | tokenIter = iter(tokens)
256 | commandUseCount = 0
257 | arcX1 = 0
258 | arcY1 = 0
259 | arcX2 = 0
260 | arcY2 = 0
261 |
262 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
263 | pathCommands = [
264 | 'M', 'L', 'H', 'V', 'Z', 'C', 'S', 'Q', 'T', 'A', # M x,y (absolute coordinates)
265 | 'm', 'l', 'h', 'v', 'z', 'c', 's', 'q', 't', 'a', # m dx,dy (relative)
266 | ]
267 | for token in tokenIter:
268 | # print('\t\t' + token)
269 |
270 | if token in pathCommands:
271 | commandUseCount = 0
272 | lastCommand = command
273 | command = token
274 | if command.lower() == 'z': # Close Path
275 | out.append('z')
276 | continue
277 |
278 | commandUseCount += 1
279 |
280 | if command == 'm' or command == 'l':
281 | # Move To x,y
282 | # Line to x,y
283 | x = token
284 | y = next(tokenIter)
285 | x1 = float(x)
286 | y1 = float(y)
287 |
288 | if commandUseCount == 1:
289 | # Remember original x,y positions
290 | arcX1 = x1
291 | arcY1 = y1
292 | elif commandUseCount >= 2:
293 | # x,y is delta dx,dy
294 | x1 = arcX1 + x1
295 | y1 = arcY1 + y1
296 | arcX1 = x1
297 | arcY1 = y1
298 |
299 | x2, y2 = applyMatrixToPoint(x1, y1, a, b, c, d, e, f)
300 |
301 | if commandUseCount == 1:
302 | # Remember matrixed x,y positions
303 | arcX2 = x2
304 | arcY2 = y2
305 | x2 = formatNumber(roundIfNeeded(x2))
306 | y2 = formatNumber(roundIfNeeded(y2))
307 | commandStr = "{} {},{}".format(command, x2, y2)
308 | elif commandUseCount >= 2:
309 | # Generate matrixed delta dx,dy
310 | dx = x2 - arcX2
311 | dy = y2 - arcY2
312 | dx = formatNumber(roundIfNeeded(dx))
313 | dy = formatNumber(roundIfNeeded(dy))
314 | arcX2 = x2
315 | arcY2 = y2
316 | commandStr = "{},{}".format(dx, dy)
317 | # print("\t\t[{}] ({},{}) => ({})".format(command, x, y, commandStr))
318 | out.append(commandStr)
319 |
320 |
321 | #--- Abosolute coordinates
322 | elif command == 'H': # Horizontal to x
323 | # H x
324 | raise Exception("Implement path Bezier Curves")
325 | elif command == 'V': # Vertical to y
326 | # V y
327 | raise Exception("Implement path Bezier Curves")
328 | elif command == 'Z': # Close Path
329 | raise Exception('Close Path does not use arguments')
330 | elif command == 'C': # Cubic Bezier curve to
331 | # C x1 y1, x2 y2, x y
332 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
333 | elif command == 'S': # Continue Cubic Bezier curve
334 | # S x2 y2, x y
335 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
336 | elif command == 'Q': # Quadratic Bezier curve
337 | # Q x1 y1, x y
338 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
339 | elif command == 'T': # Continue Quadratic Bezier curve
340 | # T x y
341 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
342 | elif command == 'A': # Arc to
343 | # A rx ry x-axis-rotation large-arc-flag sweep-flag x y
344 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Arc', el))
345 |
346 |
347 | #--- Relative coordinates
348 | elif command == 'h': # Horizontal to x
349 | # h dx
350 | dx = float(token)
351 | if matrixIsTranslation:
352 | pass # Only uses delta coordinates (which we can ignore)
353 | else:
354 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path h/v', el))
355 | elif command == 'v': # Vertical to y
356 | # v dy
357 | dy = float(token)
358 | if matrixIsTranslation:
359 | pass # Only uses delta coordinates (which we can ignore)
360 | else:
361 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path h/v', el))
362 | elif command == 'z': # Close Path
363 | raise Exception('Close Path does not use arguments {}'.format(el))
364 | elif command == 'c': # Cubic Bezier curve to
365 | # c dx1 dy1, dx2 dy2, dx dy
366 | dx1 = float(token)
367 | dy1 = float(next(tokenIter))
368 | dx2 = float(next(tokenIter))
369 | dy2 = float(next(tokenIter))
370 | dx = float(next(tokenIter))
371 | dy = float(next(tokenIter))
372 | if matrixIsTranslation:
373 | pass # Only uses delta coordinates (which we can ignore)
374 | else:
375 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
376 | elif command == 's': # Continue Cubic Bezier curve
377 | # s dx2 dy2, dx dy
378 | dx2 = float(token)
379 | dy2 = float(next(tokenIter))
380 | dx = float(next(tokenIter))
381 | dy = float(next(tokenIter))
382 | if matrixIsTranslation:
383 | pass # Only uses delta coordinates (which we can ignore)
384 | else:
385 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
386 | elif command == 'q': # Quadratic Bezier curve
387 | # q dx1 dy1, dx dy
388 | dx1 = float(token)
389 | dy1 = float(next(tokenIter))
390 | dx = float(next(tokenIter))
391 | dy = float(next(tokenIter))
392 | if matrixIsTranslation:
393 | pass # Only uses delta coordinates (which we can ignore)
394 | else:
395 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
396 | elif command == 't': # Continue Quadratic Bezier curve
397 | # t dx dy
398 | dx = float(token)
399 | dy = float(next(tokenIter))
400 | if matrixIsTranslation:
401 | pass # Only uses delta coordinates (which we can ignore)
402 | else:
403 | raise Exception("Implement '{}' {}. el: {}".format(command, 'path Bezier Curves', el))
404 | elif command == 'a': # Arc to
405 | # A rx ry x-axis-rotation large-arc-flag sweep-flag x y
406 | # a 4.5,4.5 0 0 0 -4.5,4.5
407 | rx = token
408 | ry = next(tokenIter)
409 | xAxisRotation = next(tokenIter)
410 | largeArcFlag = next(tokenIter)
411 | sweepFlag = next(tokenIter)
412 | x = next(tokenIter)
413 | y = next(tokenIter)
414 |
415 | rx1 = float(rx)
416 | ry1 = float(ry)
417 | x1 = float(x)
418 | y1 = float(y)
419 |
420 |
421 | if commandUseCount >= 2 or lastCommand == 'm':
422 | # x,y is delta dx,dy
423 | x1 = arcX1 + x1
424 | y1 = arcY1 + y1
425 | arcX1 = x1
426 | arcY1 = y1
427 | else: # commandUseCount == 1
428 | # Remember original x,y positions
429 | arcX1 = x1
430 | arcY1 = y1
431 | # pass
432 |
433 | # rx2, ry2 = applyMatrixToPoint(rx1, ry1, a, b, c, d, e, f)
434 | rx2 = a*rx1 + c*ry1
435 | ry2 = b*rx1 + d*ry1
436 | # rx2 = a*rx1
437 | # ry2 = d*ry1
438 | x2, y2 = applyMatrixToPoint(x1, y1, a, b, c, d, e, f)
439 |
440 | if rx2 < 0:
441 | rx2 *= -1
442 | x2 -= rx2 * 2
443 | if ry2 < 0:
444 | ry2 *= -1
445 | y2 -= ry2 * 2
446 |
447 | if commandUseCount >= 2 or lastCommand == 'm':
448 | # Generate matrixed delta dx,dy
449 | dx = x2 - arcX2
450 | dy = y2 - arcY2
451 | dx = formatNumber(roundIfNeeded(dx))
452 | dy = formatNumber(roundIfNeeded(dy))
453 | rx2 = formatNumber(roundIfNeeded(rx2))
454 | ry2 = formatNumber(roundIfNeeded(ry2))
455 | arcX2 = x2
456 | arcY2 = y2
457 | commandStr = "{},{} {} {} {} {},{}".format(rx2, ry2, xAxisRotation, largeArcFlag, sweepFlag, dx, dy)
458 | else: # commandUseCount == 1
459 | # Remember matrixed x,y positions
460 | arcX2 = x2
461 | arcY2 = y2
462 | x2 = formatNumber(roundIfNeeded(x2))
463 | y2 = formatNumber(roundIfNeeded(y2))
464 | rx2 = formatNumber(roundIfNeeded(rx2))
465 | ry2 = formatNumber(roundIfNeeded(ry2))
466 | commandStr = "{},{} {} {} {} {},{}".format(rx2, ry2, xAxisRotation, largeArcFlag, sweepFlag, x2, y2)
467 |
468 | if lastCommand != 'a':
469 | commandStr = 'a ' + commandStr
470 |
471 | # print("\t\t({},{} ... {},{}) => ({})".format(rx, ry, x, y, commandStr))
472 | # print("\t\t[a] ({},{}) => ({})".format(x, y, commandStr))
473 | out.append(commandStr)
474 |
475 | # raise Exception("Implement path Arc to")
476 |
477 |
478 | el['d'] = ' '.join(out)
479 |
480 |
481 | def removeGroupTransforms(svg):
482 | for g in svg.find_all('g'):
483 | transform = g.attrs.get('transform')
484 | # print(transform)
485 | # TODO: Support multiple functions in 1 tag.
486 | if transform is None or transform.strip() == '':
487 | continue
488 | elif 'translate(' in transform:
489 | # translateChildren()
490 | args = parseArgs(transform)
491 | transformChildren(g, applyTranslate, *args)
492 | del g.attrs['transform']
493 | elif 'matrix(' in transform:
494 | # translateChildren()
495 | args = parseArgs(transform)
496 | transformChildren(g, applyMatrix, *args)
497 | del g.attrs['transform']
498 | else:
499 | raise Exception('Unsupported transform', transform)
500 |
501 | def removeRectTransforms(svg):
502 | for rect in svg.find_all('rect'):
503 | # print(rect)
504 | transform = rect.attrs.get('transform')
505 | # print(transform)
506 | # TODO: Support multiple functions in 1 tag.
507 | if transform is None or transform.strip() == '':
508 | continue
509 | elif 'scale(' in transform:
510 | args = parseArgs(transform)
511 | applyScaleToRect(rect, *args)
512 | del rect.attrs['transform']
513 | elif 'matrix(' in transform:
514 | args = parseArgs(transform)
515 | applyMatrixToRect(rect, *args)
516 | del rect.attrs['transform']
517 | else:
518 | raise Exception('Unsupported transform', transform)
519 |
520 | def removeTransforms(svg):
521 | removeRectTransforms(svg)
522 | removeGroupTransforms(svg)
523 |
524 |
525 | def printHelp():
526 | print('python3 removeSvgTransforms.py [svgFile]')
527 | print('\tEg: python3 removeSvgTransforms.py _templates/tasks-outside.svg')
528 |
529 | if __name__ == '__main__':
530 | if len(sys.argv) >= 2:
531 | inSvgPath = sys.argv[1]
532 | outSvgPath = sys.argv[1]
533 | else:
534 | if False:
535 | inSvgPath = '_templates/tasks-outside.svg'
536 | outSvgPath = 'test.svg'
537 | else:
538 | printHelp()
539 | sys.exit(1)
540 |
541 | with open(inSvgPath, 'r') as fin:
542 | soup = BeautifulSoup(fin, 'xml')
543 | svg = soup.find('svg')
544 | removeTransforms(svg)
545 | s = renderSvg(svg)
546 | # print(s)
547 |
548 | with open(outSvgPath, 'w') as fout:
549 | fout.write(s)
550 |
--------------------------------------------------------------------------------
/_templates/widgets/background.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
193 |
--------------------------------------------------------------------------------
/dialogs/background.svg:
--------------------------------------------------------------------------------
1 |
725 |
--------------------------------------------------------------------------------
/widgets/tabbar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
663 |
--------------------------------------------------------------------------------
/_templates/tabbar-breeze.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
663 |
--------------------------------------------------------------------------------
/_templates/dialogs/background.svg:
--------------------------------------------------------------------------------
1 |
725 |
--------------------------------------------------------------------------------
/desktoptheme.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 |
3 | import configparser # Python 3 only
4 | import sys, os
5 | import glob
6 | import time
7 | import shutil
8 | import re
9 | from pprint import pprint
10 |
11 |
12 | class Point:
13 | def __init__(self, x, y):
14 | self.x = x
15 | self.y = y
16 |
17 | def __str__(self):
18 | return '{},{}'.format(self.x, self.y)
19 |
20 |
21 | # KDE rc files differences:
22 | # Keys are cAsE sensitive
23 | # No spaces around the =
24 | # [Sections can have spaces and : colons]
25 | # Parses [Sub][Sections] as "Sub][Sections", but cannot have comments on the [Section] line
26 | class KdeConfig(configparser.ConfigParser):
27 | def __init__(self, filename):
28 | super().__init__()
29 |
30 | # Keep case sensitive keys
31 | # http://stackoverflow.com/questions/19359556/configparser-reads-capital-keys-and-make-them-lower-case
32 | self.optionxform = str
33 |
34 | # Parse SubSections as "Sub][Sections"
35 | self.SECTCRE = re.compile(r"\[(?P.+?)]\w*$")
36 |
37 | self.filename = filename
38 | self.read(self.filename)
39 |
40 | def set(self, section, option, value):
41 | if not self.has_section(section):
42 | self.add_section(section)
43 | super().set(section, option, str(value))
44 |
45 | def setProp(self, key, value):
46 | section, option = key.split('.', 1)
47 | return self.set(section, option, value)
48 |
49 | def getProp(self, key):
50 | section, option = key.split('.', 1)
51 | return self.get(section, option)
52 |
53 | def default(self, section, option, value):
54 | if not self.has_option(section, option):
55 | self.set(section, option, value)
56 |
57 | def save(self):
58 | with open(self.filename, 'w') as fp:
59 | self.write(fp, space_around_delimiters=False)
60 |
61 | class KdeGlobalsConfig(KdeConfig):
62 | def __init__(self):
63 | super().__init__(os.path.abspath(os.path.expanduser('~/.config/kdeglobals')))
64 |
65 | def limit(minVal, val, maxVal):
66 | return min(max(minVal, val), maxVal)
67 |
68 | def limitColor(val):
69 | return limit(0, val, 255)
70 |
71 | def deltaColor(colorStr, delta):
72 | r, g, b = map(int, colorStr.split(','))
73 | if max(r, g, b) >= 128:
74 | d = -delta
75 | else:
76 | d = delta
77 | return '{},{},{}'.format(limitColor(r+d), limitColor(g+d), limitColor(b+d))
78 |
79 | def hoverEffect(colorStr, rd, gd, bd):
80 | def f(a, b):
81 | if a + b > 255:
82 | return limitColor(a - b)
83 | else:
84 | return limitColor(a + b)
85 | r, g, b = map(int, colorStr.split(','))
86 | return '{},{},{}'.format(f(r, rd), f(g, gd), f(b, bd))
87 |
88 | def setTitlebarColors(backgroundColor='0,0,0', textColor='255,255,255'):
89 | filename = os.path.abspath(os.path.expanduser('~/.config/kdeglobals'))
90 | kdeglobals = KdeConfig(filename)
91 | kdeglobals['WM']['activeBackground'] = backgroundColor
92 | kdeglobals['WM']['inactiveBackground'] = backgroundColor
93 | kdeglobals['WM']['frame'] = backgroundColor
94 | kdeglobals['WM']['inactiveFrame'] = backgroundColor
95 | kdeglobals['WM']['activeForeground'] = textColor
96 | kdeglobals['WM']['inactiveForeground'] = textColor
97 | kdeglobals.save()
98 |
99 | def applyColorSchemeTitlebarColors(kdeglobals, colorScheme):
100 | # Default to Breeze.colors if the file leaves it undefined.
101 | activeBackgroundColor = colorScheme['Colors:Window'].get('BackgroundNormal', '239,240,241')
102 | kdeglobals['WM']['activeBackground'] = colorScheme['WM'].get('activeBackground', '71,80,87')
103 | kdeglobals['WM']['inactiveBackground'] = colorScheme['WM'].get('inactiveBackground', '239,240,241')
104 | kdeglobals['WM']['frame'] = colorScheme['WM'].get('frame', activeBackgroundColor)
105 | kdeglobals['WM']['inactiveFrame'] = colorScheme['WM'].get('inactiveFrame', kdeglobals['WM']['frame'])
106 | kdeglobals['WM']['activeForeground'] = colorScheme['WM'].get('activeForeground', '252,252,252')
107 | kdeglobals['WM']['inactiveForeground'] = colorScheme['WM'].get('inactiveForeground', '189,195,199')
108 | kdeglobals.save()
109 |
110 |
111 |
112 | def scanForColorScheme(colorSchemeName):
113 | def scanColorSchemeDir(colorSchemeDir):
114 | colorSchemeFilename = colorSchemeName + '.colors'
115 | dirFilenames = os.listdir(colorSchemeDir)
116 |
117 | if colorSchemeFilename in dirFilenames:
118 | colorSchemeFilepath = os.path.join(colorSchemeDir, colorSchemeFilename)
119 | # print(colorSchemeFilepath)
120 | colorScheme = KdeConfig(colorSchemeFilepath)
121 | return colorScheme
122 |
123 | # Color scheme was not found in this dir
124 | return None
125 |
126 |
127 | # 1st: Check the home color-scheme dir (downloads by the user).
128 | homeColorSchemeFilepath = os.path.abspath(os.path.expanduser('~/.local/share/color-schemes/'))
129 | colorScheme = scanColorSchemeDir(homeColorSchemeFilepath)
130 | if colorScheme:
131 | return colorScheme
132 |
133 | # 2nd: Check the root color-scheme dir.
134 | colorScheme = scanColorSchemeDir('/usr/share/color-schemes/')
135 | if colorScheme:
136 | return colorScheme
137 |
138 | # No color scheme fulfulled the conditions anywhere
139 | return None
140 |
141 | def resetTitlebarColors():
142 | kdeglobals = KdeGlobalsConfig()
143 |
144 | colorSchemeName = kdeglobals['General'].get('ColorScheme', 'Breeze')
145 |
146 | colorScheme = scanForColorScheme(colorSchemeName)
147 | if colorScheme:
148 | applyColorSchemeTitlebarColors(kdeglobals, colorScheme)
149 |
150 | # elif os.path.isfile(localDesktopThemeFilename):
151 | # elif os.path.isfile(rootDesktopThemeFilename):
152 | # The color scheme is probably in a desktop theme folder...
153 | # Whatever, user can reapply it.
154 | else:
155 | # Deleting the keys will use white + highlight color,
156 | # which isn't the same as the default Breeze colors.
157 | del kdeglobals['WM']['activeBackground']
158 | del kdeglobals['WM']['inactiveBackground']
159 | del kdeglobals['WM']['frame']
160 | del kdeglobals['WM']['inactiveFrame']
161 | del kdeglobals['WM']['activeForeground']
162 | del kdeglobals['WM']['inactiveForeground']
163 | kdeglobals.save()
164 |
165 | class DesktopTheme:
166 | def __init__(self, themeName):
167 | self.themeName = themeName
168 | themeDir = os.path.abspath(os.path.expanduser('~/.local/share/plasma/desktoptheme'))
169 | self.themeDir = os.path.join(themeDir, themeName)
170 | self.dontReload = False
171 |
172 | def colorsConfig(self):
173 | filename = os.path.join(self.themeDir, 'colors')
174 | return KdeConfig(filename)
175 |
176 | def reloadTheme(self):
177 | if self.dontReload:
178 | # We're setting multiple properties at once,
179 | # and want to skip automatically reloading
180 | # after each change.
181 | return
182 |
183 | try:
184 | import dbus
185 | bus = dbus.SessionBus()
186 | obj = bus.get_object('org.kde.plasmashell', '/PlasmaShell')
187 | obj.evaluateScript('theme="breeze-dark";theme="' + self.themeName + '"')
188 | except:
189 | # Switch to another theme and back to apply the changes to breeze-alphablack.
190 | filename = os.path.abspath(os.path.expanduser('~/.config/plasmarc'))
191 | plasmarc = KdeConfig(filename)
192 |
193 | # Make sure we're not already in the process of changing the theme.
194 | if plasmarc['Theme']['name'] == self.themeName:
195 | plasmarc['Theme']['name'] = 'breeze-dark'
196 | plasmarc.save()
197 | time.sleep(1)
198 | plasmarc['Theme']['name'] = self.themeName
199 | plasmarc.save()
200 |
201 | def clearCache(self):
202 | wildcardPath='plasma-svgelements-{}_*'.format(self.themeName)
203 | cacheDir = os.path.realpath(os.path.expanduser('~/.cache/'))
204 | for file in glob.glob(os.path.join(cacheDir, wildcardPath)):
205 | print('Deleting cached theme file "{}"'.format(file))
206 | os.remove(file)
207 |
208 | wildcardPath='plasma_theme_{}_*.kcache'.format(self.themeName)
209 | for file in glob.glob(os.path.join(cacheDir, wildcardPath)):
210 | print('Deleting cached theme file "{}"'.format(file))
211 | os.remove(file)
212 |
213 |
214 |
215 | class BreezeAlphaBlack(DesktopTheme):
216 | def __init__(self, themeName='breeze-alphablack'):
217 | super().__init__(themeName=themeName)
218 | self.configProps = [
219 | # (group, key, defaultValue, setterKey)
220 | ('dialog', 'opacity', 0.9, 'setDialogOpacity'),
221 | ('dialog', 'padding', 6, 'setDialogPadding'),
222 | ('dialog', 'radius', 3, 'setDialogRadius'),
223 | ('panel', 'opacity', 0.9, 'setPanelOpacity'),
224 | ('panel', 'padding', 2, 'setPanelPadding'),
225 | ('panel', 'radius', 3, 'setPanelRadius'),
226 | ('panel', 'taskStyle', 'inside', 'setTasksSvg'),
227 | ('panel', 'thickpadding', 8, 'setPanelThickPadding'),
228 | ('theme', 'accentColor', '0,0,0', 'setAccentColor'),
229 | ('theme', 'highlightColor', '61,174,230', 'setHighlightColor'),
230 | ('theme', 'textColor', '239,240,241', 'setTextColor'),
231 | ('widget', 'opacity', 0.9, 'setWidgetOpacity'),
232 | ]
233 |
234 | def themeConfig(self):
235 | filename = os.path.join(self.themeDir, 'config.ini')
236 | config = KdeConfig(filename)
237 | for (group, key, value, setterKey) in self.configProps:
238 | config.default(group, key, value)
239 | return config
240 |
241 | def configGet(self, propPath):
242 | config = self.themeConfig()
243 | value = config.getProp(propPath)
244 | return value
245 |
246 | def configSet(self, propPath, value):
247 | group1, key1 = propPath.split('.')
248 | for (group2, key2, defaultValue, setterKey) in self.configProps:
249 | if group1 == group2 and key1 == key2:
250 | setter = getattr(self, setterKey)
251 | setter(value)
252 | break
253 |
254 | def useTemplate(self, inPath, outPath):
255 | templatePath = os.path.join(self.themeDir, '_templates', inPath)
256 | outPath = os.path.join(self.themeDir, outPath)
257 | shutil.copyfile(templatePath, outPath)
258 | self.reloadTheme()
259 |
260 | def renderTemplate(self, templatePath, **kwargs):
261 | inFilename = os.path.join(self.themeDir, '_templates', templatePath)
262 | outFilename = os.path.join(self.themeDir, templatePath)
263 | outDir = os.path.dirname(outFilename)
264 | os.makedirs(outDir, exist_ok=True)
265 | with open(inFilename, 'r') as fin:
266 | with open(outFilename, 'w') as fout:
267 | for line in fin:
268 | for a, b in kwargs.items():
269 | line = line.replace(a, b)
270 | fout.write(line)
271 |
272 | def getNonZero(self, config, section, key):
273 | value = float(config.get(section, key))
274 | value = max(0.001, value)
275 | return value
276 |
277 | def getDialogPadding(self, config, section='dialog'):
278 | # Plasma doesn't like a padding of 0, so just use a really small number (which is rounded to 0).
279 | return self.getNonZero(config, section, 'padding')
280 |
281 | def getDialogRadius(self, config, section='dialog'):
282 | # It'd be a pain to adjust the paths to be perfectly square edge, so just do a really small radius.
283 | return self.getNonZero(config, section, 'radius')
284 |
285 | def getPanelThickPadding(self, config, section='panel'):
286 | if section == 'dialog':
287 | # Is not actually implemented.
288 | return 0
289 | else:
290 | # Note: The Panel.qml code in the desktop shell package defines an upper limit for
291 | # the thick margins. The panel must fit a smallMedium (22px) icon.
292 | # * https://invent.kde.org/plasma/plasma-desktop/-/blob/master/desktoppackage/contents/views/Panel.qml
293 | # * https://invent.kde.org/frameworks/kiconthemes/blob/master/src/kiconloader.h#L149
294 | # * /usr/share/plasma/shells/org.kde.plasma.desktop/contents/views/Panel.qml
295 | # So a 24px panel will have a maximum padding of (24-22)/2 = 1px
296 | # 30px panel will have a maximum padding of (30-22)/2 = 4px
297 | # 38px panel will have a maximum padding of (38-22)/2 = 8px
298 | # 64px panel will have a maximum padding of (64-22)/2 = 21px
299 | # So a panel needs to be 38px tall to actually have a thick padding of 8px (Breeze's default).
300 | return self.getNonZero(config, section, 'thickpadding')
301 |
302 | def calcCorners(self, padding, thickPadding, radius):
303 | out = {}
304 | out['{{padding}}'] = str(padding)
305 | out['{{thickPadding}}'] = str(thickPadding)
306 |
307 | centerSize = 32
308 | # radius (Default = 3)
309 | size = radius # max(6, radius) # Default = 6
310 | extra = size - radius # Default = 3
311 | farEdgeOffset = size + centerSize # Default = 38
312 | marginThickness = 4 # This is just a visual cue
313 | marginCenterOffset = size + (centerSize/2) - (marginThickness/2) # Default = 20
314 | marginFarOffset = size + centerSize + size - padding
315 | if thickPadding > 0:
316 | marginCenterOffset -= (marginThickness/2)
317 | thickMarginCenterOffset = marginCenterOffset + marginThickness
318 | thickMarginFarOffset = size + centerSize + size - thickPadding
319 |
320 | out['{{centerSize}}'] = str(centerSize)
321 | out['{{edgeSize}}'] = str(size)
322 | out['{{extraSize}}'] = str(extra)
323 | out['{{farEdgeOffset}}'] = str(farEdgeOffset)
324 | out['{{marginThickness}}'] = str(marginThickness)
325 | out['{{marginCenterOffset}}'] = str(marginCenterOffset)
326 | out['{{marginFarOffset}}'] = str(marginFarOffset)
327 | out['{{thickMarginCenterOffset}}'] = str(thickMarginCenterOffset)
328 | out['{{thickMarginFarOffset}}'] = str(thickMarginFarOffset)
329 |
330 | third = radius * 1/3
331 | twoThird = radius * 2/3
332 |
333 | #--- Background+Mask Tileset
334 | # topLeft (Clockwise)
335 | # m 6,6 h -6 v -3 c 0,-2 1,-3 3,-3 h 3 z
336 | path = 'm {} h {} v {} c {} {} {} h {} z'.format(
337 | # m
338 | Point(size, size), # point touching center
339 | # h
340 | -size,
341 | # v
342 | -extra,
343 | # c
344 | Point(0, -twoThird),
345 | Point(third, -radius),
346 | Point(radius, -radius),
347 | # h
348 | extra,
349 | # z
350 | )
351 | out['{{topLeftPath}}'] = path
352 |
353 | # topRight (Clockwise)
354 | # m 38,6 v -6 h 3 c 2,0 3,1 3,3 v 3 z
355 | path = 'm {} v {} h {} c {} {} {} v {} z'.format(
356 | # m
357 | Point(farEdgeOffset, size), # point touching center
358 | # v
359 | -size,
360 | # h
361 | extra,
362 | # c
363 | Point(twoThird, 0),
364 | Point(radius, third),
365 | Point(radius, radius),
366 | # v
367 | extra,
368 | # z
369 | )
370 | out['{{topRightPath}}'] = path
371 |
372 | # bottomLeft (Clockwise)
373 | # m 6,38 v 6 h -3 c -2,0 -3,-1 -3,-3 v -3 z
374 | path = 'm {} v {} h {} c {} {} {} v {} z'.format(
375 | # m
376 | Point(size, farEdgeOffset), # point touching center
377 | # v
378 | size,
379 | # h
380 | -extra,
381 | # c
382 | Point(-twoThird, 0),
383 | Point(-radius, -third),
384 | Point(-radius, -radius),
385 | # v
386 | -extra,
387 | # z
388 | )
389 | out['{{bottomLeftPath}}'] = path
390 |
391 | # bottomRight (Clockwise)
392 | # m 38,38 h 6 v 3 c 0,2 -1,3 -3,3 h -3 z
393 | path = 'm {} h {} v {} c {} {} {} h {} z'.format(
394 | # m
395 | Point(farEdgeOffset, farEdgeOffset), # point touching center
396 | # h
397 | size,
398 | # v
399 | extra,
400 | # c
401 | Point(0, twoThird),
402 | Point(-third, radius),
403 | Point(-radius, radius),
404 | # h
405 | -extra,
406 | # z
407 | )
408 | out['{{bottomRightPath}}'] = path
409 |
410 | #--- Shadow Tileset
411 | shadowSize = 6
412 | out['{{shadowSize}}'] = str(shadowSize)
413 |
414 | shadowAndRadius = shadowSize + radius # Default = 9
415 | shadowEdgeSize = shadowSize + size # Default = 12
416 | shadowFarEdge = shadowEdgeSize + centerSize # Default = 44
417 | shadowFarShadow = shadowEdgeSize + centerSize + size # Default = 50
418 | shadowTilesetSize = shadowEdgeSize + centerSize + shadowEdgeSize # Default = 56
419 | out['{{shadowAndRadius}}'] = str(shadowAndRadius)
420 | out['{{shadowEdgeSize}}'] = str(shadowEdgeSize)
421 | out['{{shadowFarEdge}}'] = str(shadowFarEdge)
422 | out['{{shadowFarShadow}}'] = str(shadowFarShadow)
423 | out['{{shadowTilesetSize}}'] = str(shadowTilesetSize)
424 |
425 | edgeStopDone = 2/3 # Default = 0.66
426 | cornerStopA = size / shadowEdgeSize # Default = 0.33
427 | cornerStopB = (size + shadowSize*edgeStopDone) / shadowEdgeSize # Default = 0.77
428 | out['{{edgeStopDone}}'] = str(edgeStopDone)
429 | out['{{cornerStopA}}'] = str(cornerStopA)
430 | out['{{cornerStopB}}'] = str(cornerStopB)
431 |
432 | # shadowTopLeft (CounterClockwise)
433 | # m 0,0 v 9 h 6 c 0,-2 1,-3 3,-3 v -6 z
434 | path = 'm {} v {} h {} c {} {} {} v {} z'.format(
435 | # m
436 | Point(0, 0), # point furthest from center
437 | # v
438 | shadowAndRadius,
439 | # h
440 | shadowSize,
441 | # c
442 | Point(0, -twoThird),
443 | Point(third, -radius),
444 | Point(radius, -radius),
445 | # v
446 | -shadowSize,
447 | # z
448 | )
449 | out['{{shadowTopLeftPath}}'] = path
450 |
451 | # shadowTopRight (CounterClockwise)
452 | # m 56,0 h -9 v 6 c 2,0 3,1 3,3 h 6 z
453 | path = 'm {} h {} v {} c {} {} {} h {} z'.format(
454 | # m
455 | Point(shadowTilesetSize, 0), # point furthest from center
456 | # h
457 | -shadowAndRadius,
458 | # v
459 | shadowSize,
460 | # c
461 | Point(twoThird, 0),
462 | Point(radius, third),
463 | Point(radius, radius),
464 | # h
465 | shadowSize,
466 | # z
467 | )
468 | out['{{shadowTopRightPath}}'] = path
469 |
470 | # shadowBottomLeft (CounterClockwise)
471 | # m 0,56 h 9 v -6 c -2,0 -3,-1 -3,-3 h -6 z
472 | path = 'm {} h {} v {} c {} {} {} h {} z'.format(
473 | # m
474 | Point(0, shadowTilesetSize), # point furthest from center
475 | # h
476 | shadowAndRadius,
477 | # v
478 | -shadowSize,
479 | # c
480 | Point(-twoThird, 0),
481 | Point(-radius, -third),
482 | Point(-radius, -radius),
483 | # h
484 | -shadowSize,
485 | # z
486 | )
487 | out['{{shadowBottomLeftPath}}'] = path
488 |
489 | # shadowBottomRight (CounterClockwise)
490 | # m 56,56 v -9 h -6 c 0,2 -1,3 -3,3 v 6 z
491 | path = 'm {} v {} h {} c {} {} {} v {} z'.format(
492 | # m
493 | Point(shadowTilesetSize, shadowTilesetSize), # point furthest from center
494 | # v
495 | -shadowAndRadius,
496 | # h
497 | -shadowSize,
498 | # c
499 | Point(0, twoThird),
500 | Point(-third, radius),
501 | Point(-radius, radius),
502 | # v
503 | shadowSize,
504 | # z
505 | )
506 | out['{{shadowBottomRightPath}}'] = path
507 |
508 | return out
509 |
510 | def getDialogVars(self, config, section='dialog'):
511 | dialogPadding = config.get(section, 'padding')
512 | noDialogPadding = config.getint(section, 'padding') == 0
513 | fillOpacity = config.get(section, 'opacity')
514 | if section == 'panel':
515 | if float(fillOpacity) >= 0.3:
516 | shadowOpacity = 1
517 | else:
518 | # Background fill isn't strong enough, hide shadows.
519 | shadowOpacity = 0
520 | else:
521 | # Dialogs should always have shadows.
522 | shadowOpacity = 1
523 |
524 | dialogVars = {
525 | '{{fillOpacity}}': str(fillOpacity),
526 | '{{shadowOpacity}}': str(shadowOpacity),
527 | '{{noDialogPadding}}': '' if noDialogPadding else '',
528 | }
529 |
530 | dialogPadding = self.getDialogPadding(config, section)
531 | dialogRadius = self.getDialogRadius(config, section)
532 | thickPadding = self.getPanelThickPadding(config, section)
533 | cornerVars = self.calcCorners(dialogPadding, thickPadding, dialogRadius)
534 | dialogVars.update(cornerVars)
535 | return dialogVars
536 |
537 | def renderDialogBackground(self, config):
538 | dialogVars = self.getDialogVars(config)
539 | pprint(dialogVars)
540 | self.renderTemplate('dialogs/background.svg', **dialogVars)
541 |
542 | def renderPlasmoidHeading(self, config):
543 | dialogVars = self.getDialogVars(config)
544 | self.renderTemplate('widgets/plasmoidheading.svg', **dialogVars)
545 |
546 | def setDialogProperty(self, key, newValue):
547 | config = self.themeConfig()
548 | config.set('dialog', key, newValue)
549 | self.renderDialogBackground(config)
550 | self.renderPlasmoidHeading(config)
551 | config.save()
552 | self.clearCache() # Not really necessary
553 | self.reloadTheme()
554 |
555 | def setDialogOpacity(self, newOpacity=0.9):
556 | self.setDialogProperty('opacity', newOpacity)
557 |
558 | def setDialogPadding(self, newPadding=6):
559 | self.setDialogProperty('padding', newPadding)
560 |
561 | def setDialogRadius(self, newRadius=3):
562 | self.setDialogProperty('radius', newRadius)
563 |
564 | def renderPanel(self, config):
565 | panelVars = self.getDialogVars(config, section='panel')
566 | pprint(panelVars)
567 | self.renderTemplate('widgets/panel-background.svg', **panelVars)
568 |
569 | def setPanelProperty(self, key, newValue):
570 | config = self.themeConfig()
571 | config.set('panel', key, newValue)
572 | self.renderPanel(config)
573 | config.save()
574 | self.clearCache() # Only necessary for setPanelPadding
575 | self.reloadTheme()
576 |
577 | def setPanelOpacity(self, newOpacity=0.9):
578 | self.setPanelProperty('opacity', newOpacity)
579 |
580 | def setPanelPadding(self, newPadding=2):
581 | self.setPanelProperty('padding', newPadding)
582 |
583 | def setPanelRadius(self, newRadius=3):
584 | self.setPanelProperty('radius', newRadius)
585 |
586 | def setPanelThickPadding(self, newPadding=8):
587 | self.setPanelProperty('thickpadding', newPadding)
588 |
589 | def renderWidgetBackground(self, config):
590 | # Breeze's widget/background.svg
591 | # fill: 0.9
592 | # shadows: 1.0 (Linear gradients)
593 | # corner shadows: 0.6 (Radial gradients)
594 | fillOpacity = float(config.getProp('widget.opacity'))
595 | if fillOpacity == 0:
596 | # Hide shadows completely if there's no fill
597 | shadowOpacity = 0
598 | cornerOpacity = 0
599 | else:
600 | shadowOpacity = min(fillOpacity + 0.1, 1)
601 | cornerOpacity = 0.6 * shadowOpacity
602 |
603 | self.renderTemplate('widgets/background.svg', **{
604 | '{{fillOpacity}}': str(fillOpacity),
605 | '{{shadowOpacity}}': str(shadowOpacity),
606 | '{{cornerOpacity}}': str(cornerOpacity),
607 | })
608 |
609 | def setWidgetOpacity(self, newOpacity=0.9):
610 | config = self.themeConfig()
611 | config.set('widget', 'opacity', newOpacity)
612 | self.renderWidgetBackground(config)
613 | config.save()
614 | self.clearCache() # Not really necessary
615 | self.reloadTheme()
616 |
617 | def _applyColors(self, accentColor='0,0,0', textColor='239,240,241', highlightColor='61,174,230'):
618 | altColor = deltaColor(accentColor, 23)
619 | compColor = deltaColor(accentColor, 17)
620 | compFocusColor = hoverEffect(highlightColor, -31, -28, 25) # 61,174,230 => 30,146,255
621 | focusColor = highlightColor
622 | hoverColor = highlightColor
623 | selectionColor = highlightColor
624 | selectionAltColor = hoverEffect(selectionColor, -13, -36, -47) # 61,174,230 => 48,138,183
625 |
626 | print('BackgroundNormal: {}'.format(accentColor))
627 | print('BackgroundAlternate: {}'.format(altColor))
628 | print('Complementary.BackgroundNormal: {}'.format(compColor))
629 | print('Complementary.DecorationFocus: {}'.format(compFocusColor))
630 | print('DecorationFocus: {}'.format(focusColor))
631 | print('DecorationHover: {}'.format(hoverColor))
632 | print('Selection.BackgroundNormal: {}'.format(selectionColor))
633 | print('Selection.BackgroundAlternate: {}'.format(selectionAltColor))
634 |
635 | setTitlebarColors(accentColor, textColor)
636 |
637 | colors = self.colorsConfig()
638 | allColorGroups = [
639 | 'Colors:Button',
640 | 'Colors:Selection',
641 | 'Colors:Tooltip',
642 | 'Colors:View',
643 | 'Colors:Window',
644 | 'Colors:Complementary',
645 | 'Colors:Header',
646 | ]
647 | allButComplementaryGroup = [g for g in allColorGroups if g != 'Colors:Complementary']
648 | def applyToGroups(groups, key, value):
649 | for group in groups:
650 | colors[group][key] = value
651 |
652 | colors['Colors:Button']['BackgroundNormal'] = compColor
653 | colors['Colors:Button']['BackgroundAlternate'] = altColor
654 | colors['Colors:Button']['ForegroundNormal'] = textColor
655 | colors['Colors:Header']['BackgroundNormal'] = accentColor
656 | colors['Colors:Header']['BackgroundAlternate'] = altColor
657 | colors['Colors:Header']['ForegroundNormal'] = textColor
658 | colors['Colors:View']['BackgroundNormal'] = accentColor
659 | colors['Colors:View']['BackgroundAlternate'] = altColor
660 | colors['Colors:View']['ForegroundNormal'] = textColor
661 | colors['Colors:Window']['BackgroundNormal'] = accentColor
662 | colors['Colors:Window']['BackgroundAlternate'] = altColor
663 | colors['Colors:Window']['ForegroundNormal'] = textColor
664 | colors['Colors:Complementary']['BackgroundNormal'] = compColor
665 | colors['Colors:Complementary']['BackgroundAlternate'] = altColor
666 | colors['Colors:Complementary']['ForegroundNormal'] = textColor
667 |
668 | # Focus
669 | applyToGroups(allButComplementaryGroup, 'DecorationFocus', focusColor)
670 | colors['Colors:Complementary']['DecorationFocus'] = compFocusColor
671 |
672 | # Hover
673 | applyToGroups(allColorGroups, 'DecorationHover', hoverColor)
674 | colors['Colors:Selection']['BackgroundNormal'] = selectionColor # Note this variable controls `theme.highlightColor`
675 | colors['Colors:Selection']['BackgroundAlternate'] = selectionAltColor
676 |
677 | colors.save()
678 |
679 | config = self.themeConfig()
680 | config.set('theme', 'accentColor', accentColor)
681 | config.set('theme', 'highlightColor', highlightColor)
682 | config.set('theme', 'textColor', textColor)
683 | config.save()
684 |
685 | self.reloadTheme()
686 |
687 | def applyColors(self, accentColor=None, textColor=None, highlightColor=None):
688 | config = self.themeConfig()
689 | if accentColor is None:
690 | accentColor = config.get('theme', 'accentColor')
691 | if textColor is None:
692 | textColor = config.get('theme', 'textColor')
693 | if highlightColor is None:
694 | highlightColor = config.get('theme', 'highlightColor')
695 | self._applyColors(accentColor, textColor, highlightColor)
696 |
697 | def setAccentColor(self, accentColor):
698 | self.applyColors(accentColor=accentColor)
699 |
700 | def setHighlightColor(self, highlightColor):
701 | self.applyColors(highlightColor=highlightColor)
702 |
703 | def setTextColor(self, textColor):
704 | self.applyColors(textColor=textColor)
705 |
706 | def setTasksSvg(self, taskTheme):
707 | templatePath = "tasks-{}.svg".format(taskTheme)
708 | self.useTemplate(templatePath, 'widgets/tasks.svg')
709 |
710 | config = self.themeConfig()
711 | config.set('panel', 'taskStyle', taskTheme)
712 | config.save()
713 |
714 | self.reloadTheme()
715 |
716 | def resetToDefaults(self):
717 | self.dontReload = True
718 | for (group, key, value, setterKey) in self.configProps:
719 | print("===[ {}.{}: {} ]===".format(group, key, value))
720 | setter = getattr(self, setterKey)
721 | setter(value)
722 |
723 | self.dontReload = False
724 | self.reloadTheme()
725 |
726 |
727 |
728 |
729 | #--- Main
730 | def theme_getall(args):
731 | import json
732 |
733 | desktoptheme = BreezeAlphaBlack()
734 | config = desktoptheme.themeConfig()
735 |
736 | if args.json:
737 | out = {}
738 | for section in config.sections():
739 | out[section] = {}
740 | for name, value in config.items(section):
741 | out[section][name] = value
742 | print(json.dumps(out, indent="\t"))
743 | else:
744 | for section in config.sections():
745 | for name, value in config.items(section):
746 | print("{}.{}: {}".format(section, name, value))
747 |
748 | def theme_get(args):
749 | desktopTheme = BreezeAlphaBlack()
750 | argsVars = vars(args)
751 | propPath = argsVars['section.property']
752 | value = desktopTheme.configGet(propPath)
753 | print(value)
754 |
755 | def theme_set(args):
756 | desktopTheme = BreezeAlphaBlack()
757 | config = desktopTheme.themeConfig()
758 | argsVars = vars(args)
759 | propPath = argsVars['section.property']
760 | desktopTheme.configSet(propPath, args.value)
761 |
762 |
763 | def theme_setTitlebarColors(args):
764 | def parseColorStr(colorStr):
765 | r, g, b = map(int, colorStr.split(','))
766 | return "{},{},{}".format(r, g, b)
767 |
768 | argsVars = vars(args)
769 | if argsVars.get('bgColor'):
770 | bgColor = parseColorStr(argsVars.get('bgColor'))
771 | if argsVars.get('textColor'):
772 | textColor = parseColorStr(argsVars.get('textColor'))
773 | setTitlebarColors(bgColor, textColor)
774 | else:
775 | setTitlebarColors(bgColor)
776 |
777 | def theme_reset(args):
778 | desktopTheme = BreezeAlphaBlack()
779 | desktopTheme.resetToDefaults()
780 |
781 | def theme_resetTitlebarColors(args):
782 | resetTitlebarColors()
783 |
784 | def main():
785 | import argparse
786 |
787 | parser = argparse.ArgumentParser(
788 | prog='desktoptheme',
789 | description='Python script to modify a desktop theme.',
790 | epilog='Note that colors must be [red],[green],[blue] (seperated by commas). [red/green/blue] are integers from 0-255.'
791 | )
792 | subparsers = parser.add_subparsers()
793 |
794 | def add_subcommand(name, func, *args, description=None):
795 | tokens = ['[{}]'.format(arg) for arg in args]
796 | tokens = ['python3', 'desktoptheme.py', name] + tokens
797 | cmdstr = ' '.join(tokens)
798 | helpstr = cmdstr
799 | if description:
800 | helpstr += ' - ' + description
801 | parser_subcommand = subparsers.add_parser(name, help=helpstr)
802 | parser_subcommand.set_defaults(func=func)
803 | for arg in args:
804 | parser_subcommand.add_argument(arg)
805 | return parser_subcommand
806 |
807 | parser_getall = add_subcommand('getall', theme_getall)
808 | parser_getall.add_argument('--json', default=False, action='store_true')
809 | add_subcommand('get', theme_get, 'section.property')
810 | add_subcommand('set', theme_set, 'section.property', 'value')
811 | add_subcommand('settitlebarcolor', theme_setTitlebarColors, 'bgColor', description='Eg: 0,0,0')
812 | add_subcommand('settitlebarcolors', theme_setTitlebarColors, 'bgColor', 'textColor', description='Eg: 0,0,0 255,255,255')
813 | add_subcommand('reset', theme_reset)
814 | add_subcommand('resettitlebarcolors', theme_resetTitlebarColors)
815 |
816 | args = parser.parse_args()
817 |
818 | if 'func' in args:
819 | try:
820 | args.func(args)
821 | except KeyboardInterrupt:
822 | pass
823 | else:
824 | parser.print_help()
825 |
826 |
827 | if __name__ == '__main__':
828 | main()
829 |
--------------------------------------------------------------------------------