Repository: kivy/kivy

Issue:
'Carousel.remove_widget()' causes IndexError when the last slide is active, and the error is not delivered to a user
### Versions

* Python: 3.7.1
* OS: Linux Mint 18.2
* Kivy:  v2.0.0.dev0, git-e828f22
* Kivy installation method: `git clone` then `pip -e`

### Description

When a `Carousel` meets the following conditions:

- having 2 or more slides (`len(carousel.slides) >= 2`)
- the last slide is active  (`carousel.index == len(carousel.slides) - 1`)

removing any slide causes `IndexError`. The error is shown in the strerr, but can't be catched.

### Code and Logs

```python
from kivy.app import App
from kivy.factory import Factory
from kivy.clock import Clock
import random


N_SLIDES = random.choice(range(2, 4))  # Has to be 2 or greater

class SampleApp(App):
    def build(self):
        c = Factory.Carousel()
        for i in range(N_SLIDES):
            c.add_widget(Factory.Label(text=str(i), font_size=100))
        c.index = N_SLIDES - 1  # switch to the last slide
        return c
    
    def on_start(self):
        c = self.root
        Clock.schedule_once(
            lambda __: c.remove_widget(random.choice(c.slides)),  # remove any slide
            c.anim_move_duration + 1)


if __name__ == '__main__':
    SampleApp().run()
```

The following errors are printed, but the app still is running and I can switch to other slide.

```
 Traceback (most recent call last):
   File "kivy/properties.pyx", line 599, in kivy.properties.Property.dispatch
     ps.observers.dispatch(obj, ps.value, None, None, 0)
   File "kivy/_event.pyx", line 1214, in kivy._event.EventObservers.dispatch
     result = self._dispatch(
   File "kivy/_event.pyx", line 1120, in kivy._event.EventObservers._dispatch
     return f(obj, value)
   File "kivy/properties.pyx", line 1503, in kivy.properties.AliasProperty.trigger_change
     cpdef trigger_change(self, EventDispatcher obj, value):
   File "kivy/properties.pyx", line 1505, in kivy.properties.AliasProperty.trigger_change
     dvalue = ps.getter(obj)
   File "/tmp/firefox/kivy/kivy/uix/carousel.py", line 194, in _curr_slide
     return self.slides[self.index or 0]
 IndexError: list index out of range
 Exception ignored in: 'kivy.properties.observable_list_dispatch'
 Traceback (most recent call last):
   File "kivy/properties.pyx", line 599, in kivy.properties.Property.dispatch
     ps.observers.dispatch(obj, ps.value, None, None, 0)
   File "kivy/_event.pyx", line 1214, in kivy._event.EventObservers.dispatch
     result = self._dispatch(
   File "kivy/_event.pyx", line 1120, in kivy._event.EventObservers._dispatch
     return f(obj, value)
   File "kivy/properties.pyx", line 1503, in kivy.properties.AliasProperty.trigger_change
     cpdef trigger_change(self, EventDispatcher obj, value):
   File "kivy/properties.pyx", line 1505, in kivy.properties.AliasProperty.trigger_change
     dvalue = ps.getter(obj)
   File "/tmp/firefox/kivy/kivy/uix/carousel.py", line 194, in _curr_slide
     return self.slides[self.index or 0]
 IndexError: list index out of range
```

#### pytest

```python
import pytest


@pytest.fixture(
    scope='session', params=(True, False), ids=lambda v: 'loop=' + str(v))
def loop(request):
    return request.param


@pytest.fixture(
    scope='session', params=tuple(range(5)),
    ids=lambda v: 'n_slides=' + str(v))
def n_slides(request):
    return request.param


@pytest.fixture
def carousel(loop, n_slides):
    from kivy.uix.carousel import Carousel
    from kivy.uix.widget import Widget
    c = Carousel(loop=loop)
    for i in range(n_slides):
        c.add_widget(Widget())
    return c


def test_remove_a_slide_when_the_last_slide_is_active(capsys, carousel):
    import random
    if len(carousel.slides) == 0:
        pytest.skip()
    carousel.index = len(carousel.slides) - 1
    carousel.remove_widget(random.choice(carousel.slides))
    stderr_text = capsys.readouterr().err
    for line in stderr_text.split('\n'):
        assert not line.startswith('IndexError:')


if __name__ == "__main__":
    pytest.main(args=[
        __file__,
    ])
```


