Repository: ibis-project/ibis

Issue:
bug: interactive repl shows parent table if expression name is longer than terminal width
### What happened?

This is a wacky one.  But take this example:

```python
[ins] In [15]: import ibis

[ins] In [16]: ibis.options.interactive = True

[ins] In [17]: import pandas as pd

[ins] In [18]: df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": ["a", "b", "c"]})

[ins] In [19]: t = ibis.memtable(df)

[ins] In [20]: t.y.replace("a", "b").replace("b", "c").replace("c", "d")
Out[20]: 
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ StringReplace(StringReplace(StringReplace(y, 'a', 'b'), 'b', 'c'), 'c', 'd') ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ string                                                                       │
├──────────────────────────────────────────────────────────────────────────────┤
│ d                                                                            │
│ d                                                                            │
│ d                                                                            │
└──────────────────────────────────────────────────────────────────────────────┘
```

Now, I shrink the terminal width a bit, so that `StringReplace...` won't fit without wrapping, and re-run the same command:

```python
[nav] In [21]: t.y.replace("a", "b").replace("b", "c").replace(
          ...: "c", "d")
Out[21]: 
┏━━━━━━━━━┳━━━━━━━━┳━━━┓
┃ x       ┃ y      ┃ … ┃
┡━━━━━━━━━╇━━━━━━━━╇━━━┩
│ float64 │ string │ … │
├─────────┼────────┼───┤
│     1.0 │ a      │ … │
│     2.0 │ b      │ … │
│     3.0 │ c      │ … │
└─────────┴────────┴───┘

```

### What version of ibis are you using?

master

### What backend(s) are you using, if any?

DuckDB

### Relevant log output

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@jcrist has this reproducer:

```python
In [1]: import ibis, pandas as pd

In [2]: df = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})

In [3]: con = ibis.duckdb.connect()

In [4]: t = con.register(df)

In [5]: t2 = (t.y + 1).as_table()  # column expression back to table

In [6]: t3 = t2.select([])  # select no columns

In [7]: t3.schema()  # what
Out[7]: 
ibis.Schema {
  x  int64
  y  int64
}

In [8]: t3.execute()  # why
Out[8]: 
   x  y
0  1  4
1  2  5
2  3  6
```
This is due to two issues:

- The column subselection code can result in the repr trying to compute 0 columns (see https://github.com/ibis-project/ibis/blob/cd4a7688ffb6ebfb8b61ad4d2ca7ceb90792ca03/ibis/expr/types/pretty.py#L201). We should adjust this code to always compute at least one column.
- `table.select([])` does weird things when computing. In this case it seems to result in the parent table. This might point to a deeper bug, but a cheap fix would be to forbid selecting 0 columns. It's not clear what that would even mean.
An empty selection should probably error. It's inconsistent for both no selected columns and the child table to mean "all columns".

Resolving patch:
diff --git a/ibis/expr/types/pretty.py b/ibis/expr/types/pretty.py
--- a/ibis/expr/types/pretty.py
+++ b/ibis/expr/types/pretty.py
@@ -193,21 +193,23 @@ def to_rich_table(table, console_width=None):
     if console_width is None:
         console_width = float("inf")
 
+    orig_ncols = len(table.columns)
+
     # First determine the maximum subset of columns that *might* fit in the
     # current console. Note that not every column here may actually fit later
     # on once we know the repr'd width of the data.
-    columns_truncated = False
     if console_width:
         computed_cols = []
         remaining = console_width - 1  # 1 char for left boundary
         for c in table.columns:
             needed = len(c) + 3  # padding + 1 char for right boundary
-            if needed < remaining:
+            if (
+                needed < remaining or not computed_cols
+            ):  # always select at least one col
                 computed_cols.append(c)
                 remaining -= needed
             else:
                 table = table.select(*computed_cols)
-                columns_truncated = True
                 break
 
     # Compute the data and return a pandas dataframe
@@ -230,6 +232,7 @@ def to_rich_table(table, console_width=None):
         ):
             # Don't truncate non-nested dtypes
             min_width = max(min_width, len(dtype_str))
+
         min_width = max(min_width, len(name))
         if max_width is not None:
             max_width = max(min_width, max_width)
@@ -239,19 +242,30 @@ def to_rich_table(table, console_width=None):
             col_data.append(formatted)
             formatted_dtypes.append(dtype_str)
             remaining -= needed
+        elif not col_info:
+            # Always pretty print at least one column. If only one column, we
+            # truncate to fill the available space, leaving room for the
+            # ellipsis & framing.
+            min_width = remaining - 3  # 3 for framing
+            if orig_ncols > 1:
+                min_width -= 4  # 4 for ellipsis
+            col_info.append((name, dtype, min_width, min_width))
+            col_data.append(formatted)
+            formatted_dtypes.append(dtype_str)
+            break
         else:
             if remaining < 4:
                 # Not enough space for ellipsis column, drop previous column
                 col_info.pop()
                 col_data.pop()
                 formatted_dtypes.pop()
-            columns_truncated = True
             break
 
     # rich's column width computations are super buggy and can result in tables
     # that are much wider than the available console space. To work around this
     # for now we manually compute all column widths rather than letting rich
     # figure it out for us.
+    columns_truncated = orig_ncols > len(col_info)
     col_widths = {}
     flex_cols = []
     remaining = console_width - 1


Test patch: