I have an application with two Text widgets in two Frames. Clicking a link (formatted text with a tag bind) in one Text widget (Results.text
) should select text in another (Editor.text
).
When I click once on the link, the see
part works but mark_set
and applying the selection do not.
If I double click the link it works as intended, but I'd like it to work with a single click. I have tried returning "break"
from the event handler and using focus_set
, focus_force
, update_idletasks
and update
in the goto
method with no success.
I'm using Python 3.13, Tcl/Tk 8.6.15 and Windows 10.
import math
import tkinter as tk
from tkinter.font import Font
from typing import Any, Callable
LIPSUM = """\
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque et lectus lacinia enim dictum posuere. Quisque nunc tellus, luctus quis eleifend a, placerat maximus ipsum.
Nam egestas, nisi in varius tempus, enim tortor consectetur eros, ac pretium urna ipsum at orci. Praesent ut dui eu lectus efficitur ultrices. In vehicula leo faucibus tempor posuere.
Nam et est in nisi iaculis rhoncus eu et nibh. Aliquam eget risus lacus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus."""
class Editor(tk.Frame):
def __init__(self, *args: Any, **kwargs: Any):
tk.Frame.__init__(self, *args, **kwargs)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(1, weight=1)
self.line_num = tk.Text(
self,
width=3,
yscrollcommand=self.scroll_update,
cursor="arrow",
)
self.line_num.tag_configure("rjust", justify="right")
self.line_num.insert("1.0", "1", "rjust")
# disable selection
self.line_num.bindtags((str(self.line_num), str(self), "all"))
self.line_num.grid(row=1, column=0, sticky="ns")
self.text = tk.Text(
self,
yscrollcommand=self.scroll_update,
wrap="none",
)
self.text.bind("<KeyRelease>", lambda _: self.update_lines())
self.text.grid(row=1, column=1, sticky="nesw")
self.editor_v_sbr = tk.Scrollbar(self, command=self.editor_scroll)
self.editor_v_sbr.grid(row=1, column=2, sticky="ns")
self.editor_h_sbr = tk.Scrollbar(
self, command=self.text.xview, orient="horizontal" # type:ignore
)
self.editor_h_sbr.grid(row=2, column=0, columnspan=2, sticky="ew")
self.text.config(xscrollcommand=self.editor_h_sbr.set)
def editor_scroll(self, _: Any, position: float):
self.line_num.yview_moveto(position)
self.text.yview_moveto(position)
def scroll_update(self, first: float, last: float):
self.text.yview_moveto(first)
self.line_num.yview_moveto(first)
self.editor_v_sbr.set(first, last)
def update_lines(self, new_text: str | None = None):
if not new_text:
new_text = self.text.get("1.0", "end")
self.line_num.config(state="normal")
self.line_num.delete("1.0", "end")
num_lines = len(new_text.splitlines(True))
if num_lines >= 1000:
new_width = int(math.log10(num_lines)) + 1
self.line_num.config(width=new_width)
self.line_num.insert(
"1.0",
"\n".join([str(x + 1) for x in range(num_lines)]),
"rjust",
)
self.line_num.config(state="disabled")
def view_file(self):
self.text_content = LIPSUM
self.update_lines(self.text_content)
self.text.delete("1.0", "end")
self.text.insert("1.0", self.text_content)
def goto(self, start: tuple[int, int], end: tuple[int, int]):
pos: Callable[[tuple[int, int]], str] = lambda x: f"{x[0]}.{x[1]-1}"
self.text.focus_set()
self.text.see(pos(start))
self.text.mark_set("insert", pos(start))
self.text.tag_remove("sel", "1.0", "end")
self.text.tag_add("sel", pos(start), pos(end))
class Results(tk.LabelFrame):
def __init__(
self,
master: tk.Tk,
goto: Callable[[tuple[int, int], tuple[int, int]], None],
*args: Any,
**kwargs: Any,
):
tk.LabelFrame.__init__(self, master, *args, text="Results", **kwargs)
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self.text = tk.Text(
self,
wrap="none",
)
self.text.insert("end", "Lorem ipsum dolor sit amet\n")
self.text.insert("end", "View in editor", "link")
def callback():
self.goto((1, 1), (1, 27))
return "break"
self.text.tag_bind("link", "<Button-1>", lambda _: callback())
self.link_font = Font(self, "TkDefaultFont")
self.link_font.configure(underline=True)
self.text.tag_configure("link", font=self.link_font, foreground="SlateBlue3")
self.text.configure(state="disabled")
self.text.tag_bind("link", "<Enter>", self.link_enter)
self.text.tag_bind("link", "<Leave>", self.link_leave)
self.text.grid(row=0, column=0, sticky="nesw", padx=(10, 0), pady=(10, 0))
self.y_sbr = tk.Scrollbar(self, command=self.text.yview) # type:ignore
self.y_sbr.grid(row=0, column=1, sticky="ns", padx=(0, 10), pady=(10, 0))
self.x_sbr = tk.Scrollbar(
self, command=self.text.xview, orient="horizontal" # type:ignore
)
self.x_sbr.grid(row=1, column=0, sticky="ew", pady=(0, 10), padx=(10, 0))
self.text.configure(yscrollcommand=self.y_sbr.set)
self.text.configure(xscrollcommand=self.x_sbr.set)
self.goto = goto
def link_enter(self, _: Any):
self.text.config(cursor="hand2")
def link_leave(self, _: Any):
self.text.config(cursor="")
class App(tk.Tk):
def __init__(self):
tk.Tk.__init__(self)
self.editor = Editor(self)
self.editor.grid(row=0, column=1, sticky="nesw", padx=10, pady=10)
self.results = Results(self, self.editor.goto)
self.results.grid(row=0, column=2, sticky="nesw", padx=10, pady=10)
self.editor.text.focus()
self.editor.view_file()
if __name__ == "__main__":
app = App()
app.mainloop()
I think the simplest solution is to force the focus back to the text widget, but schedule that to happen after all other event processing.
Add this anywhere in goto
:
self.text.after_idle(self.text.focus_set)