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"]*>(.*?)",t,re.I) if not m: url.title = "No title" else: url.title = m.group(1) self.update_table() init_urls = [] args = sys.argv[1:] stdin = None for arg in args: if re.match(r"https?://",arg): init_urls.append(arg) elif arg == "-": if stdin is None: stdin = sys.stdin.read().rstrip().splitlines() for line in stdin: if re.match(r"https?://",line): init_urls.append(line) elif os.path.isfile(arg): try: with open(arg) as f: lines = f.read().rstrip().splitlines() for line in lines: if re.match(r"https?://",line): init_urls.append(line) except Exception as e: print(f"Exception {e} ({type(e)}) opening {arg}") else: print(f"Can't deal with {arg}") main = UrlList(init_urls) main.show() app.exec() ``` # Older Version ```py import sys import re import subprocess import requests import os from threading import Thread import PySide6.QtCore import PySide6.QtGui import PySide6.QtWidgets import PySide6.QtNetwork app = PySide6.QtWidgets.QApplication() 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 F3Cell(PySide6.QtWidgets.QTableWidgetItem): def __init__(self,label,href=None): super().__init__(label) if href is None: href = label self.href = href def mousePressEvent(self,e): button = e.button() if button == PySide6.QtGui.LeftButton: subprocess.run(["f3c",self.href]) elif button == PySide6.QtGui.RightButton: subprocess.run(["pce",self.href]) class Url: def __init__(self,url,title=None): self.url = url self.title = title class UrlList(PySide6.QtWidgets.QWidget): def __init__(self,init_urls): super().__init__() self.table_widget = PySide6.QtWidgets.QTableWidget(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) 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 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 := F3Cell(url.url)) table.setItem(i,1,y := F3Cell(title)) x.setFont(self.urlfont) y.setFont(self.titlefont) 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"]*>(.*?)",t,re.I) if not m: url.title = "No title" else: url.title = m.group(1) self.update_table() init_urls = [] args = sys.argv[1:] stdin = None for arg in args: if re.match(r"https?://",arg): init_urls.append(arg) elif arg == "-": if stdin is None: stdin = sys.stdin.read().rstrip().splitlines() for line in stdin: if re.match(r"https?://",line): init_urls.append(line) elif os.path.isfile(arg): try: with open(arg) as f: lines = f.read().rstrip().splitlines() for line in lines: if re.match(r"https?://",line): init_urls.append(line) except Exception as e: print(f"Exception {e} ({type(e)}) opening {arg}") else: print(f"Can't deal with {arg}") main = UrlList(init_urls) main.show() app.exec() ``` ## pp ```bash #!/bin/bash if [ -n "$WAYLAND_DISPLAY" ]; then paste() { wl-paste; } elif [ -n "$DISPLAY" ]; then # X11 paste() { xsel -o -b; } elif [ -d "/Applications" ]; then # macos paste() { pbpaste; } elif [ -d "/cygdrive/c/cygwin64" ]; then # cygwin paste() { cat /dev/clipboard; } else echo "Cannot paste as not gui" 1>&2 exit 1 fi if [ $# = 0 ]; then paste else for s; do if [ -n "$s" ]; then if [ -e "$1" ]; then if [ -z "$CLOBBER" ] && [ -o noclobber ]; then echo "$1 exists, not overwriting" continue fi fi paste | tee "$1" else echo "Blank argument" fi done fi ``` ## osascript ```py #!/usr/bin/env python import subprocess import sys args = sys.argv[1:] if len(args) == 0: print(f"{sys.argv[0]} url") exit(1) url = args[0] if '"' in url: print(f"Url can't contain \"") exit(1) script = f"""tell application "Google Chrome" tell front window set URL of active tab to "{url}" end tell end tell """ subprocess.run(["osascript"],input=script.encode()) ```