bpo-37903: IDLE: add shell sidebar mouse interactions (GH-25708)

Left click and drag to select lines.  With selection, right click for context menu with copy and copy-with-prompts.
Also add copy-with-prompts to the text-box context menu.

Co-authored-by: Terry Jan Reedy <tjreedy@udel.edu>
diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py
index 447e9ec..4e74400 100755
--- a/Lib/idlelib/pyshell.py
+++ b/Lib/idlelib/pyshell.py
@@ -33,6 +33,7 @@
     raise SystemExit(1)
 
 from code import InteractiveInterpreter
+import itertools
 import linecache
 import os
 import os.path
@@ -865,6 +866,13 @@ class PyShell(OutputWindow):
     rmenu_specs = OutputWindow.rmenu_specs + [
         ("Squeeze", "<<squeeze-current-text>>"),
     ]
+    _idx = 1 + len(list(itertools.takewhile(
+        lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs)
+    ))
+    rmenu_specs.insert(_idx, ("Copy with prompts",
+                              "<<copy-with-prompts>>",
+                              "rmenu_check_copy"))
+    del _idx
 
     allow_line_numbers = False
     user_input_insert_tags = "stdin"
@@ -906,6 +914,7 @@ def __init__(self, flist=None):
         text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
         text.bind("<<toggle-debugger>>", self.toggle_debugger)
         text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
+        text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
         if use_subprocess:
             text.bind("<<view-restart>>", self.view_restart_mark)
             text.bind("<<restart-shell>>", self.restart_shell)
@@ -979,6 +988,42 @@ def replace_event(self, event):
     def get_standard_extension_names(self):
         return idleConf.GetExtensions(shell_only=True)
 
+    def copy_with_prompts_callback(self, event=None):
+        """Copy selected lines to the clipboard, with prompts.
+
+        This makes the copied text useful for doc-tests and interactive
+        shell code examples.
+
+        This always copies entire lines, even if only part of the first
+        and/or last lines is selected.
+        """
+        text = self.text
+
+        selection_indexes = (
+            self.text.index("sel.first linestart"),
+            self.text.index("sel.last +1line linestart"),
+        )
+        if selection_indexes[0] is None:
+            # There is no selection, so do nothing.
+            return
+
+        selected_text = self.text.get(*selection_indexes)
+        selection_lineno_range = range(
+            int(float(selection_indexes[0])),
+            int(float(selection_indexes[1]))
+        )
+        prompts = [
+            self.shell_sidebar.line_prompts.get(lineno)
+            for lineno in selection_lineno_range
+        ]
+        selected_text_with_prompts = "\n".join(
+            line if prompt is None else f"{prompt} {line}"
+            for prompt, line in zip(prompts, selected_text.splitlines())
+        ) + "\n"
+
+        text.clipboard_clear()
+        text.clipboard_append(selected_text_with_prompts)
+
     reading = False
     executing = False
     canceled = False