Discussion:

Resolving patch:
diff --git a/kivy/uix/carousel.py b/kivy/uix/carousel.py
--- a/kivy/uix/carousel.py
+++ b/kivy/uix/carousel.py
@@ -635,9 +635,10 @@ def _change_touch_mode(self, *largs):
             return
 
     def add_widget(self, widget, index=0, canvas=None):
-        slide = RelativeLayout(size=self.size, x=self.x - self.width, y=self.y)
-        slide.add_widget(widget)
-        super(Carousel, self).add_widget(slide, index, canvas)
+        container = RelativeLayout(
+            size=self.size, x=self.x - self.width, y=self.y)
+        container.add_widget(widget)
+        super(Carousel, self).add_widget(container, index, canvas)
         if index != 0:
             self.slides.insert(index - len(self.slides), widget)
         else:
@@ -648,11 +649,14 @@ def remove_widget(self, widget, *args, **kwargs):
         # added in add_widget(). But it will break if RelativeLayout
         # implementation change.
         # if we passed the real widget
-        if widget in self.slides:
-            slide = widget.parent
-            self.slides.remove(widget)
-            super(Carousel, self).remove_widget(slide)
-            return slide.remove_widget(widget, *args, **kwargs)
+        slides = self.slides
+        if widget in slides:
+            if self.index >= slides.index(widget):
+                self.index = max(0, self.index - 1)
+            container = widget.parent
+            slides.remove(widget)
+            super(Carousel, self).remove_widget(container)
+            return container.remove_widget(widget, *args, **kwargs)
         return super(Carousel, self).remove_widget(widget, *args, **kwargs)
 
     def clear_widgets(self):


Test patch:
diff --git a/kivy/tests/test_uix_carousel.py b/kivy/tests/test_uix_carousel.py
--- a/kivy/tests/test_uix_carousel.py
+++ b/kivy/tests/test_uix_carousel.py
@@ -1,15 +1,60 @@
 import pytest
 
 
-def test_remove_widget():
+@pytest.fixture(
+    scope='session', params=(True, False), ids=lambda v: 'loop=' + str(v))
+def loop(request):
+    return request.param
+
+
+def test_remove_widget(loop):
     from kivy.uix.carousel import Carousel
     from kivy.uix.widget import Widget
 
-    c = Carousel()
-    w = Widget()
-    c.add_widget(w)
+    c = Carousel(loop=loop)
+    assert c.index is None
+    assert c.current_slide is None
+    assert len(c.children) == 0
+    assert len(c.slides) == 0
+
+    N_SLIDES = 4
+    for i in range(N_SLIDES):
+        c.add_widget(Widget())
+    assert c.index == 0
+    assert c.current_slide == c.slides[0]
+    assert len(c.children) == 3 if loop else 2
+    assert len(c.slides) == N_SLIDES
+
+    # test issue #6370
+    c.index = len(c.slides) - 1
+    c.remove_widget(c.slides[0])
+
+    # remove a slide(smaller index than the current_slide's).
+    c.index = 1
+    old_current_slide = c.current_slide
+    c.remove_widget(c.slides[c.index - 1])
+    assert c.index == 0
+    assert c.current_slide is old_current_slide
+    assert len(c.children) == 2
+    assert len(c.slides) == 2
+
+    # remove a slide(bigger index than the current_slide's).
+    old_current_slide = c.current_slide
+    c.remove_widget(c.slides[c.index + 1])
+    assert c.index == 0
+    assert c.current_slide is old_current_slide
     assert len(c.children) == 1
     assert len(c.slides) == 1
-    c.remove_widget(w)
+
+    # remove the current_slide(the last one left).
+    c.remove_widget(c.current_slide)
+    assert c.index is None
+    assert c.current_slide is None
     assert len(c.children) == 0
     assert len(c.slides) == 0
+
+
+if __name__ == "__main__":
+    pytest.main(args=[
+        __file__,
+    ])