Как быстро создать простую и полезную утилиту без навыков программирования
Иногда нужна утилита, которой просто нет в природе: готовые аналоги либо платные, либо обвешаны функциями, которые не нужны. Например, понадобилась утилита для хранения ссылок на папки, файлы и ссылки на сайты таким образом, чтобы все это было перед глазами. В статье пойдет речь о том, как быстро и без навыков программирования создать такую утилиту, используя Qwen 3.8
Выбор языка программирования: почему именно Python
Python уже установлен. Если даже и не установлен, то его можно легко и быстро установить.
Если нет необходимости разрабатывать сложный проект, заниматься отладкой, то можно обойтись любым простым текстовым редактором. Не нужно ни PyCharm, ни Visual Studio Code с плагинами для разработки на Python, ни иных сред разработки. Хватит обычного Notepad++. Он простой, компактный, удобный и быстро работает.
Нейросеть отлично пишет код на Python. В нашем случае не нужно идеального кода, нужно, чтобы на разработку ушло минут пять и все чтобы заработало. Qwen с таким легко справится.
Что хотелось получить
Требования были простые и конкретные:
- одно окно с вкладками;
- в каждой вкладке — кликабельные ссылки на файлы, папки и сайты;
- ссылки можно раскладывать по окну произвольно, перетаскивая мышкой;
- всё (включая координаты ссылок) хранится в простом YAML-файле;
- окно умеет прятаться за левый край экрана и вызываться обратно;
- кнопка «пин», чтобы окно не пряталось;
- глобальная горячая клавиша для показа/скрытия.
Почему не подошли готовые решения
Я честно посмотрел, что есть: Tabble (платный), PStart, SideSlide, файловые менеджеры с вкладками, визуальные доски вроде Miro и Obsidian. Всё было «не то»: либо слишком тяжело, либо платно, либо хранит файлы не так, как мне нужно. И в какой-то момент я понял простую вещь: моя задача настолько конкретна, что проще написать её под себя, чем переделывать чужое.
Промпт
Напиши утилиту для Windows на Python одним файлом (я не программист — дай готовый код и инструкцию, как запустить).Это «полка»: окно с вкладками, где лежат ссылки на файлы, папки и сайты. Это НЕ файловый менеджер — сами файлы не трогать, храним только ссылки на них.
- Вкладки: добавлять, удалять, переименовывать.
- Ярлыки перетаскиваются мышкой в любое место окна, координаты сохраняются; клик — открыть, правый клик — меню (открыть/переименовать/удалить).
- Добавление: перетаскиванием файлов и папок из Проводника и ссылок из браузера (обрабатывай http/https и ссылки как текст), плюс запасное меню «Добавить файл / папку / ссылку (URL)».
- Окно без рамки, поверх остальных окон, прячется за левый край (торчит полоска-ручка) и выезжает по мыши; кнопка «пин» 📌 не даёт прятаться; глобальный хоткей Scroll Lock показывает/прячет окно (хоткей живёт в отдельном потоке — пробрось его в GUI через сигнал Qt, иначе упадёт).
- Всё состояние (вкладки, ссылки, координаты, позиция и размер окна, пин) храни в shelf. yaml рядом со скриптом.
- Стек: Python + PySide6 + PyYAML + keyboard. В конце коротко объясни запуск и дай код shelf. bat для старта двойным кликом.
Из чего собираем
Python — язык, на котором всё пишется; PySide6 — графика: окно, вкладки, перетаскивание; PyYAML — сохранение настроек и ссылок в YAML; keyboard — глобальная горячая клавиша.
Слова «GUI» и «библиотеки» пугать не должны: весь код ниже, его достаточно скопировать.
import sys, os, subprocess, importlib
# ---------- авто-установка пакетов при первом запуске ----------
def ensure_packages():
required = []
for mod, pip in [("PySide6", "PySide6"), ("yaml", "PyYAML")]:
try:
__import__(mod)
except ImportError:
required.append(pip)
if required:
print("Первый запуск — ставлю пакеты:", ", ".join(required))
subprocess.check_call([sys.executable, "-m", "pip", "install", *required])
try:
__import__("keyboard")
except ImportError:
print("Ставлю пакет: keyboard (для горячей клавиши)")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "keyboard"])
except Exception:
print("Не вышло поставить keyboard — хоткей работать не будет.")
importlib.invalidate_caches()
# защита от free-threaded сборки, которую не поддерживает PySide6
if getattr(sys, "abiflags", "") == "t" or sys.executable.lower().endswith("t.exe"):
print("=" * 50)
print("Запущен free-threaded Python (python...t).")
print("PySide6 его НЕ поддерживает. Поставьте обычный Python 3.12")
print("и запустите: py -3.12 shelf.py")
print("=" * 50)
sys.exit(1)
try:
ensure_packages()
except Exception as e:
print("Авто-установка не удалась:", e)
try:
import yaml
from PySide6.QtCore import (Qt, QPoint, QRect, QTimer, QPropertyAnimation,
QUrl, QFileInfo, QEasingCurve, Signal)
from PySide6.QtGui import QDesktopServices, QCursor, QFontMetrics
from PySide6.QtWidgets import (
QApplication, QWidget, QTabWidget, QLabel, QVBoxLayout, QHBoxLayout,
QPushButton, QMenu, QFileDialog, QInputDialog, QLineEdit, QFrame,
QSizeGrip, QStyle, QFileIconProvider
)
except ImportError as e:
print("Не хватает зависимостей:", e)
print("Поставьте вручную: pip install PySide6 PyYAML keyboard")
sys.exit(1)
try:
import keyboard
HAVE_KEYBOARD = True
except ImportError:
HAVE_KEYBOARD = False
STRIP = 16
TICK = 80
DATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shelf.yaml")
BLOCK_SCROLLLOCK = False
def is_url(p):
return p.startswith("http://") or p.startswith("https://")
class ShortcutItem(QWidget):
def __init__(self, path, label, canvas, is_web=None):
super().__init__(canvas)
self.path = path
self.label = label
self.canvas = canvas
self.is_web = is_url(path) if is_web is None else is_web
self.setFixedSize(84, 86)
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 4, 4, 2)
lay.setSpacing(2)
self.icon_lbl = QLabel(self); self.icon_lbl.setAlignment(Qt.AlignCenter)
self.text_lbl = QLabel(self); self.text_lbl.setAlignment(Qt.AlignCenter)
self.text_lbl.setFixedHeight(28)
lay.addWidget(self.icon_lbl); lay.addWidget(self.text_lbl)
self._set_icon(); self._set_text()
self.setToolTip(path)
self.setStyleSheet("""
ShortcutItem { background: rgba(255,255,255,0.06); border-radius: 8px; }
ShortcutItem:hover { background: rgba(255,255,255,0.16); }
ShortcutItem QLabel { background: transparent; color:#e8e8e8; font-size:11px; }
""")
self._press = None
self._moved = False
def _set_icon(self):
if self.is_web:
icon = self.style().standardIcon(QStyle.SP_DirLinkIcon)
else:
icon = QFileIconProvider().icon(QFileInfo(self.path))
if icon.isNull():
icon = self.style().standardIcon(QStyle.SP_FileIcon)
self.icon_lbl.setPixmap(icon.pixmap(40, 40))
def _set_text(self):
fm = QFontMetrics(self.text_lbl.font())
self.text_lbl.setText(fm.elidedText(self.label, Qt.ElideMiddle, self.width() - 8))
def _clamp(self, p):
w = self.canvas
x = max(0, min(p.x(), max(0, w.width() - self.width())))
y = max(0, min(p.y(), max(0, w.height() - self.height())))
return QPoint(x, y)
def open_target(self):
if self.is_web:
QDesktopServices.openUrl(QUrl(self.path))
else:
QDesktopServices.openUrl(QUrl.fromLocalFile(self.path))
def rename(self):
name, ok = QInputDialog.getText(self, "Переименовать", "Название:",
QLineEdit.Normal, self.label)
if ok and name.strip():
self.label = name.strip()
self._set_text()
self.canvas.save()
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton:
self._press = {"g": e.globalPosition().toPoint(), "pos": self.pos()}
self._moved = False
self.raise_()
def mouseMoveEvent(self, e):
if self._press:
d = e.globalPosition().toPoint() - self._press["g"]
if abs(d.x()) > 4 or abs(d.y()) > 4:
self._moved = True
if self._moved:
self.move(self._clamp(self._press["pos"] + d))
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and self._press:
if not self._moved:
self.open_target()
else:
self.canvas.save()
self._press = None
def contextMenuEvent(self, e):
menu = QMenu(self)
a_open = menu.addAction("Открыть")
a_ren = menu.addAction("Переименовать")
a_dir = None
if not self.is_web:
a_dir = menu.addAction("Открыть папку с файлом")
menu.addSeparator()
a_del = menu.addAction("Удалить")
sel = menu.exec(e.globalPos())
if sel == a_open:
self.open_target()
elif sel == a_ren:
self.rename()
elif a_dir and sel == a_dir:
d = os.path.dirname(self.path)
if d:
QDesktopServices.openUrl(QUrl.fromLocalFile(d))
elif sel == a_del:
self.canvas.remove_item(self)
e.accept()
class DropCanvas(QWidget):
def __init__(self, window):
super().__init__()
self.window_ref = window
self.items = []
self.setAcceptDrops(True)
self.setStyleSheet("DropCanvas{background:#17181c;}")
def save(self):
self.window_ref.save()
def add_item(self, path, pos=None, center=True, label=None):
web = is_url(path)
if label is None:
if web:
label = QUrl(path).host() or path
else:
label = os.path.basename(os.path.normpath(path)) or path
it = ShortcutItem(path, label, self, is_web=web)
if pos is None:
n = len(self.items)
pos = QPoint(24 + (n % 5) * 24, 24 + (n % 5) * 24)
if center:
pos = pos - QPoint(it.width() // 2, it.height() // 2)
it.move(it._clamp(pos))
it.show()
self.items.append(it)
def remove_item(self, it):
if it in self.items:
self.items.remove(it)
it.deleteLater()
self.save()
def dragEnterEvent(self, e):
md = e.mimeData()
if md.hasUrls() or md.hasText():
e.acceptProposedAction()
def dragMoveEvent(self, e):
md = e.mimeData()
if md.hasUrls() or md.hasText():
e.acceptProposedAction()
def dropEvent(self, e):
pos = e.position().toPoint()
md = e.mimeData()
added = False
if md.hasUrls():
for url in md.urls():
if url.isLocalFile():
self.add_item(url.toLocalFile(), pos)
added = True
elif url.scheme() in ("http", "https"):
self.add_item(url.toString(), pos)
added = True
pos += QPoint(20, 20)
if not added and md.hasText():
txt = md.text().strip()
if txt.startswith("www."):
txt = "https://" + txt
if is_url(txt):
self.add_item(txt, pos)
added = True
if added:
e.acceptProposedAction()
self.save()
def mouseDoubleClickEvent(self, e):
if self.childAt(e.position().toPoint()) is None:
self._add_dialog(False, e.position().toPoint())
def contextMenuEvent(self, e):
if self.childAt(e.pos()) is not None:
return
menu = QMenu(self)
a_f = menu.addAction("Добавить файл…")
a_d = menu.addAction("Добавить папку…")
a_u = menu.addAction("Добавить ссылку (URL)…")
sel = menu.exec(e.globalPos())
if sel == a_f:
self._add_dialog(False, e.pos())
elif sel == a_d:
self._add_dialog(True, e.pos())
elif sel == a_u:
self.add_url_dialog(e.pos())
def _add_dialog(self, folder, pos):
if folder:
p = QFileDialog.getExistingDirectory(self, "Выберите папку")
else:
p, _ = QFileDialog.getOpenFileName(self, "Выберите файл")
if p:
self.add_item(p, pos)
self.save()
def add_url_dialog(self, pos):
url, ok = QInputDialog.getText(self, "Добавить ссылку", "URL:",
QLineEdit.Normal, "https://")
if ok and url.strip():
u = url.strip()
if u.startswith("www."):
u = "https://" + u
elif not is_url(u):
u = "https://" + u
self.add_item(u, pos)
self.save()
class TopBar(QFrame):
def __init__(self, win):
super().__init__()
self.win = win
self.setFixedHeight(38)
self.setObjectName("topbar")
h = QHBoxLayout(self)
h.setContentsMargins(8, 0, 8, 0); h.setSpacing(6)
self.pin = QPushButton("📌"); self.pin.setCheckable(True)
self.pin.setToolTip("Закрепить (не прятать)")
self.pin.clicked.connect(win.toggle_pin)
title = QLabel("Shelf"); title.setStyleSheet("color:#cfd2d6;font-weight:600;")
self.add_tab = QPushButton("+"); self.add_tab.setToolTip("Добавить вкладку")
self.add_tab.clicked.connect(win.add_tab)
self.rem_tab = QPushButton("-"); self.rem_tab.setToolTip("Удалить вкладку")
self.rem_tab.clicked.connect(win.remove_current_tab)
self.close = QPushButton("✕"); self.close.setToolTip("Закрыть")
self.close.clicked.connect(win.close)
for b in (self.pin, self.add_tab, self.rem_tab, self.close):
b.setFixedSize(28, 26)
h.addWidget(self.pin); h.addWidget(title); h.addStretch(1)
h.addWidget(self.add_tab); h.addWidget(self.rem_tab); h.addWidget(self.close)
self._off = None
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton:
self._off = e.globalPosition().toPoint() - self.win.frameGeometry().topLeft()
def mouseMoveEvent(self, e):
if self._off is not None:
self.win.move(e.globalPosition().toPoint() - self._off)
self.win.on_moved()
def mouseReleaseEvent(self, e):
if self._off is not None:
self._off = None
self.win.on_moved()
self.win.save()
class ShelfWindow(QWidget):
hotkey_sig = Signal()
def __init__(self):
super().__init__()
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window | Qt.WindowStaysOnTopHint)
self.setMinimumSize(320, 240)
self.data = self.load()
w = self.data.get("window", {})
self.normal_geo = QRect(w.get("x", 60), w.get("y", 60),
w.get("width", 460), w.get("height", 560))
self.pinned = w.get("pinned", True)
self.setGeometry(self.normal_geo)
self.target_state = "visible"
self.animating = False
self._build_ui()
self._build_tabs()
self.timer = QTimer(self); self.timer.timeout.connect(self.tick); self.timer.start(TICK)
self.hotkey_sig.connect(self.hotkey_toggle, Qt.QueuedConnection)
if HAVE_KEYBOARD:
ok = False
for name in ("scroll lock", "scrolllock"):
try:
keyboard.add_hotkey(name, self.hotkey_sig.emit)
ok = True
break
except Exception:
continue
if ok and BLOCK_SCROLLLOCK:
try:
keyboard.block_key("scroll lock")
except Exception:
pass
if not ok:
print("Не удалось назначить Scroll Lock как хоткей")
else:
print("Пакет keyboard не установлен — хоткей работать не будет.")
def _build_ui(self):
self.setStyleSheet("""
ShelfWindow { background:#1e1f24; }
#topbar { background:#26272e; }
QPushButton { background:#33343c; color:#e8e8e8; border:none; border-radius:6px; font-size:13px; }
QPushButton:hover { background:#45464f; }
QPushButton:checked { background:#2d6cdf; color:white; }
QTabWidget::pane { border:1px solid #33343c; top:-1px; }
QTabBar::tab { background:#26272e; color:#cfd2d6; padding:6px 14px;
border:1px solid #33343c; border-bottom:none;
border-top-left-radius:6px; border-top-right-radius:6px; margin-right:2px; }
QTabBar::tab:selected { background:#17181c; color:white; }
QTabBar::tab:hover { background:#33343c; }
""")
outer = QHBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
content = QWidget(); cv = QVBoxLayout(content); cv.setContentsMargins(0,0,0,0); cv.setSpacing(0)
self.top = TopBar(self); cv.addWidget(self.top)
self.tabs = QTabWidget(); self.tabs.setDocumentMode(True)
self.tabs.tabBarDoubleClicked.connect(self.rename_tab)
# НОВОЕ: контекстное меню на вкладках
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
self.tabs.tabBar().customContextMenuRequested.connect(self.tab_context_menu)
cv.addWidget(self.tabs, 1)
brow = QHBoxLayout(); brow.addStretch(1)
brow.addWidget(QSizeGrip(self), 0, Qt.AlignBottom | Qt.AlignRight)
cv.addLayout(brow)
outer.addWidget(content, 1)
accent = QFrame(); accent.setObjectName("accent"); accent.setFixedWidth(STRIP)
accent.setStyleSheet("#accent{background:#2d6cdf;}")
outer.addWidget(accent)
self.top.pin.setChecked(self.pinned)
def _build_tabs(self):
tabs = self.data.get("tabs", []) or [{"name": "Главная", "shortcuts": []}]
for t in tabs:
canvas = DropCanvas(self)
self.tabs.addTab(canvas, t.get("name", "Вкладка"))
for s in t.get("shortcuts", []):
p = s.get("path")
if not p:
continue
lbl = s.get("label")
if not lbl:
lbl = (QUrl(p).host() or p) if is_url(p) else (os.path.basename(os.path.normpath(p)) or p)
it = ShortcutItem(p, lbl, canvas)
it.move(s.get("x", 20), s.get("y", 20))
it.show()
canvas.items.append(it)
self.tabs.setCurrentIndex(0)
def hidden_rect(self):
return QRect(STRIP - self.normal_geo.width(), self.normal_geo.y(),
self.normal_geo.width(), self.normal_geo.height())
def tick(self):
if self.animating:
return
if self.pinned:
self.set_target("visible"); return
m = QCursor.pos()
over = self.frameGeometry().contains(m)
g = self.normal_geo
near = m.x() <= STRIP and g.y() <= m.y() <= g.y() + g.height()
self.set_target("visible" if (over or near) else "hidden")
def set_target(self, target):
if target == self.target_state:
return
self.target_state = target
end = self.normal_geo if target == "visible" else self.hidden_rect()
self.anim = QPropertyAnimation(self, b"geometry")
self.anim.setStartValue(self.geometry())
self.anim.setEndValue(end)
self.anim.setDuration(180)
self.anim.setEasingCurve(QEasingCurve.OutCubic)
self.animating = True
self.anim.finished.connect(self._done)
self.anim.start()
def _done(self):
self.animating = False
def on_moved(self):
if self.target_state == "visible":
self.normal_geo = self.geometry()
def resizeEvent(self, e):
super().resizeEvent(e)
if self.target_state == "visible":
self.normal_geo = self.geometry()
def hotkey_toggle(self):
self.pinned = not self.pinned
self.top.pin.setChecked(self.pinned)
if self.pinned:
self.set_target("visible")
else:
self.set_target("hidden")
self.save()
def toggle_pin(self, checked):
self.pinned = checked
if checked:
self.set_target("visible")
self.save()
def add_tab(self):
c = DropCanvas(self)
idx = self.tabs.addTab(c, f"Вкладка {self.tabs.count()+1}")
self.tabs.setCurrentIndex(idx)
self.save()
def remove_current_tab(self):
idx = self.tabs.currentIndex()
if idx < 0:
return
w = self.tabs.widget(idx)
self.tabs.removeTab(idx)
if w:
w.deleteLater()
if self.tabs.count() == 0:
self.add_tab()
self.save()
# НОВОЕ: контекстное меню вкладок
def tab_context_menu(self, pos):
bar = self.tabs.tabBar()
idx = bar.tabAt(pos)
menu = QMenu(self)
if idx >= 0:
a_ren = menu.addAction("Переименовать")
a_del = menu.addAction("Удалить")
sel = menu.exec(bar.mapToGlobal(pos))
if sel == a_ren:
self.rename_tab(idx)
elif sel == a_del:
self.tabs.setCurrentIndex(idx)
self.remove_current_tab()
else:
a_add = menu.addAction("Добавить вкладку")
sel = menu.exec(bar.mapToGlobal(pos))
if sel == a_add:
self.add_tab()
def rename_tab(self, idx):
cur = self.tabs.tabText(idx)
name, ok = QInputDialog.getText(self, "Имя вкладки", "Название:",
QLineEdit.Normal, cur)
if ok and name.strip():
self.tabs.setTabText(idx, name.strip())
self.save()
def load(self):
if os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
return {}
def gather(self):
d = {"window": {"x": self.normal_geo.x(), "y": self.normal_geo.y(),
"width": self.normal_geo.width(), "height": self.normal_geo.height(),
"pinned": self.pinned}, "tabs": []}
for i in range(self.tabs.count()):
canvas = self.tabs.widget(i)
sc = [{"path": it.path, "label": it.label, "x": it.x(), "y": it.y()}
for it in canvas.items]
d["tabs"].append({"name": self.tabs.tabText(i), "shortcuts": sc})
return d
def save(self):
try:
with open(DATA_FILE, "w", encoding="utf-8") as f:
yaml.safe_dump(self.gather(), f, allow_unicode=True, sort_keys=False)
except Exception as e:
print("save error:", e)
def closeEvent(self, e):
self.timer.stop()
if HAVE_KEYBOARD:
try:
keyboard.unhook_all()
except Exception:
pass
self.save()
e.accept()
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
win = ShelfWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Сохраните файл как shelf.py. При первом запуске скрипт сам доустановит недостающие пакеты.
Ярлык, чтобы ничего не вводить и запустить программу
Чтобы не помнить никаких команд, создайте рядом файл shelf. bat:
@echo off
py -3.13 "%~dp0shelf.py"
if errorlevel 1 pause
3.13 — версия Python. Подойдут и другие версии.
Что умеет готовая утилита
- вкладки: добавлять, удалять, переименовывать;
- перетаскивание файлов и папок из Проводника прямо на вкладку;
- перетаскивание ссылок из браузера (и запасной пункт «Добавить ссылку (URL)…»);
- клик по ярлыку открывает файл/папку/сайт; правый клик — переименовать/удалить;
- окно прячется за левый край и вызывается мышкой или клавишей Scroll Lock;
- кнопка «пин» закрепляет окно;
- всё состояние лежит в читаемом shelf.yaml.
Ссылки на сайты выглядят, как папка со стрелкой. Этот момент легко исправить соответствующим промптом, но такой вид особо и не мешает. Папка, так папка.
Проблемы, с которыми я столкнулся, и как они решаются
При первом запуске получил ошибку: «No matching distribution found for PySide6». Не спешите винить код — посмотрите, каким Python вы запустились. У меня это оказался python3.13t.exe: буква «t» означает free-threaded сборку (без GIL). PySide6 под неё пока не выпускается, поэтому pip физически не находит подходящий пакет. Решение: поставить обычный Python 3.13 с python.org (галка «Add to PATH») и запускать командой py -3.13 shelf.py.
Здесь 3.13 — версия Python. В таблице ниже показан минимум необходимых команд командной строки. Впрочем, pip не нужен: при первом запуске программы будет авто-установка пакетов: при старте скрипт проверяет, чего не хватает, и сам вызывает pip. Первый запуск может занять минуту (качается PySide6), дальше — мгновенно.
| команда | что делает |
|---|---|
| py -0 | Cписок установленных Пайтонов |
| py -3.13 файл. py | запустить программу конкретной версией интерпретатора |
| pip install … | установить пакет … |
Итог
Не бойтесь писать утилиты для себя. Даже без опыта программирования можно быстро собрать инструмент, который упростит повседневные задачи. Нужна не слишком сложная идея и промпт, а нейросеть напишет рабочий код.





0 комментариев
Добавить комментарий