Repository: iced-rs/iced (language: rust)

Issue:
text_editor::Content.text() gives inconsistent/misleading results
### Is your issue REALLY a bug?

- [x] My issue is indeed a bug!
- [x] I am not crazy! I will not fill out this form just to ask a question or request a feature. Pinky promise.

### Is there an existing issue for this?

- [x] I have searched the existing issues.

### Is this issue related to iced?

- [x] My hardware is compatible and my graphics drivers are up-to-date.

### What happened?

`text_editor::Content::text()` appends a newline character ('\n') to the end of the returned string if there is not one present.

This causes issues/inconsistencies when the actual content of the `text_editor` genuinely ends with a newline character.

For example, if I have a `text_editor` with no content in it, it displays on the screen as a box with a single line. At this point calling `text()` on its content returns a string with a length of 1 and content of "\n".

If I then focus on the text_editor and hit return, the display correctly changes to a box with two lines. At this point, however, calling `text()` on the content returns a string with a length of 1 and content of "\n".

Clearly at this stage the actual content of the `text_editor`s is different but I have no way of determining this when calling `text()` as it returns identical results for two different situations.

This issue is due to the check on lines 410-412 of [widget/src/text_editor.rs](https://github.com/iced-rs/iced/blob/2b920235b528590694930da542e77db973d82be7/widget/src/text_editor.rs#L410-L412):
```
        if !text.ends_with('\n') {
            text.push('\n');
        }
```

### What is the expected behavior?

I expect to be able to unambiguously determine the content of the `text_editor` and relate it correctly to what is displayed on the screen.

Options I can see for this are:
 - Always add a newline character to the string returned by `text()`, allowing the consumer to handle removing the newline to get to the actual content
 - Never add a newline, leaving the consumer to add their own newline should that fit their purposes

Personally, I would prefer that the response didn't add the newline so that what is returned to me is what is actually in the `text_editor`.

### Version

master

### Operating System

Linux

### Do you have any log output?

```shell

```


Discussion / hints:
I encountered this same issue; In my case, I'm computing hashes over data the user has entered, and so it's very sensitive to the actual contents the user provides.

Doing a bit of git history spelunking, it seems this helper method was added [here](https://github.com/iced-rs/iced/commit/8cc19de254c37d3123d5ea1b6513f1f34d35c7c8).

At the time, this was being used for a file editor example. It makes sense that a file editor would append new lines before saving the file, but that shouldn't (IMO) be the responsibility of the control itself.

At the very least, can we have a `.text_raw()` method, if it's now a question of backwards compatibility?
Ah, yeah. This has been bugging me too.

Let me fix it.

Fix patch:
diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs
index bbcdd8ffd3..5732c41bb9 100644
--- a/core/src/renderer/null.rs
+++ b/core/src/renderer/null.rs
@@ -137,7 +137,7 @@ impl text::Editor for () {
         None
     }
 
-    fn line(&self, _index: usize) -> Option<&str> {
+    fn line(&self, _index: usize) -> Option<text::editor::Line<'_>> {
         None
     }
 
diff --git a/core/src/text/editor.rs b/core/src/text/editor.rs
index cd30db3a38..6921c61c6b 100644
--- a/core/src/text/editor.rs
+++ b/core/src/text/editor.rs
@@ -3,6 +3,7 @@ use crate::text::highlighter::{self, Highlighter};
 use crate::text::{LineHeight, Wrapping};
 use crate::{Pixels, Point, Rectangle, Size};
 
+use std::borrow::Cow;
 use std::sync::Arc;
 
 /// A component that can be used by widgets to edit multi-line text.
@@ -28,7 +29,7 @@ pub trait Editor: Sized + Default {
     fn selection(&self) -> Option<String>;
 
     /// Returns the text of the given line in the [`Editor`], if it exists.
-    fn line(&self, index: usize) -> Option<&str>;
+    fn line(&self, index: usize) -> Option<Line<'_>>;
 
     /// Returns the amount of lines in the [`Editor`].
     fn line_count(&self) -> usize;
@@ -189,3 +190,41 @@ pub enum Cursor {
     /// Cursor selecting a range of text
     Selection(Vec<Rectangle>),
 }
+
+/// A line of an [`Editor`].
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct Line<'a> {
+    /// The raw text of the [`Line`].
+    pub text: Cow<'a, str>,
+    /// The line ending of the [`Line`].
+    pub ending: LineEnding,
+}
+
+/// The line ending of a [`Line`].
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum LineEnding {
+    /// Use `\n` for line ending (POSIX-style)
+    #[default]
+    Lf,
+    /// Use `\r\n` for line ending (Windows-style)
+    CrLf,
+    /// Use `\r` for line ending (many legacy systems)
+    Cr,
+    /// Use `\n\r` for line ending (some legacy systems)
+    LfCr,
+    /// No line ending
+    None,
+}
+
+impl LineEnding {
+    /// Gets the string representation of the [`LineEnding`].
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Lf => "\n",
+            Self::CrLf => "\r\n",
+            Self::Cr => "\r",
+            Self::LfCr => "\n\r",
+            Self::None => "",
+        }
+    }
+}
diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs
index 7032324a5a..c039672e0f 100644
--- a/examples/editor/src/main.rs
+++ b/examples/editor/src/main.rs
@@ -117,8 +117,16 @@ impl Editor {
                 } else {
                     self.is_loading = true;
 
+                    let mut text = self.content.text();
+
+                    if let Some(ending) = self.content.line_ending() {
+                        if !text.ends_with(ending.as_str()) {
+                            text.push_str(ending.as_str());
+                        }
+                    }
+
                     Task::perform(
-                        save_file(self.file.clone(), self.content.text()),
+                        save_file(self.file.clone(), text),
                         Message::FileSaved,
                     )
                 }
