This is a very simple quick and dirty app using a `QTableWidget` to display a list of urls copied to the clipboard since the program was started. It relies on the `pp` script, which is a wrapper around e.g. `pbpaste` on macos, or `xsel` on Linux. Clicking on a table cell runs a command with the url as the argument. In this case it is `f3c`, which sets the url of the front Chrome tab on my mac mini via `osascript`. **Health Warning** This is far from robust. It is a quick bodge to scratch an itch, like a lot of stuff I write. But it serves as simple examples for a number of things with Python and Qt. If I find myself using it a lot, then based on how I use it, I'll think out how to rewrite it. This is the initial bodge I wrote after thinking 'what was that url' this morning, viz, an app that watches the clipboard for urls. The other itch to scratch was to have an easy way of telling the mac mini connected to my TV to play a given youtube url. # Current Version This takes the command from the environment variable `CMD`, which is expected to take the url as its only argument. (Anything clever should be done in a script that `CMD` points to.) It could easily be modified to pass both url and title, but I haven't found a use for that yet, so haven't bothered. There are three keyboard shortcuts: C-c to clear, s to save, and l to load. It uses JSON files to store data. ```py #!/usr/bin/env python from icecream import ic; ic.configureOutput(includeContext=True) import sys import re import subprocess import requests import os import json from threading import Thread import PySide6.QtCore import PySide6.QtGui import PySide6.QtWidgets import PySide6.QtNetwork app = PySide6.QtWidgets.QApplication() CMD = os.getenv("CMD","f3c") def copy(x): subprocess.run(["pc"],input=x.encode()) def paste(): m = subprocess.run(["pp"],capture_output=True) f = sys.stderr if (x := m.returncode) > 0: out = m.stdout.splitlines() err = m.stderr.splitlines() print(f"pp returned {x}:",file=f) print(f"stdout:",file=f) for line in out: line = b" " + line + b"\n" f.buffer.write(line) print(f"stderr:",file=f) for line in err: line = b" " + line + b"\n" f.buffer.write(line) return "" else: return m.stdout.decode() class Url: def __init__(self,url,title=None): self.url = url self.title = title class UrlTable(PySide6.QtWidgets.QTableWidget): def __init__(self,*xs,**kw): super().__init__(*xs,**kw) self.parent = xs[0] def mousePressEvent(self, event): ic(event) if event.button() == PySide6.QtCore.Qt.LeftButton: it = self.itemAt(event.pos()) if it is not None: row = it.row() self.parent.clicked_row(row,event) def keyPressEvent(self,event): self.parent.keyPressEvent(event) class UrlList(PySide6.QtWidgets.QWidget): def __init__(self,init_urls): super().__init__() #self.table_widget = PySide6.QtWidgets.QTableWidget(self) self.table_widget = UrlTable(self) self.resize(1300,600) self.urlset = set() self.urls = [] self.urlfont = PySide6.QtGui.QFont("Hack Nerd Font Mono",14) self.titlefont = PySide6.QtGui.QFont("Optima",18) self.urlcolor = PySide6.QtGui.QColor("#aaffaa") self.titlecolor = PySide6.QtGui.QColor("#aaaaff") #self.urlStyle = 'QTableWidgetItem { font-family: "Hack Nerd Font Mono"; font-size: 14px; color: red; }' #self.titleStyle = 'QTableWidgetItem { font-family: "Optima"; font-size: 18px; color: yellow; }' for url in init_urls: if re.match(r"https?://",url): if not url in self.urlset: self.urlset.add(url) self.urls.append(Url(url)) table = self.table_widget table.setHorizontalHeaderLabels(["URL","Title"]) table.setRowCount(0) table.setColumnCount(2) table.resize(self.rect().width(),self.rect().height()) self.update_table() self.timer = PySide6.QtCore.QTimer() timer = self.timer timer.timeout.connect(self.tick) timer.setInterval(1000) timer.start() #table.cellClicked.connect(self.cell_clicked) table.setSelectionMode(PySide6.QtWidgets.QTableWidget.NoSelection) def cell_clicked(self,row,column): url = self.urls[row] print(row,column,url) subprocess.run(["f3c",url.url]) def keyPressEvent(self,e): key = e.key() ic("key",e) key = PySide6.QtGui.QKeySequence(key).toString().lower() mods = e.modifiers() match key: case "c": if mods == PySide6.QtCore.Qt.ControlModifier: self.urlset = set() self.urls = [] self.update_table() return case "s": self.save_urls() return case "l": self.load_urls() return def save_urls(self): file_path, _ = PySide6.QtWidgets.QFileDialog.getSaveFileName(self, "Save File", "", "JSON Files (*.json)") # If the user selected a path, print it or handle it as needed if file_path: try: data = [] for u in self.urls: data.append([u.url,u.title]) with open(file_path,"wt") as f: json.dump(data,f) PySide6.QtWidgets.QMessageBox.information(self,"Saved Successfully",f"Saved to {file_path}") except Exception as e: msg = f"Exception {e} saving to {file_path}" print(msg) PySide6.QtWidgets.QMessageBox.critical(self,"Failed to save",msg) def message_box(self,msg): PySide def load_urls(self): file_path, _ = PySide6.QtWidgets.QFileDialog.getOpenFileName(self, "Load File", "", "JSON Files (*.json)") # If the user selected a path, print it or handle it as needed if file_path: try: if not os.path.exists(file_path): msg = f"File '{file_path}' does not exist" PySide6.QtWidgets.QMessageBox.critical(self,"Failed to load",msg) return with open(file_path) as f: data = json.load(f) newurls = [] newurlset = set() if type(data) is not list: raise Exception("JSON data must be array") for item in data: if not type(item) is list: raise Exception("JSON item must be array") if not len(item) == 2: raise Exception("JSON item must be array with 2 items") url, title = item if type(url) is not str: raise Exception(f"URL must be string, got {url} ({type(url)})") if type(title) is not str: raise Exception(f"Title must be string, got {title} ({type(title)})") if not url in newurlset: newurls.append(Url(url,title)) newurlset.add(url) self.urls = newurls self.urlset = newurlset self.update_table() PySide6.QtWidgets.QMessageBox.information(self,"Loaded Successfully",f"Loaded from {file_path}") except Exception as e: msg = f"Exception {e} loading from {file_path}" print(msg) PySide6.QtWidgets.QMessageBox.critical(self,"Failed to load",msg) def clicked_row(self,row,event): mods = event.modifiers() if mods == PySide6.QtCore.Qt.NoModifier: url = self.urls[row] subprocess.run([CMD,url.url]) elif mods == PySide6.QtCore.Qt.ControlModifier: url = self.urls[row] copy(url.url) elif mods == PySide6.QtCore.Qt.AltModifier: url = self.urls[row] self.urls.pop(row) if url.url in self.urlset: self.urlset.remove(url.url) self.update_table() else: print("Other") print("click",row,mods) def tick(self): p = paste() if re.match(r"https?://",p): if not p in self.urlset: self.urlset.add(p) self.urls.append(Url(p)) self.update_table() for i,url in enumerate(self.urls): title = url.title if title is None: print(f"Fetch title for {url.url}") thread = Thread(target=self.get_title_for(url)) thread.start() def update_table(self): table = self.table_widget table.setRowCount(len(self.urls)) for i,url in enumerate(self.urls): title = url.title if title is None: title = "No title" table.setItem(i,0,x := PySide6.QtWidgets.QTableWidgetItem(url.url)) table.setItem(i,1,y := PySide6.QtWidgets.QTableWidgetItem(title)) x.setFont(self.urlfont) y.setFont(self.titlefont) x.setForeground(self.urlcolor) y.setForeground(self.titlecolor) table.resizeColumnsToContents() self.update() def resizeEvent(self,e): self.table_widget.resize(self.rect().width(),self.rect().height()) self.table_widget.resizeColumnsToContents() super().resizeEvent(e) def get_title_for(self,url): print(f"Getting title for {url.url}") r = requests.get(url.url) print(f"Got reply for for {url.url}") t = r.text.replace("\r","").replace("\n"," ") m = re.search(r"