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.
#!/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"<title[^>]*>(.*?)</title>",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
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"<title[^>]*>(.*?)</title>",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
#!/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
#!/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())