diff --git a/projects/gui/pylash/pylash/main.py b/projects/gui/pylash/pylash/main.py new file mode 100644 index 0000000..116da14 --- /dev/null +++ b/projects/gui/pylash/pylash/main.py @@ -0,0 +1,253 @@ +import dataclasses +import logging +import pathlib +import shutil +import sys +import textwrap +from pprint import pformat +from typing import Optional + +from PySide6.QtCore import Slot +from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QGraphicsScene, + QGraphicsView, + QHBoxLayout, + QMessageBox, + QPushButton, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from pylash import settings, unsplash # isort: skip + +# ------------- +# Prerequisites +# ------------- + +logging.basicConfig( + level=settings.DEBUG or logging.INFO, + format=settings.LOG_FORMAT, + style="{", +) +LOG = logging.getLogger("pylash.main") +SECOND = 1000 + + +@dataclasses.dataclass() +class Geometry: + x: int + y: int + width: int + height: int + + +# ----------- +# Application +# ----------- + + +class PylashWallpaperApp(QWidget): + + STORE_DIR = pathlib.Path("~/Desktop/pylash").expanduser() + + def __init__(self, client: unsplash.UnsplashClient, title: str = "") -> None: + super().__init__() + self.client = client + self.ctx = {} + self._current_image: Optional[pathlib.Path] = None + + self.setup(title=title) + + def setup(self, title: str): + self.setWindowTitle(title) + self.addAction(self._make_close_action(callback=self.close)) + self._viewer = self._make_viewer(name="viewer", geom=(30, 30, 960, 480)) + self._nextButton = self._make_button( + name="nextButton", + text="下一张", + geom=(50, 520, 400, 100), + callback=self.next, + ) + + self._saveButton = self._make_button( + name="saveButton", + text="保存", + geom=(550, 520, 400, 100), + callback=self.save, + ) + + self._status = self._make_status_bar() + # main window layout + layout = self._make_layout() + self.setLayout(layout) + + def _make_status_bar(self, css: str = ""): + bar = QStatusBar() + style = """ + background-color: none; + color: 'grey'; + padding: 0; + border: none; + """ + + if css: + style = css + bar.setStyleSheet(style) + else: + bar.setStyleSheet(style) + + ctx = self.ctx.setdefault("statusBar", {}) + ctx.update(css=textwrap.dedent(style)) + + return bar + + def _make_viewer(self, name: str, geom): + viewer = QGraphicsView() + viewer.setObjectName(name) + viewer.setGeometry(*geom) + + ctx = self.ctx.setdefault("viewer", {}) + ctx[name] = dict(name=name, geom=Geometry(*geom)) + return viewer + + def _make_button(self, name: str, text: str, geom, callback=None): + btn = QPushButton(text=text) + btn.setObjectName(name) + btn.setGeometry(*geom) + + if callback: + btn.clicked.connect(callback) # noqa: ignore + + ctx = self.ctx.setdefault("button", {}) + ctx[name] = dict( + name=name, + text=text, + geom=Geometry(*geom), + callback=callback.__name__ if callback else None, + ) + return btn + + def _make_layout(self): + layout = QVBoxLayout() + layout.addWidget(self._viewer) + + # button layout + btn_layout = QHBoxLayout() + btn_layout.addWidget(self._nextButton) + btn_layout.addWidget(self._saveButton) + + layout.addWidget(self._status) + layout.addLayout(btn_layout) + return layout + + def _check_store_dir(self): + if not self.STORE_DIR.exists(): + LOG.debug(f"initialization the directory to save a photo: {self.STORE_DIR}") + self.STORE_DIR.mkdir() + + def _make_image_layer(self, data): + image = QPixmap() + image.loadFromData(data) + + scene = QGraphicsScene() + scene.addPixmap(image) + + return scene + + @Slot() + def save(self): + """save photo to specific directory.""" + self._check_store_dir() # create a directory or not before saving + if not self._current_image: + self._status.showMessage( + "There isn't any image on the screen", timeout=5 * SECOND + ) + return + + name = self._current_image.name + fpath = self.STORE_DIR.joinpath(name) + if fpath.exists(): + self._status.showMessage( + f"this image has saved to {fpath}", timeout=5 * SECOND + ) + LOG.warning(f"{fpath} image has existed.") + return + + shutil.copy(self._current_image, fpath) + self._status.showMessage(f"save to {fpath}") + + @Slot() + def next(self): + """fetch the next photo.""" + if self._status.currentMessage(): + self._status.clearMessage() + + image = self.client.fetch() + if not image: + msg = "the api has been limited, please try it after next hour time." + self._status.showMessage(msg) + LOG.warning(msg) + return + + self._current_image = image + + scene = self._make_image_layer(data=image.read_bytes()) + self._viewer.setScene(scene) + + def _make_close_action(self, callback): + key = "&Close" + close = QAction(key, self) + close.setShortcut(QKeySequence.Close) + close.triggered.connect(callback) # noqa: ignore + + ctx = self.ctx.setdefault("actions", {}) + + ctx["close"] = dict( + key=key, + shortcut=str(QKeySequence.Close), + callback=callback.__name__ if callback else None, + ) + + return close + + def _close_event(self, event: QCloseEvent): + ctx_msg = pformat(self.ctx, compact=True) + LOG.debug(f"context: \n{ctx_msg}") + box = QMessageBox() + box.setIcon(QMessageBox.Question) + box.setWindowTitle("Quit") + box.setText("Are you sure to close window?") + box.setStandardButtons(QMessageBox.No | QMessageBox.Yes) + box.setDefaultButton(QMessageBox.No) + + choice = box.exec() + key = "No" if choice == 65536 else "Yes" + LOG.debug(f"choice info(choice={key}, number={choice})") + if choice == QMessageBox.No: + event.ignore() + else: + event.accept() + + def closeEvent(self, event: QCloseEvent): + self._close_event(event) + + +def main(): + # create application window loop. + window = QApplication(sys.argv) + + # show content. + client = unsplash.UnsplashClient(token=settings.TOKEN) + app = PylashWallpaperApp(client=client, title="Unsplash 随机壁纸获取器") + app.resize(1440, 900) + app.show() + + # exit when application window closed. + sys.exit(window.exec()) + + +if __name__ == "__main__": + main() diff --git a/projects/gui/pylash/pylash/settings.py b/projects/gui/pylash/pylash/settings.py new file mode 100644 index 0000000..efd4aca --- /dev/null +++ b/projects/gui/pylash/pylash/settings.py @@ -0,0 +1,11 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + + +DEBUG = 10 if os.getenv("PYLASH_DEBUG") else False +TOKEN = os.getenv("PYLASH_TOKEN") + +LOG_FORMAT = "[{asctime}] [{levelname}] [{name}] [{funcName}:{lineno}] - {message}" diff --git a/projects/gui/pylash/pylash/unsplash.py b/projects/gui/pylash/pylash/unsplash.py new file mode 100644 index 0000000..41bf3ef --- /dev/null +++ b/projects/gui/pylash/pylash/unsplash.py @@ -0,0 +1,98 @@ +import logging +import pathlib +import tempfile +from typing import Optional +from urllib.parse import parse_qs, urlparse + +import requests + +__all__ = "UnsplashClient" + +# -------------- +# Prerequisites +# -------------- + +API = "https://api.unsplash.com/photos/random" +LOG = logging.getLogger(__name__) +CACHED = pathlib.Path(tempfile.mkdtemp(prefix="pylash-")) + + +class APIQueryException(Exception): + def __init__(self, status_code: int, detail) -> None: + self.status_code = status_code + self.detail = detail + + def __str__(self) -> str: + return f"staus_code={self.status_code}, detail={self.detail}" + + +# --------------------- +# Unsplash Integration +# --------------------- + + +class UnsplashClient(object): + def __init__(self, token: str) -> None: + if not token: + raise ValueError(f"UnsplashClient need a token for initialization.") + self.token = token + self._remaining = 50 + self._headers = { + "Accept-Version": "v1", + "Authorization": f"Client-ID {self.token}", + } + + @property + def remaining(self) -> int: + return self._remaining + + def _download(self, url: str) -> pathlib.Path: + """download image from target url""" + + # extract name from url query string. + parser = urlparse(url) + params = parse_qs(parser.query) + + name = params['ixid'][0] + file = CACHED.joinpath(f"{name}.jpg") + LOG.debug(f"save image to cached dir in: {file}") + + response = requests.get(url, stream=True, params=dict(w=1080, fm="jpg", q=80)) + total = 0 + with open(file, mode="wb") as f: + for chunk in response.iter_content(1024): + total += f.write(chunk) + + LOG.debug(f"writing data: {total >> 10} KB.") + + return file + + def fetch(self) -> Optional[pathlib.Path]: + if self._remaining == 0: + LOG.warning( + "the API request/usage has exhausted." + "Please try it after next hour time." + ) + return + + response = requests.get( + API, headers=self._headers, params={"orientation": "landscape"} + ) + data = response.json() + + if response.status_code != 200: + raise APIQueryException(status_code=response.status_code, detail=data) + + # this property is not thread safety. + self._remaining = int(response.headers.get("X-Ratelimit-Remaining")) + + url = data["urls"].get("raw") + image = self._download(url=url) + return image + + +if __name__ == "__main__": + from pylash import settings + + client = UnsplashClient(token=settings.TOKEN) + client.fetch() diff --git a/projects/gui/quickstart.py b/projects/gui/quickstart.py new file mode 100644 index 0000000..9a0f4f9 --- /dev/null +++ b/projects/gui/quickstart.py @@ -0,0 +1,20 @@ +import sys + +from PySide6.QtWidgets import QApplication, QMessageBox + + +def main(): + app = QApplication() + + box = QMessageBox() + box.setText("Do you attempt to close window?") + + box.setStandardButtons(box.No | box.Yes) + box.setDefaultButton(box.Yes) + box.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/projects/gui/signals.py b/projects/gui/signals.py new file mode 100644 index 0000000..e441f82 --- /dev/null +++ b/projects/gui/signals.py @@ -0,0 +1,49 @@ +import sys + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import ( + QApplication, + QPushButton, + QStatusBar, + QVBoxLayout, + QWidget, +) + + +class MyWidget(QWidget): + + counter = 0 + + def __init__(self) -> None: + super().__init__() + self.resize(200, 100) + + btn = QPushButton() + btn.setText("Click me") + btn.clicked.connect(self.count) + self.btn = btn + + bar = QStatusBar() + self.bar = bar + + layout = QVBoxLayout() + layout.addWidget(self.btn) + layout.addWidget(self.bar) + self.setLayout(layout) + + @Slot() + def count(self): + self.counter += 1 + self.bar.showMessage(f"you have clicked: {self.counter}") + + +def main(): + app = QApplication() + widget = MyWidget() + widget.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/projects/gui/widget.py b/projects/gui/widget.py new file mode 100644 index 0000000..1de7198 --- /dev/null +++ b/projects/gui/widget.py @@ -0,0 +1,53 @@ +import sys + +from PySide6.QtWidgets import QApplication, QLabel, QLineEdit, QVBoxLayout, QWidget + + +class MyWidget(QWidget): + def __init__(self): + super().__init__() + self.resize(200, 200) + + label = QLabel() + label.setText("Hello, World!") + label.setStyleSheet( + """ + QLabel { + font-size: 20px; + text-align: center; + margin: auto 50%; + padding-bottom: 5px; + } + """ + ) + self.label = label + + editor = QLineEdit() + editor.setPlaceholderText("input your name here.") + editor.textChanged.connect(self.echo) + self.editor = editor + + layout = QVBoxLayout() + layout.addWidget(self.label) + layout.addWidget(self.editor) + + self.setLayout(layout) + + def echo(self, name: str = ""): + if name: + msg = f"Hello, {name}!" + else: + msg = f"Hello, world!" + self.label.setText(msg) + + +def main(): + app = QApplication() + widget = MyWidget() + widget.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main()