diff --git a/graphics/src/text/editor.rs b/graphics/src/text/editor.rs
index 1f1d0050c8..c73d189c8e 100644
--- a/graphics/src/text/editor.rs
+++ b/graphics/src/text/editor.rs
@@ -9,6 +9,7 @@ use crate::text;
 
 use cosmic_text::Edit as _;
 
+use std::borrow::Cow;
 use std::fmt;
 use std::sync::{self, Arc};
 
@@ -89,11 +90,17 @@ impl editor::Editor for Editor {
             || (buffer.lines.len() == 1 && buffer.lines[0].text().is_empty())
     }
 
-    fn line(&self, index: usize) -> Option<&str> {
-        self.buffer()
-            .lines
-            .get(index)
-            .map(cosmic_text::BufferLine::text)
+    fn line(&self, index: usize) -> Option<editor::Line<'_>> {
+        self.buffer().lines.get(index).map(|line| editor::Line {
+            text: Cow::Borrowed(line.text()),
+            ending: match line.ending() {
+                cosmic_text::LineEnding::Lf => editor::LineEnding::Lf,
+                cosmic_text::LineEnding::CrLf => editor::LineEnding::CrLf,
+                cosmic_text::LineEnding::Cr => editor::LineEnding::Cr,
+                cosmic_text::LineEnding::LfCr => editor::LineEnding::LfCr,
+                cosmic_text::LineEnding::None => editor::LineEnding::None,
+            },
+        })
     }
 
     fn line_count(&self) -> usize {
diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs
index ad852ce984..a347076861 100644
--- a/widget/src/text_editor.rs
+++ b/widget/src/text_editor.rs
@@ -50,12 +50,13 @@ use crate::core::{
     Rectangle, Shell, Size, SmolStr, Theme, Vector,
 };
 
+use std::borrow::Cow;
 use std::cell::RefCell;
 use std::fmt;
 use std::ops::DerefMut;
 use std::sync::Arc;
 
-pub use text::editor::{Action, Edit, Motion};
+pub use text::editor::{Action, Edit, Line, LineEnding, Motion};
 
 /// A multi-line text input.
 ///
@@ -349,69 +350,47 @@ where
     }
 
     /// Returns the text of the line at the given index, if it exists.
-    pub fn line(
-        &self,
-        index: usize,
-    ) -> Option<impl std::ops::Deref<Target = str> + '_> {
-        std::cell::Ref::filter_map(self.0.borrow(), |internal| {
-            internal.editor.line(index)
+    pub fn line(&self, index: usize) -> Option<Line<'_>> {
+        let internal = self.0.borrow();
+        let line = internal.editor.line(index)?;
+
+        Some(Line {
+            text: Cow::Owned(line.text.into_owned()),
+            ending: line.ending,
         })
-        .ok()
     }
 
     /// Returns an iterator of the text of the lines in the [`Content`].
-    pub fn lines(
-        &self,
-    ) -> impl Iterator<Item = impl std::ops::Deref<Target = str> + '_> {
-        struct Lines<'a, Renderer: text::Renderer> {
-            internal: std::cell::Ref<'a, Internal<Renderer>>,
-            current: usize,
-        }
-
-        impl<'a, Renderer: text::Renderer> Iterator for Lines<'a, Renderer> {
-            type Item = std::cell::Ref<'a, str>;
-
-            fn next(&mut self) -> Option<Self::Item> {
-                let line = std::cell::Ref::filter_map(
-                    std::cell::Ref::clone(&self.internal),
-                    |internal| internal.editor.line(self.current),
-                )
-                .ok()?;
-
-                self.current += 1;
-
-                Some(line)
-            }
-        }
-
-        Lines {
-            internal: self.0.borrow(),
-            current: 0,
-        }
+    pub fn lines(&self) -> impl Iterator<Item = Line<'_>> {
+        (0..)
+            .map(|i| self.line(i))
+            .take_while(Option::is_some)
+            .flatten()
     }
 
     /// Returns the text of the [`Content`].
-    ///
-    /// Lines are joined with `'\n'`.
     pub fn text(&self) -> String {
-        let mut text = self.lines().enumerate().fold(
-            String::new(),
-            |mut contents, (i, line)| {
-                if i > 0 {
-                    contents.push('\n');
-                }
+        let mut contents = String::new();
+        let mut lines = self.lines().peekable();
 
-                contents.push_str(&line);
+        while let Some(line) = lines.next() {
+            contents.push_str(&line.text);
 
-                contents
-            },
-        );
-
-        if !text.ends_with('\n') {
-            text.push('\n');
+            if lines.peek().is_some() {
+                contents.push_str(if line.ending == LineEnding::None {
+                    LineEnding::default().as_str()
+                } else {
+                    line.ending.as_str()
+                });
+            }
         }
 
-        text
+        contents
+    }
+
+    /// Returns the kind of [`LineEnding`] used for separating lines in the [`Content`].
+    pub fn line_ending(&self) -> Option<LineEnding> {
+        Some(self.line(0)?.ending)
     }
 
     /// Returns the selected text of the [`Content`].


Test patch: